🔒 Added uuid verification to member endpoints not requiring a session
ref https://linear.app/tryghost/issue/ENG-1364 ref https://linear.app/tryghost/issue/ENG-1464 - credits to https://github.com/1337Nerd - added a hashed value to endpoints that do not require a member sign in in order to verify the source of the link and resulting request - added redirect to sign in page when trying to access newsletter management
This commit is contained in:
parent
beb70e9c11
commit
dac2561252
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tryghost/portal",
|
"name": "@tryghost/portal",
|
||||||
"version": "2.38.0",
|
"version": "2.39.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -211,7 +211,7 @@ export default class App extends React.Component {
|
|||||||
async fetchData() {
|
async fetchData() {
|
||||||
const {site: apiSiteData, member} = await this.fetchApiData();
|
const {site: apiSiteData, member} = await this.fetchApiData();
|
||||||
const {site: devSiteData, ...restDevData} = this.fetchDevData();
|
const {site: devSiteData, ...restDevData} = this.fetchDevData();
|
||||||
const {site: linkSiteData, ...restLinkData} = this.fetchLinkData(apiSiteData);
|
const {site: linkSiteData, ...restLinkData} = this.fetchLinkData(apiSiteData, member);
|
||||||
const {site: previewSiteData, ...restPreviewData} = this.fetchPreviewData();
|
const {site: previewSiteData, ...restPreviewData} = this.fetchPreviewData();
|
||||||
const {site: notificationSiteData, ...restNotificationData} = this.fetchNotificationData();
|
const {site: notificationSiteData, ...restNotificationData} = this.fetchNotificationData();
|
||||||
let page = '';
|
let page = '';
|
||||||
@ -420,18 +420,32 @@ export default class App extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch state from Portal Links */
|
/** Fetch state from Portal Links */
|
||||||
fetchLinkData(site) {
|
fetchLinkData(site, member) {
|
||||||
const qParams = new URLSearchParams(window.location.search);
|
const qParams = new URLSearchParams(window.location.search);
|
||||||
if (qParams.get('uuid') && qParams.get('action') === 'unsubscribe') {
|
if (qParams.get('action') === 'unsubscribe') {
|
||||||
return {
|
// if the user is unsubscribing from a newsletter with an old unsubscribe link that we can't validate, push them to newsletter mgmt where they have to log in
|
||||||
showPopup: true,
|
if (qParams.get('key') && qParams.get('uuid')) {
|
||||||
page: 'unsubscribe',
|
return {
|
||||||
pageData: {
|
showPopup: true,
|
||||||
uuid: qParams.get('uuid'),
|
page: 'unsubscribe',
|
||||||
newsletterUuid: qParams.get('newsletter'),
|
pageData: {
|
||||||
comments: qParams.get('comments')
|
uuid: qParams.get('uuid'),
|
||||||
}
|
key: qParams.get('key'),
|
||||||
};
|
newsletterUuid: qParams.get('newsletter'),
|
||||||
|
comments: qParams.get('comments')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else { // any malformed unsubscribe links should simply go to email prefs
|
||||||
|
return {
|
||||||
|
showPopup: true,
|
||||||
|
page: 'accountEmail',
|
||||||
|
pageData: {
|
||||||
|
newsletterUuid: qParams.get('newsletter'),
|
||||||
|
action: 'unsubscribe',
|
||||||
|
redirect: site.url + '#/portal/account/newsletters'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasRecommendations({site}) && qParams.get('action') === 'signup' && qParams.get('success') === 'true') {
|
if (hasRecommendations({site}) && qParams.get('action') === 'signup' && qParams.get('success') === 'true') {
|
||||||
@ -453,19 +467,31 @@ export default class App extends React.Component {
|
|||||||
const linkRegex = /^\/portal\/?(?:\/(\w+(?:\/\w+)*))?\/?$/;
|
const linkRegex = /^\/portal\/?(?:\/(\w+(?:\/\w+)*))?\/?$/;
|
||||||
const feedbackRegex = /^\/feedback\/(\w+?)\/(\w+?)\/?$/;
|
const feedbackRegex = /^\/feedback\/(\w+?)\/(\w+?)\/?$/;
|
||||||
|
|
||||||
if (path && feedbackRegex.test(path) && hashQuery.get('uuid')) {
|
if (path && feedbackRegex.test(path)) {
|
||||||
const [, postId, scoreString] = path.match(feedbackRegex);
|
const [, postId, scoreString] = path.match(feedbackRegex);
|
||||||
const score = parseInt(scoreString);
|
const score = parseInt(scoreString);
|
||||||
if (score === 1 || score === 0) {
|
if (score === 1 || score === 0) {
|
||||||
return {
|
// if logged in, submit feedback
|
||||||
showPopup: true,
|
if (member || (hashQuery.get('uuid') && hashQuery.get('key'))) {
|
||||||
page: 'feedback',
|
return {
|
||||||
pageData: {
|
showPopup: true,
|
||||||
uuid: hashQuery.get('uuid'),
|
page: 'feedback',
|
||||||
postId,
|
pageData: {
|
||||||
score
|
uuid: member ? null : hashQuery.get('uuid'),
|
||||||
}
|
key: member ? null : hashQuery.get('key'),
|
||||||
};
|
postId,
|
||||||
|
score
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
showPopup: true,
|
||||||
|
page: 'signin',
|
||||||
|
pageData: {
|
||||||
|
redirect: site.url + `#/feedback/${postId}/${score}/`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (path && linkRegex.test(path)) {
|
if (path && linkRegex.test(path)) {
|
||||||
|
@ -1,22 +1,84 @@
|
|||||||
import AppContext from '../../AppContext';
|
import AppContext from '../../AppContext';
|
||||||
import {useContext, useEffect, useState} from 'react';
|
import {useContext, useEffect, useState} from 'react';
|
||||||
import {isPaidMember} from '../../utils/helpers';
|
import {isPaidMember, getSiteNewsletters} from '../../utils/helpers';
|
||||||
|
import {SYNTAX_I18NEXT} from '@doist/react-interpolate';
|
||||||
import NewsletterManagement from '../common/NewsletterManagement';
|
import NewsletterManagement from '../common/NewsletterManagement';
|
||||||
|
import Interpolate from '@doist/react-interpolate';
|
||||||
|
|
||||||
export default function AccountEmailPage() {
|
export default function AccountEmailPage() {
|
||||||
const {member, onAction, site, t} = useContext(AppContext);
|
const {member, onAction, site, t, pageData} = useContext(AppContext);
|
||||||
|
let newsletterUuid;
|
||||||
|
let action;
|
||||||
|
if (pageData) {
|
||||||
|
newsletterUuid = pageData.newsletterUuid;
|
||||||
|
action = pageData.action;
|
||||||
|
}
|
||||||
|
const [hasInteracted, setHasInteracted] = useState(true);
|
||||||
|
const siteNewsletters = getSiteNewsletters({site});
|
||||||
|
|
||||||
|
// Redirect to signin page if member is not available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
onAction('switchPage', {
|
onAction('switchPage', {
|
||||||
page: 'signin',
|
page: 'signin'
|
||||||
pageData: {
|
|
||||||
redirect: window.location.href // This includes the search/fragment of the URL (#/portal/account) which is missing from the default referer header
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [member, onAction]);
|
}, [member, onAction]);
|
||||||
|
|
||||||
|
// this results in an infinite loop, needs to run only once...
|
||||||
|
useEffect(() => {
|
||||||
|
// attempt auto-unsubscribe if we were redirected here from an unsubscribe link
|
||||||
|
if (newsletterUuid && action === 'unsubscribe') {
|
||||||
|
// Filter out the newsletter that matches the uuid
|
||||||
|
const remainingNewsletterSubscriptions = member?.newsletters.filter(n => n.uuid !== newsletterUuid);
|
||||||
|
setSubscribedNewsletters(remainingNewsletterSubscriptions);
|
||||||
|
setHasInteracted(false); // this shows the dialog
|
||||||
|
onAction('updateNewsletterPreference', {newsletters: remainingNewsletterSubscriptions});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const HeaderNotification = () => {
|
||||||
|
if (pageData.comments && commentsEnabled) {
|
||||||
|
const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}>
|
||||||
|
<Interpolate
|
||||||
|
syntax={SYNTAX_I18NEXT}
|
||||||
|
string={t('{{memberEmail}} will no longer receive emails when someone replies to your comments.')}
|
||||||
|
mapping={{
|
||||||
|
memberEmail: <strong>{member?.email}</strong>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const unsubscribedNewsletter = siteNewsletters?.find((d) => {
|
||||||
|
return d.uuid === pageData.newsletterUuid;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unsubscribedNewsletter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}>
|
||||||
|
<Interpolate
|
||||||
|
syntax={SYNTAX_I18NEXT}
|
||||||
|
string={t('{{memberEmail}} will no longer receive {{newsletterName}} newsletter.')}
|
||||||
|
mapping={{
|
||||||
|
memberEmail: <strong>{member?.email}</strong>,
|
||||||
|
newsletterName: <strong>{unsubscribedNewsletter?.name}</strong>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const defaultSubscribedNewsletters = [...(member?.newsletters || [])];
|
const defaultSubscribedNewsletters = [...(member?.newsletters || [])];
|
||||||
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultSubscribedNewsletters);
|
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultSubscribedNewsletters);
|
||||||
const {comments_enabled: commentsEnabled} = site;
|
const {comments_enabled: commentsEnabled} = site;
|
||||||
@ -28,7 +90,7 @@ export default function AccountEmailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NewsletterManagement
|
<NewsletterManagement
|
||||||
notification={null}
|
notification={newsletterUuid ? HeaderNotification : null}
|
||||||
subscribedNewsletters={subscribedNewsletters}
|
subscribedNewsletters={subscribedNewsletters}
|
||||||
updateSubscribedNewsletters={(updatedNewsletters) => {
|
updateSubscribedNewsletters={(updatedNewsletters) => {
|
||||||
setSubscribedNewsletters(updatedNewsletters);
|
setSubscribedNewsletters(updatedNewsletters);
|
||||||
|
@ -112,6 +112,6 @@ describe('Account Email Page', () => {
|
|||||||
newsletters: newsletterData
|
newsletters: newsletterData
|
||||||
});
|
});
|
||||||
const {mockOnActionFn} = setup({site: siteData, member: null});
|
const {mockOnActionFn} = setup({site: siteData, member: null});
|
||||||
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin', pageData: {redirect: window.location.href}});
|
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin'});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -258,9 +258,9 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sendFeedback({siteUrl, uuid, postId, score}) {
|
async function sendFeedback({siteUrl, uuid, key, postId, score}, api) {
|
||||||
const ghostApi = setupGhostApi({siteUrl});
|
const ghostApi = api || setupGhostApi({siteUrl});
|
||||||
await ghostApi.feedback.add({uuid, postId, score});
|
await ghostApi.feedback.add({uuid, postId, key, score});
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoadingFeedbackView = ({action, score}) => {
|
const LoadingFeedbackView = ({action, score}) => {
|
||||||
@ -301,11 +301,10 @@ const ConfirmFeedback = ({positive}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FeedbackPage() {
|
export default function FeedbackPage() {
|
||||||
const {site, pageData, member, t} = useContext(AppContext);
|
const {site, pageData, member, t, api} = useContext(AppContext);
|
||||||
const {uuid, postId, score: initialScore} = pageData;
|
const {uuid, key, postId, score: initialScore} = pageData;
|
||||||
const [score, setScore] = useState(initialScore);
|
const [score, setScore] = useState(initialScore);
|
||||||
const positive = score === 1;
|
const positive = score === 1;
|
||||||
|
|
||||||
const isLoggedIn = !!member;
|
const isLoggedIn = !!member;
|
||||||
|
|
||||||
const [confirmed, setConfirmed] = useState(isLoggedIn);
|
const [confirmed, setConfirmed] = useState(isLoggedIn);
|
||||||
@ -315,7 +314,7 @@ export default function FeedbackPage() {
|
|||||||
const doSendFeedback = async (selectedScore) => {
|
const doSendFeedback = async (selectedScore) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await sendFeedback({siteUrl: site.url, uuid, postId, score: selectedScore});
|
await sendFeedback({siteUrl: site.url, uuid, key, postId, score: selectedScore}, api);
|
||||||
setScore(selectedScore);
|
setScore(selectedScore);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = HumanReadableError.getMessageFromError(e, t('There was a problem submitting your feedback. Please try again a little later.'));
|
const text = HumanReadableError.getMessageFromError(e, t('There was a problem submitting your feedback. Please try again a little later.'));
|
||||||
@ -341,6 +340,5 @@ export default function FeedbackPage() {
|
|||||||
return <LoadingFeedbackView action={doSendFeedback} score={score} />;
|
return <LoadingFeedbackView action={doSendFeedback} score={score} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<ConfirmFeedback positive={positive} />);
|
return (<ConfirmFeedback positive={positive} />);
|
||||||
}
|
}
|
||||||
|
40
apps/portal/src/components/pages/FeedbackPage.test.js
Normal file
40
apps/portal/src/components/pages/FeedbackPage.test.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {getMemberData, getSiteData} from '../../utils/fixtures-generator';
|
||||||
|
import {render} from '../../utils/test-utils';
|
||||||
|
import FeedbackPage from './FeedbackPage';
|
||||||
|
|
||||||
|
const setup = (overrides) => {
|
||||||
|
const {mockOnActionFn, ...utils} = render(
|
||||||
|
<FeedbackPage />,
|
||||||
|
{
|
||||||
|
overrideContext: {
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mockOnActionFn,
|
||||||
|
...utils
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('FeedbackPage', () => {
|
||||||
|
const siteData = getSiteData();
|
||||||
|
const posts = siteData.posts;
|
||||||
|
const member = getMemberData();
|
||||||
|
|
||||||
|
// we need the API to actually test the component, so the bulk of tests will be in the FeedbackFlow file
|
||||||
|
test('renders', () => {
|
||||||
|
// mock what the larger app would process and set
|
||||||
|
const pageData = {
|
||||||
|
uuid: member.uuid,
|
||||||
|
key: 'key',
|
||||||
|
postId: posts[0].id,
|
||||||
|
score: 1
|
||||||
|
};
|
||||||
|
const {getByTestId} = setup({pageData});
|
||||||
|
|
||||||
|
const loaderIcon = getByTestId('loaderIcon');
|
||||||
|
|
||||||
|
expect(loaderIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -6,7 +6,7 @@ export default class LoadingPage extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div style={{display: 'flex', flexDirection: 'column', color: '#313131'}}>
|
<div style={{display: 'flex', flexDirection: 'column', color: '#313131'}}>
|
||||||
<div style={{paddingLeft: '16px', paddingRight: '16px', paddingTop: '12px', height: '50px'}}>
|
<div style={{paddingLeft: '16px', paddingRight: '16px', paddingTop: '12px', height: '50px'}}>
|
||||||
<LoaderIcon className={'gh-portal-loadingicon dark'} />
|
<LoaderIcon className={'gh-portal-loadingicon dark'} data-testid="loaderIcon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -32,9 +32,9 @@ function AccountHeader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMemberNewsletters({api, memberUuid, newsletters, enableCommentNotifications}) {
|
async function updateMemberNewsletters({api, memberUuid, key, newsletters, enableCommentNotifications}) {
|
||||||
try {
|
try {
|
||||||
return await api.member.updateNewsletters({uuid: memberUuid, newsletters, enableCommentNotifications});
|
return await api.member.updateNewsletters({uuid: memberUuid, key, newsletters, enableCommentNotifications});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore auto unsubscribe error
|
// ignore auto unsubscribe error
|
||||||
}
|
}
|
||||||
@ -62,7 +62,7 @@ export default function UnsubscribePage() {
|
|||||||
// when we have a member logged in, we need to update the newsletters in the context
|
// when we have a member logged in, we need to update the newsletters in the context
|
||||||
onAction('updateNewsletterPreference', {newsletters});
|
onAction('updateNewsletterPreference', {newsletters});
|
||||||
} else {
|
} else {
|
||||||
await updateMemberNewsletters({api, memberUuid: pageData.uuid, newsletters});
|
await updateMemberNewsletters({api, memberUuid: pageData.uuid, key: pageData.key, newsletters});
|
||||||
}
|
}
|
||||||
setSubscribedNewsletters(newsletters);
|
setSubscribedNewsletters(newsletters);
|
||||||
};
|
};
|
||||||
@ -74,7 +74,7 @@ export default function UnsubscribePage() {
|
|||||||
await onAction('updateNewsletterPreference', {enableCommentNotifications: enabled});
|
await onAction('updateNewsletterPreference', {enableCommentNotifications: enabled});
|
||||||
updatedData = {...loggedInMember, enable_comment_notifications: enabled};
|
updatedData = {...loggedInMember, enable_comment_notifications: enabled};
|
||||||
} else {
|
} else {
|
||||||
updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, enableCommentNotifications: enabled});
|
updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, key: pageData.key, enableCommentNotifications: enabled});
|
||||||
}
|
}
|
||||||
setMember(updatedData);
|
setMember(updatedData);
|
||||||
};
|
};
|
||||||
@ -102,7 +102,7 @@ export default function UnsubscribePage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
let memberData;
|
let memberData;
|
||||||
try {
|
try {
|
||||||
memberData = await api.member.newsletters({uuid: pageData.uuid});
|
memberData = await api.member.newsletters({uuid: pageData.uuid, key: pageData.key});
|
||||||
setMember(memberData ?? null);
|
setMember(memberData ?? null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import App from '../App.js';
|
import App from '../App.js';
|
||||||
import {appRender, fireEvent, within} from '../utils/test-utils';
|
import {appRender, fireEvent, within, waitFor} from '../utils/test-utils';
|
||||||
import {newsletters as Newsletters, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
|
import {newsletters as Newsletters, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
|
||||||
import setupGhostApi from '../utils/api.js';
|
import setupGhostApi from '../utils/api.js';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@ -92,13 +92,16 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
// unsure why fireEvent has no effect here
|
// unsure why fireEvent has no effect here
|
||||||
await userEvent.click(manageSubscriptionsButton);
|
await userEvent.click(manageSubscriptionsButton);
|
||||||
|
|
||||||
const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
|
await waitFor(() => {
|
||||||
const newsletter2 = within(popupIframeDocument).queryByText('Newsletter 2');
|
const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
|
||||||
const emailPreferences = within(popupIframeDocument).queryByText('Email preferences');
|
const newsletter2 = within(popupIframeDocument).queryByText('Newsletter 2');
|
||||||
|
const emailPreferences = within(popupIframeDocument).queryByText('Email preferences');
|
||||||
|
|
||||||
expect(newsletter1).toBeInTheDocument();
|
// within(popupIframeDocument).getByText('dslkfjsdlk');
|
||||||
expect(newsletter2).toBeInTheDocument();
|
expect(newsletter1).toBeInTheDocument();
|
||||||
expect(emailPreferences).toBeInTheDocument();
|
expect(newsletter2).toBeInTheDocument();
|
||||||
|
expect(emailPreferences).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggle subscribing to a newsletter', async () => {
|
test('toggle subscribing to a newsletter', async () => {
|
||||||
@ -176,7 +179,7 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
test('unsubscribe via email link while not logged in', async () => {
|
test('unsubscribe via email link while not logged in', async () => {
|
||||||
// Mock window.location
|
// Mock window.location
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
|
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}&key=hashedMemberUuid`),
|
||||||
writable: true
|
writable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -186,10 +189,16 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
newsletters: Newsletters
|
newsletters: Newsletters
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// Verify the API was hit to collect subscribed newsletters
|
||||||
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
|
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
|
||||||
{uuid: FixtureMember.subbedToNewsletter.uuid}
|
{
|
||||||
|
uuid: FixtureMember.subbedToNewsletter.uuid,
|
||||||
|
key: 'hashedMemberUuid'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
expect(popupFrame).toBeInTheDocument();
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(within(popupIframeDocument).getByText(/will no longer receive/)).toBeInTheDocument();
|
||||||
// Verify the local state shows the newsletter as unsubscribed
|
// Verify the local state shows the newsletter as unsubscribed
|
||||||
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
|
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
|
||||||
let newsletter1Toggle = newsletterToggles[0];
|
let newsletter1Toggle = newsletterToggles[0];
|
||||||
@ -204,7 +213,7 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
test('unsubscribe via email link while logged in', async () => {
|
test('unsubscribe via email link while logged in', async () => {
|
||||||
// Mock window.location
|
// Mock window.location
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
|
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}&key=hashedMemberUuid`),
|
||||||
writable: true
|
writable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,13 +225,18 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
|
|
||||||
// Verify the API was hit to collect subscribed newsletters
|
// Verify the API was hit to collect subscribed newsletters
|
||||||
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
|
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
|
||||||
{uuid: FixtureMember.subbedToNewsletter.uuid}
|
{
|
||||||
|
uuid: FixtureMember.subbedToNewsletter.uuid,
|
||||||
|
key: 'hashedMemberUuid'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// Verify the local state shows the newsletter as unsubscribed
|
// Verify the local state shows the newsletter as unsubscribed
|
||||||
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
|
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
|
||||||
let newsletter1Toggle = newsletterToggles[0];
|
let newsletter1Toggle = newsletterToggles[0];
|
||||||
let newsletter2Toggle = newsletterToggles[1];
|
let newsletter2Toggle = newsletterToggles[1];
|
||||||
|
|
||||||
|
expect(within(popupIframeDocument).getByText(/will no longer receive/)).toBeInTheDocument();
|
||||||
|
|
||||||
expect(newsletter1Toggle).toBeInTheDocument();
|
expect(newsletter1Toggle).toBeInTheDocument();
|
||||||
expect(newsletter2Toggle).toBeInTheDocument();
|
expect(newsletter2Toggle).toBeInTheDocument();
|
||||||
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
|
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
|
||||||
@ -253,5 +267,26 @@ describe('Newsletter Subscriptions', () => {
|
|||||||
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
|
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
|
||||||
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
|
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unsubscribe link without a key param', async () => {
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup({
|
||||||
|
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
|
||||||
|
member: FixtureMember.subbedToNewsletter,
|
||||||
|
newsletters: Newsletters
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Verify the popup frame is not shown
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
// Verify the API was hit to collect subscribed newsletters
|
||||||
|
expect(ghostApi.member.newsletters).not.toHaveBeenCalled();
|
||||||
|
// expect sign in page
|
||||||
|
expect(within(popupIframeDocument).queryByText('Sign in')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
181
apps/portal/src/tests/FeedbackFlow.test.js
Normal file
181
apps/portal/src/tests/FeedbackFlow.test.js
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import App from '../App.js';
|
||||||
|
import {appRender, fireEvent, waitFor, within} from '../utils/test-utils';
|
||||||
|
import setupGhostApi from '../utils/api.js';
|
||||||
|
import {getMemberData, getPostsData, getSiteData} from '../utils/fixtures-generator.js';
|
||||||
|
|
||||||
|
const siteData = getSiteData();
|
||||||
|
const memberData = getMemberData();
|
||||||
|
const posts = getPostsData();
|
||||||
|
const postSlug = posts[0].slug;
|
||||||
|
const postId = posts[0].id;
|
||||||
|
|
||||||
|
const setup = async (site = siteData, member = memberData, loggedOut = false, api = {}) => {
|
||||||
|
const ghostApi = setupGhostApi({siteUrl: site.url});
|
||||||
|
ghostApi.init = api?.init || jest.fn(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
site,
|
||||||
|
member: loggedOut ? null : member
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ghostApi.feedback.add = api?.add || jest.fn(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
feedback: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
postId: 1,
|
||||||
|
memberId: member ? member.uuid : null,
|
||||||
|
score: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const utils = appRender(
|
||||||
|
<App api={ghostApi} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: this await is CRITICAL otherwise the iframe won't be loaded
|
||||||
|
const popupFrame = await utils.findByTitle(/portal-popup/i);
|
||||||
|
const popupIframeDocument = popupFrame.contentDocument;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ghostApi,
|
||||||
|
popupIframeDocument,
|
||||||
|
popupFrame,
|
||||||
|
...utils
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Feedback Submission Flow', () => {
|
||||||
|
describe('Valid feedback URL', () => {
|
||||||
|
describe('Logged in', () => {
|
||||||
|
test('Autosubmits feedback', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup();
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
within(popupIframeDocument).getByText('Thanks for the feedback!');
|
||||||
|
within(popupIframeDocument).getByText('Your input helps shape what gets published.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Autosubmits feedback w/o uuid or key params', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup();
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
|
||||||
|
within(popupIframeDocument).getByText('Thanks for the feedback!');
|
||||||
|
within(popupIframeDocument).getByText('Your input helps shape what gets published.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logged out', () => {
|
||||||
|
test('Requires confirmation', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText('Give feedback on this post')).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText('More like this')).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText('Less like this')).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
const submitBtn = within(popupIframeDocument).getByText('Submit feedback');
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// the re-render loop is slow to get to the final state
|
||||||
|
await waitFor(() => {
|
||||||
|
within(popupIframeDocument).getByText('Thanks for the feedback!');
|
||||||
|
within(popupIframeDocument).getByText('Your input helps shape what gets published.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Requires login without key', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sign in/)).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sign up/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Requires login without uuid or key', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sign in/)).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sign up/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error on fail to submit', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const mockApi = {
|
||||||
|
add: jest.fn(() => {
|
||||||
|
return Promise.reject(new Error('Failed to submit feedback'));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, memberData, false, mockApi);
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sorry/)).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/There was a problem submitting your feedback/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid feedback URL', () => {
|
||||||
|
test('Redirects logged in members to account settings', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/postslughere/#/feedback/1/1/1/`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {popupFrame, popupIframeDocument} = await setup();
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/Your account/)).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/Sign out/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Redirects logged out users to sign up', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: new URL(`${siteData.url}/postslughere/#/feedback/1/1/1/`),
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
const {popupFrame, popupIframeDocument} = await setup(siteData, null, true);
|
||||||
|
|
||||||
|
expect(popupFrame).toBeInTheDocument();
|
||||||
|
// takes to sign up
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(popupIframeDocument).getByText(/Name/)).toBeInTheDocument();
|
||||||
|
expect(within(popupIframeDocument).getByText(/Email/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -134,10 +134,11 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
api.feedback = {
|
api.feedback = {
|
||||||
async add({uuid, postId, score}) {
|
async add({uuid, key, postId, score}) {
|
||||||
let url = endpointFor({type: 'members', resource: 'feedback'});
|
let url = endpointFor({type: 'members', resource: 'feedback'});
|
||||||
url = url + `?uuid=${uuid}`;
|
if (uuid && key) { // only necessary if not logged in, and both are required if so
|
||||||
|
url = url + `?uuid=${uuid}&key=${key}`;
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
feedback: [
|
feedback: [
|
||||||
{
|
{
|
||||||
@ -303,9 +304,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async newsletters({uuid}) {
|
async newsletters({uuid, key}) {
|
||||||
let url = endpointFor({type: 'members', resource: `member/newsletters`});
|
let url = endpointFor({type: 'members', resource: `member/newsletters`});
|
||||||
url = url + `?uuid=${uuid}`;
|
url = url + `?uuid=${uuid}&key=${key}`;
|
||||||
return makeRequest({
|
return makeRequest({
|
||||||
url,
|
url,
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
@ -317,9 +318,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateNewsletters({uuid, newsletters, enableCommentNotifications}) {
|
async updateNewsletters({uuid, newsletters, key, enableCommentNotifications}) {
|
||||||
let url = endpointFor({type: 'members', resource: `member/newsletters`});
|
let url = endpointFor({type: 'members', resource: `member/newsletters`});
|
||||||
url = url + `?uuid=${uuid}`;
|
url = url + `?uuid=${uuid}&key=${key}`;
|
||||||
const body = {
|
const body = {
|
||||||
newsletters
|
newsletters
|
||||||
};
|
};
|
||||||
|
@ -41,6 +41,7 @@ export function getSiteData({
|
|||||||
membersSupportAddress: members_support_address = 'support@example.com',
|
membersSupportAddress: members_support_address = 'support@example.com',
|
||||||
editorDefaultEmailRecipients: editor_default_email_recipients = 'visibility',
|
editorDefaultEmailRecipients: editor_default_email_recipients = 'visibility',
|
||||||
newsletters = [],
|
newsletters = [],
|
||||||
|
posts = getPostsData(),
|
||||||
commentsEnabled,
|
commentsEnabled,
|
||||||
recommendations = [],
|
recommendations = [],
|
||||||
recommendationsEnabled
|
recommendationsEnabled
|
||||||
@ -71,7 +72,8 @@ export function getSiteData({
|
|||||||
newsletters,
|
newsletters,
|
||||||
recommendations,
|
recommendations,
|
||||||
recommendations_enabled: !!recommendationsEnabled,
|
recommendations_enabled: !!recommendationsEnabled,
|
||||||
editor_default_email_recipients
|
editor_default_email_recipients,
|
||||||
|
posts
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +177,33 @@ export function getNewslettersData({numOfNewsletters = 3} = {}) {
|
|||||||
return newsletters.slice(0, numOfNewsletters);
|
return newsletters.slice(0, numOfNewsletters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPostsData({numOfPosts = 3} = {}) {
|
||||||
|
const posts = [];
|
||||||
|
for (let i = 0; i < numOfPosts; i++) {
|
||||||
|
posts.push(getPostData({
|
||||||
|
title: `Post ${i + 1}`,
|
||||||
|
slug: `post-${i + 1}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return posts.slice(0, numOfPosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostData({
|
||||||
|
id = `post_${objectId()}`,
|
||||||
|
title = 'Post',
|
||||||
|
excerpt = 'Post excerpt',
|
||||||
|
slug = 'post',
|
||||||
|
featured = false
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
slug,
|
||||||
|
featured
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getProductsData({numOfProducts = 3} = {}) {
|
export function getProductsData({numOfProducts = 3} = {}) {
|
||||||
const products = [
|
const products = [
|
||||||
getProductData({
|
getProductData({
|
||||||
|
@ -121,6 +121,29 @@ export const site = getSiteData({
|
|||||||
subscribe_on_signup: false,
|
subscribe_on_signup: false,
|
||||||
paid: false
|
paid: false
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
posts: [
|
||||||
|
{
|
||||||
|
id: 'post_66aacfe061c94e10eb6e4fc1',
|
||||||
|
title: 'Post 1',
|
||||||
|
excerpt: 'Post excerpt',
|
||||||
|
slug: 'post-1',
|
||||||
|
featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post_66aacfe04f14b8dbb56c5721',
|
||||||
|
title: 'Post 2',
|
||||||
|
excerpt: 'Post excerpt',
|
||||||
|
slug: 'post-2',
|
||||||
|
featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post_66aacfe03d609460819af18c',
|
||||||
|
title: 'Post 3',
|
||||||
|
excerpt: 'Post excerpt',
|
||||||
|
slug: 'post-3',
|
||||||
|
featured: false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,15 +17,16 @@ class AudienceFeedbackService {
|
|||||||
* @param {string} uuid
|
* @param {string} uuid
|
||||||
* @param {string} postId
|
* @param {string} postId
|
||||||
* @param {0 | 1} score
|
* @param {0 | 1} score
|
||||||
|
* @param {string} key - hashed uuid value
|
||||||
*/
|
*/
|
||||||
buildLink(uuid, postId, score) {
|
buildLink(uuid, postId, score, key) {
|
||||||
let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true});
|
let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true});
|
||||||
|
|
||||||
if (postUrl.match(/\/404\//)) {
|
if (postUrl.match(/\/404\//)) {
|
||||||
postUrl = this.#baseURL;
|
postUrl = this.#baseURL;
|
||||||
}
|
}
|
||||||
const url = new URL(postUrl);
|
const url = new URL(postUrl);
|
||||||
url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}`;
|
url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}&key=${encodeURIComponent(key)}`;
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,8 @@ describe('audienceFeedbackService', function () {
|
|||||||
uuid: '7b11de3c-dff9-4563-82ae-a281122d201d',
|
uuid: '7b11de3c-dff9-4563-82ae-a281122d201d',
|
||||||
postId: '634fc3901e0a291855d8b135',
|
postId: '634fc3901e0a291855d8b135',
|
||||||
postTitle: 'somepost',
|
postTitle: 'somepost',
|
||||||
score: 1
|
score: 1,
|
||||||
|
key: 'somekey'
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('build link', function () {
|
describe('build link', function () {
|
||||||
@ -23,8 +24,8 @@ describe('audienceFeedbackService', function () {
|
|||||||
baseURL: new URL('https://localhost:2368')
|
baseURL: new URL('https://localhost:2368')
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score);
|
const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key);
|
||||||
const expectedLink = `https://localhost:2368/${mockData.postTitle}/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}`;
|
const expectedLink = `https://localhost:2368/${mockData.postTitle}/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`;
|
||||||
assert.equal(link.href, expectedLink);
|
assert.equal(link.href, expectedLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -37,8 +38,8 @@ describe('audienceFeedbackService', function () {
|
|||||||
baseURL: new URL('https://localhost:2368')
|
baseURL: new URL('https://localhost:2368')
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score);
|
const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key);
|
||||||
const expectedLink = `https://localhost:2368/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}`;
|
const expectedLink = `https://localhost:2368/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`;
|
||||||
assert.equal(link.href, expectedLink);
|
assert.equal(link.href, expectedLink);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,6 +3,8 @@ const url = require('url');
|
|||||||
const members = require('../../../../server/services/members');
|
const members = require('../../../../server/services/members');
|
||||||
const urlUtils = require('../../../../shared/url-utils');
|
const urlUtils = require('../../../../shared/url-utils');
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
|
const settingsHelpers = require('../../../../server/services/settings-helpers');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
module.exports = async function unsubscribeController(req, res) {
|
module.exports = async function unsubscribeController(req, res) {
|
||||||
debug('unsubscribeController');
|
debug('unsubscribeController');
|
||||||
@ -19,6 +21,17 @@ module.exports = async function unsubscribeController(req, res) {
|
|||||||
|
|
||||||
// Do an actual unsubscribe
|
// Do an actual unsubscribe
|
||||||
try {
|
try {
|
||||||
|
if (!query.key) {
|
||||||
|
logging.warn('[List-Unsubscribe] Unsubscribe failed due to missing verification key for ' + query.uuid);
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
const membersKey = settingsHelpers.getMembersValidationKey();
|
||||||
|
const memberHmac = crypto.createHmac('sha256', membersKey).update(query.uuid).digest('hex');
|
||||||
|
if (memberHmac !== query.key) {
|
||||||
|
logging.warn('[List-Unsubscribe] Unsubscribe failed due to invalid key for ' + query.uuid);
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
|
||||||
const member = await members.api.members.get({uuid: query.uuid}, {withRelated: ['newsletters']});
|
const member = await members.api.members.get({uuid: query.uuid}, {withRelated: ['newsletters']});
|
||||||
if (member) {
|
if (member) {
|
||||||
if (query.comments) {
|
if (query.comments) {
|
||||||
@ -60,6 +73,9 @@ module.exports = async function unsubscribeController(req, res) {
|
|||||||
if (query.comments) {
|
if (query.comments) {
|
||||||
redirectUrl.searchParams.append('comments', query.comments);
|
redirectUrl.searchParams.append('comments', query.comments);
|
||||||
}
|
}
|
||||||
|
if (query.key) {
|
||||||
|
redirectUrl.searchParams.append('key', query.key);
|
||||||
|
}
|
||||||
redirectUrl.searchParams.append('action', 'unsubscribe');
|
redirectUrl.searchParams.append('action', 'unsubscribe');
|
||||||
|
|
||||||
return res.redirect(302, redirectUrl.href);
|
return res.redirect(302, redirectUrl.href);
|
||||||
|
@ -15,10 +15,12 @@ const tpl = require('@tryghost/tpl');
|
|||||||
const onHeaders = require('on-headers');
|
const onHeaders = require('on-headers');
|
||||||
const tiersService = require('../tiers/service');
|
const tiersService = require('../tiers/service');
|
||||||
const config = require('../../../shared/config');
|
const config = require('../../../shared/config');
|
||||||
|
const settingsHelpers = require('../settings-helpers');
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
missingUuid: 'Missing uuid.',
|
missingUuid: 'Missing uuid.',
|
||||||
invalidUuid: 'Invalid uuid.'
|
invalidUuid: 'Invalid uuid.',
|
||||||
|
invalidKey: 'Invalid key.'
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFreeTier = async function getFreeTier() {
|
const getFreeTier = async function getFreeTier() {
|
||||||
@ -97,7 +99,7 @@ const loadMemberSession = async function loadMemberSession(req, res, next) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require member authentication, and make it possible to authenticate via uuid.
|
* Require member authentication, and make it possible to authenticate via uuid + hashed key.
|
||||||
* You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session.
|
* You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session.
|
||||||
*/
|
*/
|
||||||
const authMemberByUuid = async function authMemberByUuid(req, res, next) {
|
const authMemberByUuid = async function authMemberByUuid(req, res, next) {
|
||||||
@ -114,6 +116,22 @@ const authMemberByUuid = async function authMemberByUuid(req, res, next) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = req.query.key;
|
||||||
|
if (!key) {
|
||||||
|
throw new errors.UnauthorizedError({
|
||||||
|
message: tpl(messages.invalidKey)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// the request key is a hashed value from the member uuid and the members validation key so we can verify the source
|
||||||
|
// (only Ghost should be able to generate the key)
|
||||||
|
const memberHmac = crypto.createHmac('sha256', settingsHelpers.getMembersValidationKey()).update(uuid).digest('hex');
|
||||||
|
if (memberHmac !== key) {
|
||||||
|
throw new errors.UnauthorizedError({
|
||||||
|
message: tpl(messages.invalidKey)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const member = await membersService.api.memberBREADService.read({uuid});
|
const member = await membersService.api.memberBREADService.read({uuid});
|
||||||
if (!member) {
|
if (!member) {
|
||||||
throw new errors.UnauthorizedError({
|
throw new errors.UnauthorizedError({
|
||||||
@ -193,25 +211,14 @@ const deleteSuppression = async function deleteSuppression(req, res) {
|
|||||||
|
|
||||||
const getMemberNewsletters = async function getMemberNewsletters(req, res) {
|
const getMemberNewsletters = async function getMemberNewsletters(req, res) {
|
||||||
try {
|
try {
|
||||||
const memberUuid = req.query.uuid;
|
const memberData = req.member; // validation assumed
|
||||||
|
|
||||||
if (!memberUuid) {
|
|
||||||
res.writeHead(400);
|
|
||||||
return res.end('Invalid member uuid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberData = await membersService.api.members.get({
|
|
||||||
uuid: memberUuid
|
|
||||||
}, {
|
|
||||||
withRelated: ['newsletters']
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!memberData) {
|
if (!memberData) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
return res.end('Email address not found.');
|
return res.end('Email address not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = _.pick(memberData.toJSON(), 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status');
|
const data = _.pick(memberData, 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status');
|
||||||
|
|
||||||
if (data.newsletters) {
|
if (data.newsletters) {
|
||||||
data.newsletters = formatNewsletterResponse(data.newsletters);
|
data.newsletters = formatNewsletterResponse(data.newsletters);
|
||||||
@ -226,23 +233,15 @@ const getMemberNewsletters = async function getMemberNewsletters(req, res) {
|
|||||||
|
|
||||||
const updateMemberNewsletters = async function updateMemberNewsletters(req, res) {
|
const updateMemberNewsletters = async function updateMemberNewsletters(req, res) {
|
||||||
try {
|
try {
|
||||||
const memberUuid = req.query.uuid;
|
const memberData = req.member; // validation assumed
|
||||||
if (!memberUuid) {
|
|
||||||
res.writeHead(400);
|
|
||||||
return res.end('Invalid member uuid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications');
|
|
||||||
const memberData = await membersService.api.members.get({
|
|
||||||
uuid: memberUuid
|
|
||||||
});
|
|
||||||
if (!memberData) {
|
if (!memberData) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
return res.end('Email address not found.');
|
return res.end('Email address not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications');
|
||||||
const options = {
|
const options = {
|
||||||
id: memberData.get('id'),
|
id: memberData.id,
|
||||||
withRelated: ['newsletters']
|
withRelated: ['newsletters']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -106,6 +106,16 @@ class SettingsHelpers {
|
|||||||
return domain;
|
return domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the member validation key from the settings cache. The intent is for this key to be used where member
|
||||||
|
* auth is not required. For example, unsubscribe links in emails, which are required to be one-click unsubscribe.
|
||||||
|
*
|
||||||
|
* @returns {string} The member validation key.
|
||||||
|
*/
|
||||||
|
getMembersValidationKey() {
|
||||||
|
return this.settingsCache.get('members_email_auth_secret');
|
||||||
|
}
|
||||||
|
|
||||||
getMembersSupportAddress() {
|
getMembersSupportAddress() {
|
||||||
let supportAddress = this.settingsCache.get('members_support_address');
|
let supportAddress = this.settingsCache.get('members_support_address');
|
||||||
|
|
||||||
|
@ -40,9 +40,16 @@ module.exports = function setupMembersApp() {
|
|||||||
// Initializes members specific routes as well as assigns members specific data to the req/res objects
|
// Initializes members specific routes as well as assigns members specific data to the req/res objects
|
||||||
// We don't want to add global bodyParser middleware as that interferes with stripe webhook requests on - `/webhooks`.
|
// We don't want to add global bodyParser middleware as that interferes with stripe webhook requests on - `/webhooks`.
|
||||||
|
|
||||||
// Manage newsletter subscription via unsubscribe link
|
// Manage newsletter subscription via unsubscribe link - these should be authenticated by uuid and hashed key
|
||||||
membersApp.get('/api/member/newsletters', middleware.getMemberNewsletters);
|
membersApp.get('/api/member/newsletters',
|
||||||
membersApp.put('/api/member/newsletters', bodyParser.json({limit: '50mb'}), middleware.updateMemberNewsletters);
|
middleware.authMemberByUuid,
|
||||||
|
middleware.getMemberNewsletters
|
||||||
|
);
|
||||||
|
membersApp.put('/api/member/newsletters',
|
||||||
|
bodyParser.json({limit: '50mb'}),
|
||||||
|
middleware.authMemberByUuid,
|
||||||
|
middleware.updateMemberNewsletters
|
||||||
|
);
|
||||||
|
|
||||||
// Get and update member data
|
// Get and update member data
|
||||||
// Caching members content is an experimental feature
|
// Caching members content is an experimental feature
|
||||||
|
@ -182,7 +182,7 @@
|
|||||||
},
|
},
|
||||||
"portal": {
|
"portal": {
|
||||||
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
|
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
|
||||||
"version": "2.38"
|
"version": "2.39"
|
||||||
},
|
},
|
||||||
"sodoSearch": {
|
"sodoSearch": {
|
||||||
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
|
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
|
||||||
|
@ -605,7 +605,7 @@ table.body h2 span {
|
|||||||
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
||||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
||||||
<tr>
|
<tr>
|
||||||
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -749,7 +749,7 @@ Another email card with a similar replacement, Jamie
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
|
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -778,7 +778,7 @@ exports[`Email Preview API Read can read post email preview with email card and
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "24595",
|
"content-length": "24733",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -1302,7 +1302,7 @@ table.body h2 span {
|
|||||||
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
||||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
||||||
<tr>
|
<tr>
|
||||||
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -1463,7 +1463,7 @@ Header Level 3
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
|
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1492,7 +1492,7 @@ exports[`Email Preview API Read can read post email preview with fields 4: [head
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "29375",
|
"content-length": "29513",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -2042,7 +2042,7 @@ table.body h2 span {
|
|||||||
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
||||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
||||||
<tr>
|
<tr>
|
||||||
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -2180,7 +2180,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
|
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -2222,7 +2222,7 @@ exports[`Email Preview API Read has custom content transformations for email com
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "24349",
|
"content-length": "24487",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -3108,7 +3108,7 @@ table.body h2 span {
|
|||||||
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
||||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
||||||
<tr>
|
<tr>
|
||||||
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -3253,7 +3253,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
|
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3295,7 +3295,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "25132",
|
"content-length": "25270",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -4207,7 +4207,7 @@ table.body h2 span {
|
|||||||
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
|
||||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
|
||||||
<tr>
|
<tr>
|
||||||
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em; font-size: 13px;\\" valign=\\"top\\" align=\\"center\\">Ghost © 2024 – <a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline; font-size: 13px;\\" target=\\"_blank\\">Unsubscribe</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -4352,7 +4352,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
|
Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -4394,7 +4394,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 4: [headers
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "25132",
|
"content-length": "25270",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
|
@ -790,12 +790,12 @@ Object {
|
|||||||
"failed_count": 0,
|
"failed_count": 0,
|
||||||
"feedback_enabled": false,
|
"feedback_enabled": false,
|
||||||
"from": "support@example.com",
|
"from": "support@example.com",
|
||||||
"html": "<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">Hey Jamie, Hey Jamie,</p><a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=preview\\">Unsubscribe</a>",
|
"html": "<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">Hey Jamie, Hey Jamie,</p><a href=\\"http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=preview\\">Unsubscribe</a>",
|
||||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
"newsletter_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
"newsletter_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
"opened_count": 0,
|
"opened_count": 0,
|
||||||
"plaintext": "Hey Jamie, Hey Jamie
|
"plaintext": "Hey Jamie, Hey Jamie
|
||||||
Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=preview]",
|
Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=preview]",
|
||||||
"post_id": "618ba1ffbe2896088840a6e3",
|
"post_id": "618ba1ffbe2896088840a6e3",
|
||||||
"recipient_filter": "all",
|
"recipient_filter": "all",
|
||||||
"reply_to": null,
|
"reply_to": null,
|
||||||
@ -817,7 +817,7 @@ exports[`Emails API Does default replacements on the HTML body of an old email 2
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "923",
|
"content-length": "1061",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
|
@ -4,6 +4,7 @@ const assert = require('assert/strict');
|
|||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const escapeRegExp = require('lodash/escapeRegExp');
|
const escapeRegExp = require('lodash/escapeRegExp');
|
||||||
const should = require('should');
|
const should = require('should');
|
||||||
|
const settingsHelpers = require('../../../core/server/services/settings-helpers');
|
||||||
|
|
||||||
// @TODO: factor out these requires
|
// @TODO: factor out these requires
|
||||||
const ObjectId = require('bson-objectid').default;
|
const ObjectId = require('bson-objectid').default;
|
||||||
@ -37,6 +38,7 @@ describe('Email Preview API', function () {
|
|||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockManager.mockMailgun();
|
mockManager.mockMailgun();
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test-validation-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
|
@ -4,6 +4,7 @@ const assert = require('assert/strict');
|
|||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const jobManager = require('../../../core/server/services/jobs/job-service');
|
const jobManager = require('../../../core/server/services/jobs/job-service');
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
|
const settingsHelpers = require('../../../core/server/services/settings-helpers');
|
||||||
|
|
||||||
const matchEmail = {
|
const matchEmail = {
|
||||||
id: anyObjectId,
|
id: anyObjectId,
|
||||||
@ -43,6 +44,7 @@ describe('Emails API', function () {
|
|||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockManager.mockEvents();
|
mockManager.mockEvents();
|
||||||
mockManager.mockMailgun();
|
mockManager.mockMailgun();
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test-validation-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
|
@ -26,6 +26,104 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Allows authentication via uuid (+ key) 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"feedback": Array [
|
||||||
|
Object {
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
|
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
|
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
|
"score": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Allows authentication via uuid (+ key) 2: [headers] 1`] = `
|
||||||
|
Object {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
|
"content-length": "132",
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
|
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||||
|
"vary": "Accept-Encoding",
|
||||||
|
"x-powered-by": "Express",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Thorws for invalid key 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"errors": Array [
|
||||||
|
Object {
|
||||||
|
"code": null,
|
||||||
|
"context": null,
|
||||||
|
"details": null,
|
||||||
|
"ghostErrorCode": null,
|
||||||
|
"help": null,
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||||
|
"message": "Invalid key.",
|
||||||
|
"property": null,
|
||||||
|
"type": "UnauthorizedError",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Throws for invalid uuid 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"errors": Array [
|
||||||
|
Object {
|
||||||
|
"code": null,
|
||||||
|
"context": null,
|
||||||
|
"details": null,
|
||||||
|
"ghostErrorCode": null,
|
||||||
|
"help": null,
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||||
|
"message": "Invalid key.",
|
||||||
|
"property": null,
|
||||||
|
"type": "UnauthorizedError",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Throws for missing key 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"errors": Array [
|
||||||
|
Object {
|
||||||
|
"code": null,
|
||||||
|
"context": null,
|
||||||
|
"details": null,
|
||||||
|
"ghostErrorCode": null,
|
||||||
|
"help": null,
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||||
|
"message": "Invalid key.",
|
||||||
|
"property": null,
|
||||||
|
"type": "UnauthorizedError",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Members Feedback Authentication Throws for nonexisting uuid 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"errors": Array [
|
||||||
|
Object {
|
||||||
|
"code": null,
|
||||||
|
"context": null,
|
||||||
|
"details": null,
|
||||||
|
"ghostErrorCode": null,
|
||||||
|
"help": null,
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||||
|
"message": "Invalid key.",
|
||||||
|
"property": null,
|
||||||
|
"type": "UnauthorizedError",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`Members Feedback Can add feedback 1: [body] 1`] = `
|
exports[`Members Feedback Can add feedback 1: [body] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"feedback": Array [
|
"feedback": Array [
|
||||||
|
@ -3,9 +3,13 @@ const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = requ
|
|||||||
const {anyEtag, anyObjectId, anyLocationFor, anyErrorId} = matchers;
|
const {anyEtag, anyObjectId, anyLocationFor, anyErrorId} = matchers;
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
|
const settingsHelpers = require('../../../core/server/services/settings-helpers');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const membersValidationKeyMock = 'abc123dontstealme';
|
||||||
|
|
||||||
describe('Members Feedback', function () {
|
describe('Members Feedback', function () {
|
||||||
let membersAgent, membersAgent2, memberUuid;
|
let membersAgent, membersAgent2, memberUuid, memberHmac;
|
||||||
let clock;
|
let clock;
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
@ -14,9 +18,11 @@ describe('Members Feedback', function () {
|
|||||||
|
|
||||||
await fixtureManager.init('posts', 'members');
|
await fixtureManager.init('posts', 'members');
|
||||||
memberUuid = fixtureManager.get('members', 0).uuid;
|
memberUuid = fixtureManager.get('members', 0).uuid;
|
||||||
|
memberHmac = crypto.createHmac('sha256', membersValidationKeyMock).update(memberUuid).digest('hex');
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns(membersValidationKeyMock);
|
||||||
mockManager.mockMail();
|
mockManager.mockMail();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,21 +61,45 @@ describe('Members Feedback', function () {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Validation', function () {
|
it('Allows authentication via uuid (+ key)', async function () {
|
||||||
const postId = fixtureManager.get('posts', 0).id;
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
|
|
||||||
it('Throws for invalid score', async function () {
|
await membersAgent
|
||||||
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
|
.body({
|
||||||
|
feedback: [{
|
||||||
|
score: 1,
|
||||||
|
post_id: postId
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.expectStatus(201)
|
||||||
|
.matchHeaderSnapshot({
|
||||||
|
etag: anyEtag,
|
||||||
|
location: anyLocationFor('feedback')
|
||||||
|
})
|
||||||
|
.matchBodySnapshot({
|
||||||
|
feedback: [
|
||||||
|
{
|
||||||
|
id: anyObjectId,
|
||||||
|
memberId: anyObjectId,
|
||||||
|
postId: anyObjectId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Throws for missing key', async function () {
|
||||||
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 2,
|
score: 1,
|
||||||
post_id: postId
|
post_id: postId
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
.expectStatus(422)
|
.expectStatus(401)
|
||||||
.matchBodySnapshot({
|
.matchBodySnapshot({
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
@ -79,16 +109,17 @@ describe('Members Feedback', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Throws for invalid score type', async function () {
|
it('Thorws for invalid key', async function () {
|
||||||
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=1234`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 'text',
|
score: 1,
|
||||||
post_id: postId
|
post_id: postId
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
.expectStatus(422)
|
.expectStatus(401)
|
||||||
.matchBodySnapshot({
|
.matchBodySnapshot({
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
@ -99,6 +130,7 @@ describe('Members Feedback', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Throws for invalid uuid', async function () {
|
it('Throws for invalid uuid', async function () {
|
||||||
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=1234`)
|
.post(`/api/feedback/?uuid=1234`)
|
||||||
.body({
|
.body({
|
||||||
@ -118,6 +150,7 @@ describe('Members Feedback', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Throws for nonexisting uuid', async function () {
|
it('Throws for nonexisting uuid', async function () {
|
||||||
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
const uuid = '00000000-0000-0000-0000-000000000000';
|
const uuid = '00000000-0000-0000-0000-000000000000';
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${uuid}`)
|
.post(`/api/feedback/?uuid=${uuid}`)
|
||||||
@ -136,10 +169,52 @@ describe('Members Feedback', function () {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation', function () {
|
||||||
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
|
|
||||||
|
it('Throws for invalid score', async function () {
|
||||||
|
await membersAgent
|
||||||
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
|
.body({
|
||||||
|
feedback: [{
|
||||||
|
score: 2,
|
||||||
|
post_id: postId
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.expectStatus(422)
|
||||||
|
.matchBodySnapshot({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
id: anyErrorId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Throws for invalid score type', async function () {
|
||||||
|
await membersAgent
|
||||||
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
|
.body({
|
||||||
|
feedback: [{
|
||||||
|
score: 'text',
|
||||||
|
post_id: postId
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.expectStatus(422)
|
||||||
|
.matchBodySnapshot({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
id: anyErrorId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('Throws for nonexisting post', async function () {
|
it('Throws for nonexisting post', async function () {
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 1,
|
score: 1,
|
||||||
@ -161,7 +236,7 @@ describe('Members Feedback', function () {
|
|||||||
const postId = fixtureManager.get('posts', 0).id;
|
const postId = fixtureManager.get('posts', 0).id;
|
||||||
|
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 1,
|
score: 1,
|
||||||
@ -189,7 +264,7 @@ describe('Members Feedback', function () {
|
|||||||
const postId = fixtureManager.get('posts', 1).id;
|
const postId = fixtureManager.get('posts', 1).id;
|
||||||
|
|
||||||
const {body} = await membersAgent
|
const {body} = await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 0,
|
score: 0,
|
||||||
@ -220,7 +295,7 @@ describe('Members Feedback', function () {
|
|||||||
clock.tick(10 * 60 * 1000);
|
clock.tick(10 * 60 * 1000);
|
||||||
|
|
||||||
const {body: body2} = await membersAgent
|
const {body: body2} = await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 1,
|
score: 1,
|
||||||
@ -252,7 +327,7 @@ describe('Members Feedback', function () {
|
|||||||
|
|
||||||
// Do the same change again, and the model shouldn't change
|
// Do the same change again, and the model shouldn't change
|
||||||
const {body: body3} = await membersAgent
|
const {body: body3} = await membersAgent
|
||||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
.post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
|
||||||
.body({
|
.body({
|
||||||
feedback: [{
|
feedback: [{
|
||||||
score: 1,
|
score: 1,
|
||||||
|
@ -3,6 +3,8 @@ const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = requ
|
|||||||
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, stringMatching} = matchers;
|
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, stringMatching} = matchers;
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
const should = require('should');
|
const should = require('should');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
const settingsHelpers = require('../../../core/server/services/settings-helpers');
|
||||||
|
|
||||||
let membersAgent;
|
let membersAgent;
|
||||||
|
|
||||||
@ -60,8 +62,11 @@ describe('Comments API', function () {
|
|||||||
let member = await models.Member.findOne({id: fixtureManager.get('members', 0).id}, {require: true});
|
let member = await models.Member.findOne({id: fixtureManager.get('members', 0).id}, {require: true});
|
||||||
member.get('enable_comment_notifications').should.eql(true, 'This test requires the initial value to be true');
|
member.get('enable_comment_notifications').should.eql(true, 'This test requires the initial value to be true');
|
||||||
|
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const hmac = crypto.createHmac('sha256', 'test').update(member.get('uuid')).digest('hex');
|
||||||
|
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.put(`/api/member/newsletters/?uuid=${member.get('uuid')}`)
|
.put(`/api/member/newsletters/?uuid=${member.get('uuid')}&key=${hmac}`)
|
||||||
.body({
|
.body({
|
||||||
enable_comment_notifications: false
|
enable_comment_notifications: false
|
||||||
})
|
})
|
||||||
@ -179,9 +184,12 @@ describe('Comments API', function () {
|
|||||||
member = await models.Member.findOne({id: member.id}, {require: true});
|
member = await models.Member.findOne({id: member.id}, {require: true});
|
||||||
member.get('enable_comment_notifications').should.eql(false);
|
member.get('enable_comment_notifications').should.eql(false);
|
||||||
|
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const hmac = crypto.createHmac('sha256', 'test').update(member.get('uuid')).digest('hex');
|
||||||
|
|
||||||
// Via updateMemberNewsletters
|
// Via updateMemberNewsletters
|
||||||
await membersAgent
|
await membersAgent
|
||||||
.put(`/api/member/newsletters/?uuid=${member.get('uuid')}`)
|
.put(`/api/member/newsletters/?uuid=${member.get('uuid')}&key=${hmac}`)
|
||||||
.body({
|
.body({
|
||||||
enable_comment_notifications: true
|
enable_comment_notifications: true
|
||||||
})
|
})
|
||||||
|
@ -6,12 +6,14 @@ const moment = require('moment');
|
|||||||
const testUtils = require('../utils');
|
const testUtils = require('../utils');
|
||||||
const configUtils = require('../utils/configUtils');
|
const configUtils = require('../utils/configUtils');
|
||||||
const settingsCache = require('../../core/shared/settings-cache');
|
const settingsCache = require('../../core/shared/settings-cache');
|
||||||
|
const settingsHelpers = require('../../core/server/services/settings-helpers');
|
||||||
const DomainEvents = require('@tryghost/domain-events');
|
const DomainEvents = require('@tryghost/domain-events');
|
||||||
const {MemberPageViewEvent} = require('@tryghost/member-events');
|
const {MemberPageViewEvent} = require('@tryghost/member-events');
|
||||||
const models = require('../../core/server/models');
|
const models = require('../../core/server/models');
|
||||||
const {fixtureManager} = require('../utils/e2e-framework');
|
const {fixtureManager} = require('../utils/e2e-framework');
|
||||||
const DataGenerator = require('../utils/fixtures/data-generator');
|
const DataGenerator = require('../utils/fixtures/data-generator');
|
||||||
const members = require('../../core/server/services/members');
|
const members = require('../../core/server/services/members');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
function assertContentIsPresent(res) {
|
function assertContentIsPresent(res) {
|
||||||
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
||||||
@ -168,88 +170,114 @@ describe('Front-end members behavior', function () {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error for fetching member newsletters with missing uuid', async function () {
|
describe('Newsletters', function () {
|
||||||
await request.get('/members/api/member/newsletters')
|
afterEach(function () {
|
||||||
.expect(400);
|
sinon.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error for fetching member newsletters with invalid uuid', async function () {
|
it('should error for fetching member newsletters with missing uuid', async function () {
|
||||||
await request.get('/members/api/member/newsletters?uuid=abc')
|
await request.get('/members/api/member/newsletters')
|
||||||
.expect(404);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error for updating member newsletters with missing uuid', async function () {
|
it('should error for fetching member newsletters with invalid uuid', async function () {
|
||||||
await request.put('/members/api/member/newsletters')
|
await request.get('/members/api/member/newsletters?uuid=abc')
|
||||||
.expect(400);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error for updating member newsletters with invalid uuid', async function () {
|
it('should error for updating member newsletters with missing uuid', async function () {
|
||||||
await request.put('/members/api/member/newsletters?uuid=abc')
|
await request.put('/members/api/member/newsletters')
|
||||||
.expect(404);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch and update member newsletters with valid uuid', async function () {
|
it('should error for updating member newsletters with invalid uuid', async function () {
|
||||||
const memberUUID = DataGenerator.Content.members[0].uuid;
|
await request.put('/members/api/member/newsletters?uuid=abc')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
it('should error for updating member newsletters with no key', async function () {
|
||||||
const getRes = await request.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
.expect(200);
|
await request.get('/members/api/member/newsletters?uuid=abc')
|
||||||
const getJsonResponse = getRes.body;
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
should.exist(getJsonResponse);
|
it('should 401 for GET member newsletters with a mismatched hmac key', async function () {
|
||||||
getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
getJsonResponse.should.not.have.property('id');
|
await request.get('/members/api/member/newsletters?uuid=abc&key=blah')
|
||||||
getJsonResponse.newsletters.should.have.length(1);
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
// NOTE: these should be snapshots not code
|
it('should 401 for PUT member newsletters with a mismatched hmac key', async function () {
|
||||||
Object.keys(getJsonResponse.newsletters[0]).should.have.length(5);
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
getJsonResponse.newsletters[0].should.have.properties([
|
await request.put('/members/api/member/newsletters?uuid=abc&key=blah')
|
||||||
'id',
|
.expect(401);
|
||||||
'uuid',
|
});
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'sort_order'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Can update newsletter subscription
|
it('should fetch and update member newsletters with valid uuid', async function () {
|
||||||
const originalNewsletters = getJsonResponse.newsletters;
|
const memberUUID = DataGenerator.Content.members[0].uuid;
|
||||||
const originalNewsletterName = originalNewsletters[0].name;
|
|
||||||
originalNewsletters[0].name = 'cannot change me';
|
|
||||||
|
|
||||||
const res = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
// Can fetch newsletter subscriptions
|
||||||
.send({
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
newsletters: []
|
const memberHmac = crypto.createHmac('sha256', 'test').update(memberUUID).digest('hex');
|
||||||
})
|
const getRes = await request.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const jsonResponse = res.body;
|
const getJsonResponse = getRes.body;
|
||||||
|
|
||||||
should.exist(jsonResponse);
|
should.exist(getJsonResponse);
|
||||||
jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
||||||
jsonResponse.should.not.have.property('id');
|
getJsonResponse.should.not.have.property('id');
|
||||||
jsonResponse.newsletters.should.have.length(0);
|
getJsonResponse.newsletters.should.have.length(1);
|
||||||
|
|
||||||
const resRestored = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
// NOTE: these should be snapshots not code
|
||||||
.send({
|
Object.keys(getJsonResponse.newsletters[0]).should.have.length(5);
|
||||||
newsletters: originalNewsletters
|
getJsonResponse.newsletters[0].should.have.properties([
|
||||||
})
|
'id',
|
||||||
.expect(200);
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'sort_order'
|
||||||
|
]);
|
||||||
|
|
||||||
const restoreJsonResponse = resRestored.body;
|
// Can update newsletter subscription
|
||||||
should.exist(restoreJsonResponse);
|
const originalNewsletters = getJsonResponse.newsletters;
|
||||||
restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
const originalNewsletterName = originalNewsletters[0].name;
|
||||||
restoreJsonResponse.should.not.have.property('id');
|
originalNewsletters[0].name = 'cannot change me';
|
||||||
restoreJsonResponse.newsletters.should.have.length(1);
|
|
||||||
// @NOTE: this seems like too much exposed information, needs a review
|
|
||||||
Object.keys(restoreJsonResponse.newsletters[0]).should.have.length(5);
|
|
||||||
restoreJsonResponse.newsletters[0].should.have.properties([
|
|
||||||
'id',
|
|
||||||
'uuid',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'sort_order'
|
|
||||||
]);
|
|
||||||
|
|
||||||
should.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName);
|
const res = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
|
.send({
|
||||||
|
newsletters: []
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const jsonResponse = res.body;
|
||||||
|
|
||||||
|
should.exist(jsonResponse);
|
||||||
|
jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
||||||
|
jsonResponse.should.not.have.property('id');
|
||||||
|
jsonResponse.newsletters.should.have.length(0);
|
||||||
|
|
||||||
|
const resRestored = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
|
.send({
|
||||||
|
newsletters: originalNewsletters
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const restoreJsonResponse = resRestored.body;
|
||||||
|
should.exist(restoreJsonResponse);
|
||||||
|
restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
|
||||||
|
restoreJsonResponse.should.not.have.property('id');
|
||||||
|
restoreJsonResponse.newsletters.should.have.length(1);
|
||||||
|
// @NOTE: this seems like too much exposed information, needs a review
|
||||||
|
Object.keys(restoreJsonResponse.newsletters[0]).should.have.length(5);
|
||||||
|
restoreJsonResponse.newsletters[0].should.have.properties([
|
||||||
|
'id',
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'sort_order'
|
||||||
|
]);
|
||||||
|
|
||||||
|
should.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve theme 404 on members endpoint', async function () {
|
it('should serve theme 404 on members endpoint', async function () {
|
||||||
@ -266,6 +294,10 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Unsubscribe', function () {
|
describe('Unsubscribe', function () {
|
||||||
|
afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
it('should redirect with uuid and action param', async function () {
|
it('should redirect with uuid and action param', async function () {
|
||||||
await request.get('/unsubscribe/?uuid=XXX')
|
await request.get('/unsubscribe/?uuid=XXX')
|
||||||
.expect(302)
|
.expect(302)
|
||||||
@ -278,12 +310,18 @@ describe('Front-end members behavior', function () {
|
|||||||
.expect('Location', 'http://127.0.0.1:2369/?uuid=XXX&newsletter=YYY&action=unsubscribe');
|
.expect('Location', 'http://127.0.0.1:2369/?uuid=XXX&newsletter=YYY&action=unsubscribe');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass through an optional key param', async function () {
|
||||||
|
await request.get('/unsubscribe/?uuid=XXX&key=YYY')
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', 'http://127.0.0.1:2369/?uuid=XXX&key=YYY&action=unsubscribe');
|
||||||
|
});
|
||||||
|
|
||||||
it('should reject when missing a uuid', async function () {
|
it('should reject when missing a uuid', async function () {
|
||||||
await request.get('/unsubscribe/')
|
await request.get('/unsubscribe/')
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do an actual unsubscribe on POST', async function () {
|
it('should return unauthorized with a bad key', async function () {
|
||||||
const newsletterId = fixtureManager.get('newsletters', 0).id;
|
const newsletterId = fixtureManager.get('newsletters', 0).id;
|
||||||
const member = await createMember({
|
const member = await createMember({
|
||||||
email: 'unsubscribe-member-test@example.com',
|
email: 'unsubscribe-member-test@example.com',
|
||||||
@ -293,19 +331,40 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberUUID = member.get('uuid');
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('therealkey');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','thefalsekey').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
|
// auth via uuid+key should fail
|
||||||
|
await request
|
||||||
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do an actual unsubscribe on POST', async function () {
|
||||||
|
const newsletterId = fixtureManager.get('newsletters', 0).id;
|
||||||
|
const member = await createMember({
|
||||||
|
email: 'unsubscribe-member-test-another@example.com',
|
||||||
|
newsletters: [
|
||||||
|
{id: newsletterId}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
// Can fetch newsletter subscriptions
|
||||||
let getRes = await request
|
let getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
let getJsonResponse = getRes.body;
|
let getJsonResponse = getRes.body;
|
||||||
assert.equal(getJsonResponse.newsletters.length, 1);
|
assert.equal(getJsonResponse.newsletters.length, 1);
|
||||||
|
|
||||||
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
|
await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
getRes = await request
|
getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
getJsonResponse = getRes.body;
|
getJsonResponse = getRes.body;
|
||||||
|
|
||||||
@ -326,19 +385,21 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberUUID = member.get('uuid');
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
// Can fetch newsletter subscriptions
|
||||||
let getRes = await request
|
let getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
let getJsonResponse = getRes.body;
|
let getJsonResponse = getRes.body;
|
||||||
assert.equal(getJsonResponse.newsletters.length, 2);
|
assert.equal(getJsonResponse.newsletters.length, 2);
|
||||||
|
|
||||||
await request.post(`/unsubscribe/?uuid=${memberUUID}&newsletter=${newsletter2Uuid}`)
|
await request.post(`/unsubscribe/?uuid=${memberUUID}&newsletter=${newsletter2Uuid}&key=${memberHmac}`)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
getRes = await request
|
getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
getJsonResponse = getRes.body;
|
getJsonResponse = getRes.body;
|
||||||
|
|
||||||
@ -357,15 +418,17 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberUUID = member.get('uuid');
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
// Can fetch newsletter subscriptions
|
||||||
let getRes = await request
|
let getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
let getJsonResponse = getRes.body;
|
let getJsonResponse = getRes.body;
|
||||||
assert.equal(getJsonResponse.newsletters.length, 1);
|
assert.equal(getJsonResponse.newsletters.length, 1);
|
||||||
|
|
||||||
await request.post(`/unsubscribe/?uuid=${memberUUID}&comments=1`)
|
await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}&comments=1`)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const updatedMember = await members.api.members.get({id: member.id}, {withRelated: ['newsletters']});
|
const updatedMember = await members.api.members.get({id: member.id}, {withRelated: ['newsletters']});
|
||||||
@ -383,21 +446,23 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberUUID = member.get('uuid');
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
// Can fetch newsletter subscriptions
|
||||||
let getRes = await request
|
let getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
let getJsonResponse = getRes.body;
|
let getJsonResponse = getRes.body;
|
||||||
assert.equal(getJsonResponse.newsletters.length, 1);
|
assert.equal(getJsonResponse.newsletters.length, 1);
|
||||||
|
|
||||||
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
|
await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.type('form')
|
.type('form')
|
||||||
.send({'List-Unsubscribe': 'One-Click'})
|
.send({'List-Unsubscribe': 'One-Click'})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
getRes = await request
|
getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
getJsonResponse = getRes.body;
|
getJsonResponse = getRes.body;
|
||||||
|
|
||||||
@ -414,20 +479,22 @@ describe('Front-end members behavior', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberUUID = member.get('uuid');
|
const memberUUID = member.get('uuid');
|
||||||
|
sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
|
||||||
|
const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
|
||||||
|
|
||||||
// Can fetch newsletter subscriptions
|
// Can fetch newsletter subscriptions
|
||||||
let getRes = await request
|
let getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
let getJsonResponse = getRes.body;
|
let getJsonResponse = getRes.body;
|
||||||
assert.equal(getJsonResponse.newsletters.length, 1);
|
assert.equal(getJsonResponse.newsletters.length, 1);
|
||||||
|
|
||||||
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
|
await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.field('List-Unsubscribe', 'One-Click')
|
.field('List-Unsubscribe', 'One-Click')
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
getRes = await request
|
getRes = await request
|
||||||
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
|
.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
getJsonResponse = getRes.body;
|
getJsonResponse = getRes.body;
|
||||||
|
|
||||||
|
@ -211,7 +211,8 @@ describe('Members Service Middleware', function () {
|
|||||||
sinon.restore();
|
sinon.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 if no member uuid is part of the request', async function () {
|
// auth happens prior to this middleware
|
||||||
|
it('returns 404 if no member uuid is part of the request', async function () {
|
||||||
req.query = {};
|
req.query = {};
|
||||||
|
|
||||||
// Call the middleware
|
// Call the middleware
|
||||||
@ -219,11 +220,12 @@ describe('Members Service Middleware', function () {
|
|||||||
|
|
||||||
// Check behavior
|
// Check behavior
|
||||||
res.writeHead.calledOnce.should.be.true();
|
res.writeHead.calledOnce.should.be.true();
|
||||||
res.writeHead.firstCall.args[0].should.eql(400);
|
res.writeHead.firstCall.args[0].should.eql(404);
|
||||||
res.end.calledOnce.should.be.true();
|
res.end.calledOnce.should.be.true();
|
||||||
res.end.firstCall.args[0].should.eql('Invalid member uuid');
|
res.end.firstCall.args[0].should.eql('Email address not found.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// auth happens prior to this middleware
|
||||||
it('returns 404 if member uuid is not found', async function () {
|
it('returns 404 if member uuid is not found', async function () {
|
||||||
req.query = {uuid: 'test'};
|
req.query = {uuid: 'test'};
|
||||||
sinon.stub(membersService, 'api').get(() => {
|
sinon.stub(membersService, 'api').get(() => {
|
||||||
@ -246,8 +248,8 @@ describe('Members Service Middleware', function () {
|
|||||||
|
|
||||||
it('attempts to update newsletters', async function () {
|
it('attempts to update newsletters', async function () {
|
||||||
res.json = sinon.stub();
|
res.json = sinon.stub();
|
||||||
req.query = {uuid: 'test'};
|
// member data appended if authed via uuid+key or session
|
||||||
const memberData = {
|
req.member = {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
email: 'test@email.com',
|
email: 'test@email.com',
|
||||||
name: 'Test Name',
|
name: 'Test Name',
|
||||||
@ -258,10 +260,9 @@ describe('Members Service Middleware', function () {
|
|||||||
sinon.stub(membersService, 'api').get(() => {
|
sinon.stub(membersService, 'api').get(() => {
|
||||||
return {
|
return {
|
||||||
members: {
|
members: {
|
||||||
get: sinon.stub().resolves({id: 'test', email: 'test@email.com', get: () => 'test'}),
|
|
||||||
update: sinon.stub().resolves({
|
update: sinon.stub().resolves({
|
||||||
...memberData,
|
...req.member,
|
||||||
toJSON: () => JSON.stringify(memberData)
|
toJSON: () => JSON.stringify(req.member)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -273,7 +274,22 @@ describe('Members Service Middleware', function () {
|
|||||||
|
|
||||||
it('returns 400 on error', async function () {
|
it('returns 400 on error', async function () {
|
||||||
// use a malformed request to trigger an error
|
// use a malformed request to trigger an error
|
||||||
req = {};
|
// member data appended if authed via uuid+key or session
|
||||||
|
req.member = {
|
||||||
|
id: undefined,
|
||||||
|
email: 'test@email.com',
|
||||||
|
name: 'Test Name',
|
||||||
|
newsletters: [],
|
||||||
|
enable_comment_notifications: false,
|
||||||
|
status: 'free'
|
||||||
|
};
|
||||||
|
sinon.stub(membersService, 'api').get(() => {
|
||||||
|
return {
|
||||||
|
members: {
|
||||||
|
update: sinon.stub().rejects(new Error('Test Error'))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
await membersMiddleware.updateMemberNewsletters(req, res);
|
await membersMiddleware.updateMemberNewsletters(req, res);
|
||||||
|
|
||||||
// Check behavior
|
// Check behavior
|
||||||
|
@ -11,6 +11,7 @@ const tpl = require('@tryghost/tpl');
|
|||||||
const cheerio = require('cheerio');
|
const cheerio = require('cheerio');
|
||||||
const {EmailAddressParser} = require('@tryghost/email-addresses');
|
const {EmailAddressParser} = require('@tryghost/email-addresses');
|
||||||
const {registerHelpers} = require('./helpers/register-helpers');
|
const {registerHelpers} = require('./helpers/register-helpers');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
subscriptionStatus: {
|
subscriptionStatus: {
|
||||||
@ -117,7 +118,7 @@ class EmailRenderer {
|
|||||||
/**
|
/**
|
||||||
* @param {object} dependencies
|
* @param {object} dependencies
|
||||||
* @param {object} dependencies.settingsCache
|
* @param {object} dependencies.settingsCache
|
||||||
* @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string}} dependencies.settingsHelpers
|
* @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string, getMembersValidationKey(): string}} dependencies.settingsHelpers
|
||||||
* @param {object} dependencies.renderers
|
* @param {object} dependencies.renderers
|
||||||
* @param {{render(object, options): string}} dependencies.renderers.lexical
|
* @param {{render(object, options): string}} dependencies.renderers.lexical
|
||||||
* @param {{render(object, options): string}} dependencies.renderers.mobiledoc
|
* @param {{render(object, options): string}} dependencies.renderers.mobiledoc
|
||||||
@ -501,9 +502,14 @@ class EmailRenderer {
|
|||||||
createUnsubscribeUrl(uuid, options = {}) {
|
createUnsubscribeUrl(uuid, options = {}) {
|
||||||
const siteUrl = this.#urlUtils.urlFor('home', true);
|
const siteUrl = this.#urlUtils.urlFor('home', true);
|
||||||
const unsubscribeUrl = new URL(siteUrl);
|
const unsubscribeUrl = new URL(siteUrl);
|
||||||
|
const key = this.#settingsHelpers.getMembersValidationKey();
|
||||||
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
|
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
|
||||||
if (uuid) {
|
if (uuid) {
|
||||||
|
// hash key with member uuid for verification (and to not leak uuid) - it's possible to update member email prefs without logging in
|
||||||
|
// @ts-ignore
|
||||||
|
const hmac = crypto.createHmac('sha256', key).update(`${uuid}`).digest('hex');
|
||||||
unsubscribeUrl.searchParams.set('uuid', uuid);
|
unsubscribeUrl.searchParams.set('uuid', uuid);
|
||||||
|
unsubscribeUrl.searchParams.set('key', hmac);
|
||||||
} else {
|
} else {
|
||||||
unsubscribeUrl.searchParams.set('preview', '1');
|
unsubscribeUrl.searchParams.set('preview', '1');
|
||||||
}
|
}
|
||||||
@ -639,6 +645,12 @@ class EmailRenderer {
|
|||||||
return member.uuid;
|
return member.uuid;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'key',
|
||||||
|
getValue: (member) => {
|
||||||
|
return crypto.createHmac('sha256', this.#settingsHelpers.getMembersValidationKey()).update(member.uuid).digest('hex');
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'first_name',
|
id: 'first_name',
|
||||||
getValue: (member) => {
|
getValue: (member) => {
|
||||||
@ -962,13 +974,15 @@ class EmailRenderer {
|
|||||||
const positiveLink = this.#audienceFeedbackService.buildLink(
|
const positiveLink = this.#audienceFeedbackService.buildLink(
|
||||||
'--uuid--',
|
'--uuid--',
|
||||||
post.id,
|
post.id,
|
||||||
1
|
1,
|
||||||
).href.replace('--uuid--', '%%{uuid}%%');
|
'--key--'
|
||||||
|
).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
|
||||||
const negativeLink = this.#audienceFeedbackService.buildLink(
|
const negativeLink = this.#audienceFeedbackService.buildLink(
|
||||||
'--uuid--',
|
'--uuid--',
|
||||||
post.id,
|
post.id,
|
||||||
0
|
0,
|
||||||
).href.replace('--uuid--', '%%{uuid}%%');
|
'--key--'
|
||||||
|
).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
|
||||||
|
|
||||||
const commentUrl = new URL(postUrl);
|
const commentUrl = new URL(postUrl);
|
||||||
commentUrl.hash = '#ghost-comments';
|
commentUrl.hash = '#ghost-comments';
|
||||||
|
@ -6,6 +6,7 @@ const linkReplacer = require('@tryghost/link-replacer');
|
|||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
const {HtmlValidate} = require('html-validate');
|
const {HtmlValidate} = require('html-validate');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
async function validateHtml(html) {
|
async function validateHtml(html) {
|
||||||
const htmlvalidate = new HtmlValidate({
|
const htmlvalidate = new HtmlValidate({
|
||||||
@ -60,6 +61,10 @@ async function validateHtml(html) {
|
|||||||
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
|
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMembersValidationKey = () => {
|
||||||
|
return 'members-key';
|
||||||
|
};
|
||||||
|
|
||||||
describe('Email renderer', function () {
|
describe('Email renderer', function () {
|
||||||
let logStub;
|
let logStub;
|
||||||
|
|
||||||
@ -92,7 +97,8 @@ describe('Email renderer', function () {
|
|||||||
return 'UTC';
|
return 'UTC';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
settingsHelpers: {getMembersValidationKey}
|
||||||
});
|
});
|
||||||
newsletter = createModel({
|
newsletter = createModel({
|
||||||
uuid: 'newsletteruuid'
|
uuid: 'newsletteruuid'
|
||||||
@ -113,7 +119,8 @@ describe('Email renderer', function () {
|
|||||||
assert.equal(replacements.length, 1);
|
assert.equal(replacements.length, 1);
|
||||||
assert.equal(replacements[0].token.toString(), '/%%\\{list_unsubscribe\\}%%/g');
|
assert.equal(replacements[0].token.toString(), '/%%\\{list_unsubscribe\\}%%/g');
|
||||||
assert.equal(replacements[0].id, 'list_unsubscribe');
|
assert.equal(replacements[0].id, 'list_unsubscribe');
|
||||||
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
|
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
|
||||||
|
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a replacement if it is used', function () {
|
it('returns a replacement if it is used', function () {
|
||||||
@ -149,7 +156,8 @@ describe('Email renderer', function () {
|
|||||||
assert.equal(replacements.length, 2);
|
assert.equal(replacements.length, 2);
|
||||||
assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g');
|
assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g');
|
||||||
assert.equal(replacements[0].id, 'unsubscribe_url');
|
assert.equal(replacements[0].id, 'unsubscribe_url');
|
||||||
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
|
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
|
||||||
|
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct name', function () {
|
it('returns correct name', function () {
|
||||||
@ -310,6 +318,20 @@ describe('Email renderer', function () {
|
|||||||
// In case of empty name
|
// In case of empty name
|
||||||
assert.equal(replacements[2].getValue({name: ''}), '');
|
assert.equal(replacements[2].getValue({name: ''}), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles members uuid and key', function () {
|
||||||
|
const html = '%%{uuid}%% %%{key}%%';
|
||||||
|
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
|
||||||
|
assert.equal(replacements.length, 3);
|
||||||
|
assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g');
|
||||||
|
assert.equal(replacements[0].id, 'uuid');
|
||||||
|
assert.equal(replacements[0].getValue(member), 'myuuid');
|
||||||
|
|
||||||
|
assert.equal(replacements[1].token.toString(), '/%%\\{key\\}%%/g');
|
||||||
|
assert.equal(replacements[1].id, 'key');
|
||||||
|
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
|
||||||
|
assert.equal(replacements[1].getValue(member), memberHmac);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isMemberTrialing', function () {
|
describe('isMemberTrialing', function () {
|
||||||
@ -958,8 +980,8 @@ describe('Email renderer', function () {
|
|||||||
});
|
});
|
||||||
emailRenderer = new EmailRenderer({
|
emailRenderer = new EmailRenderer({
|
||||||
audienceFeedbackService: {
|
audienceFeedbackService: {
|
||||||
buildLink: (_uuid, _postId, score) => {
|
buildLink: (_uuid, _postId, score, key) => {
|
||||||
return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid));
|
return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid) + '&key=' + encodeURIComponent(key));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
urlUtils: {
|
urlUtils: {
|
||||||
@ -1058,7 +1080,7 @@ describe('Email renderer', function () {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns feedback buttons and unsubcribe links', async function () {
|
it('returns feedback buttons and unsubscribe links', async function () {
|
||||||
const post = createModel(basePost);
|
const post = createModel(basePost);
|
||||||
const newsletter = createModel({
|
const newsletter = createModel({
|
||||||
header_image: null,
|
header_image: null,
|
||||||
@ -1084,14 +1106,16 @@ describe('Email renderer', function () {
|
|||||||
// Unsubscribe button included
|
// Unsubscribe button included
|
||||||
response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]');
|
response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]');
|
||||||
response.html.should.containEql('Unsubscribe');
|
response.html.should.containEql('Unsubscribe');
|
||||||
response.replacements.length.should.eql(3);
|
response.replacements.length.should.eql(4);
|
||||||
response.replacements.should.match([
|
response.replacements.should.match([
|
||||||
{
|
{
|
||||||
id: 'uuid'
|
id: 'uuid'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'unsubscribe_url',
|
id: 'key'
|
||||||
token: /%%\{unsubscribe_url\}%%/g
|
},
|
||||||
|
{
|
||||||
|
id: 'unsubscribe_url'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list_unsubscribe'
|
id: 'list_unsubscribe'
|
||||||
@ -1440,21 +1464,23 @@ describe('Email renderer', function () {
|
|||||||
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fencoded-link.com%2F%3Fcode%3Dtest%26source_tracking%3Dsite`,
|
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fencoded-link.com%2F%3Fcode%3Dtest%26source_tracking%3Dsite`,
|
||||||
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
||||||
'#',
|
'#',
|
||||||
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`%%{unsubscribe_url}%%`,
|
`%%{unsubscribe_url}%%`,
|
||||||
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
|
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check uuid in replacements
|
// Check uuid in replacements
|
||||||
response.replacements.length.should.eql(3);
|
response.replacements.length.should.eql(4);
|
||||||
response.replacements[0].id.should.eql('uuid');
|
response.replacements[0].id.should.eql('uuid');
|
||||||
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
|
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
|
||||||
response.replacements[1].id.should.eql('unsubscribe_url');
|
response.replacements[1].id.should.eql('key');
|
||||||
response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g);
|
response.replacements[1].token.should.eql(/%%\{key\}%%/g);
|
||||||
response.replacements[2].id.should.eql('list_unsubscribe');
|
response.replacements[2].id.should.eql('unsubscribe_url');
|
||||||
|
response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g);
|
||||||
|
response.replacements[3].id.should.eql('list_unsubscribe');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces all relative links if click tracking is disabled', async function () {
|
it('replaces all relative links if click tracking is disabled', async function () {
|
||||||
@ -1495,10 +1521,10 @@ describe('Email renderer', function () {
|
|||||||
'http://example.com/',
|
'http://example.com/',
|
||||||
'http://example.com/#relative-test',
|
'http://example.com/#relative-test',
|
||||||
'#',
|
'#',
|
||||||
'http://feedback-link.com/?score=1&uuid=%%{uuid}%%',
|
'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%',
|
||||||
'http://feedback-link.com/?score=0&uuid=%%{uuid}%%',
|
'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%',
|
||||||
'http://feedback-link.com/?score=1&uuid=%%{uuid}%%',
|
'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%',
|
||||||
'http://feedback-link.com/?score=0&uuid=%%{uuid}%%',
|
'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%',
|
||||||
'%%{unsubscribe_url}%%',
|
'%%{unsubscribe_url}%%',
|
||||||
'https://ghost.org/?via=pbg-newsletter'
|
'https://ghost.org/?via=pbg-newsletter'
|
||||||
]);
|
]);
|
||||||
@ -1552,21 +1578,23 @@ describe('Email renderer', function () {
|
|||||||
`http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
`http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
||||||
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexternal-domain.com%2F%3Fref%3D123%26source_tracking%3Dsite`,
|
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexternal-domain.com%2F%3Fref%3D123%26source_tracking%3Dsite`,
|
||||||
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
|
||||||
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
|
`http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
|
||||||
`%%{unsubscribe_url}%%`,
|
`%%{unsubscribe_url}%%`,
|
||||||
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
|
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check uuid in replacements
|
// Check uuid in replacements
|
||||||
response.replacements.length.should.eql(3);
|
response.replacements.length.should.eql(4);
|
||||||
response.replacements[0].id.should.eql('uuid');
|
response.replacements[0].id.should.eql('uuid');
|
||||||
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
|
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
|
||||||
response.replacements[1].id.should.eql('unsubscribe_url');
|
response.replacements[1].id.should.eql('key');
|
||||||
response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g);
|
response.replacements[1].token.should.eql(/%%\{key\}%%/g);
|
||||||
response.replacements[2].id.should.eql('list_unsubscribe');
|
response.replacements[2].id.should.eql('unsubscribe_url');
|
||||||
|
response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g);
|
||||||
|
response.replacements[3].id.should.eql('list_unsubscribe');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes data-gh-segment and renders paywall', async function () {
|
it('removes data-gh-segment and renders paywall', async function () {
|
||||||
@ -1643,14 +1671,16 @@ describe('Email renderer', function () {
|
|||||||
|
|
||||||
response.html.should.containEql('Unsubscribe');
|
response.html.should.containEql('Unsubscribe');
|
||||||
response.html.should.containEql('http://example.com');
|
response.html.should.containEql('http://example.com');
|
||||||
response.replacements.length.should.eql(3);
|
response.replacements.length.should.eql(4);
|
||||||
response.replacements.should.match([
|
response.replacements.should.match([
|
||||||
{
|
{
|
||||||
id: 'uuid'
|
id: 'uuid'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'unsubscribe_url',
|
id: 'key'
|
||||||
token: /%%\{unsubscribe_url\}%%/g
|
},
|
||||||
|
{
|
||||||
|
id: 'unsubscribe_url'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list_unsubscribe'
|
id: 'list_unsubscribe'
|
||||||
@ -2263,42 +2293,38 @@ describe('Email renderer', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createUnsubscribeUrl', function () {
|
describe('createUnsubscribeUrl', function () {
|
||||||
it('includes member uuid and newsletter id', async function () {
|
let emailRenderer;
|
||||||
const emailRenderer = new EmailRenderer({
|
|
||||||
|
beforeEach(function () {
|
||||||
|
emailRenderer = new EmailRenderer({
|
||||||
urlUtils: {
|
urlUtils: {
|
||||||
urlFor() {
|
urlFor() {
|
||||||
return 'http://example.com/subdirectory';
|
return 'http://example.com/subdirectory';
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
settingsHelpers: {
|
||||||
|
getMembersValidationKey
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes member uuid and newsletter id', async function () {
|
||||||
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
|
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
|
||||||
newsletterUuid: 'newsletteruuid'
|
newsletterUuid: 'newsletteruuid'
|
||||||
});
|
});
|
||||||
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&newsletter=newsletteruuid`);
|
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex');
|
||||||
|
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&newsletter=newsletteruuid`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes comments', async function () {
|
it('includes comments', async function () {
|
||||||
const emailRenderer = new EmailRenderer({
|
|
||||||
urlUtils: {
|
|
||||||
urlFor() {
|
|
||||||
return 'http://example.com/subdirectory';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
|
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
|
||||||
comments: true
|
comments: true
|
||||||
});
|
});
|
||||||
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&comments=1`);
|
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex');
|
||||||
|
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&comments=1`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works for previews', async function () {
|
it('works for previews', async function () {
|
||||||
const emailRenderer = new EmailRenderer({
|
|
||||||
urlUtils: {
|
|
||||||
urlFor() {
|
|
||||||
return 'http://example.com/subdirectory';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const response = await emailRenderer.createUnsubscribeUrl();
|
const response = await emailRenderer.createUnsubscribeUrl();
|
||||||
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`);
|
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user