From a8687b35b9ed43790a93cdfc21600b7ebb3ab224 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 22 Apr 2022 13:20:44 +0100 Subject: [PATCH] Added newsletter from address verification (#14491) refs https://github.com/TryGhost/Team/issues/1498 refs https://github.com/TryGhost/Team/issues/584 - Added newsletter `from` address verification Co-authored-by: Hannah Wolfe --- core/server/api/canary/newsletters.js | 17 +- core/server/services/members/api.js | 4 +- .../newsletters/emails/verify-email.js | 166 ++++++++ core/server/services/newsletters/index.js | 18 +- core/server/services/newsletters/service.js | 165 +++++++- core/server/web/api/canary/admin/routes.js | 1 + .../__snapshots__/newsletters.test.js.snap | 396 ++++++++++++++++-- test/e2e-api/admin/newsletters.test.js | 126 +++++- .../server/services/newsletters/index.test.js | 42 +- .../services/newsletters/service.test.js | 194 +++++++++ 10 files changed, 1042 insertions(+), 87 deletions(-) create mode 100644 core/server/services/newsletters/emails/verify-email.js create mode 100644 test/unit/server/services/newsletters/service.test.js diff --git a/core/server/api/canary/newsletters.js b/core/server/api/canary/newsletters.js index 750754665f..c6c726c88d 100644 --- a/core/server/api/canary/newsletters.js +++ b/core/server/api/canary/newsletters.js @@ -5,6 +5,7 @@ const errors = require('@tryghost/errors'); const messages = { newsletterNotFound: 'Newsletter not found.' }; +const newslettersService = require('../../services/newsletters'); module.exports = { docName: 'newsletters', @@ -53,7 +54,7 @@ module.exports = { statusCode: 201, permissions: true, async query(frame) { - return models.Newsletter.add(frame.data.newsletters[0], frame.options); + return newslettersService.add(frame.data.newsletters[0], frame.options); } }, @@ -71,7 +72,19 @@ module.exports = { }, permissions: true, async query(frame) { - return models.Newsletter.edit(frame.data.newsletters[0], frame.options); + return newslettersService.edit(frame.data.newsletters[0], frame.options); + } + }, + + verifyPropertyUpdate: { + permissions: { + method: 'edit' + }, + data: [ + 'token' + ], + async query(frame) { + return newslettersService.verifyPropertyUpdate(frame.data.token); } } }; diff --git a/core/server/services/members/api.js b/core/server/services/members/api.js index dbaec1d305..f23cfbebce 100644 --- a/core/server/services/members/api.js +++ b/core/server/services/members/api.js @@ -13,7 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider'); const urlUtils = require('../../../shared/url-utils'); const labsService = require('../../../shared/labs'); const offersService = require('../offers'); -const getNewslettersServiceInstance = require('../newsletters'); +const newslettersService = require('../newsletters'); const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; @@ -197,7 +197,7 @@ function createApiInstance(config) { stripeAPIService: stripeService.api, offersAPI: offersService.api, labsService: labsService, - newslettersService: getNewslettersServiceInstance({NewsletterModel: models.Newsletter}) + newslettersService: newslettersService }); return membersApiInstance; diff --git a/core/server/services/newsletters/emails/verify-email.js b/core/server/services/newsletters/emails/verify-email.js new file mode 100644 index 0000000000..5196411498 --- /dev/null +++ b/core/server/services/newsletters/emails/verify-email.js @@ -0,0 +1,166 @@ +module.exports = ({email, url}) => ` + + + + + + Confirm your email address + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Hey there,

+

Please confirm your email address with this link:

+ + + + + + +
+ + + + + + +
Confirm email address
+
+

For your security, the link will expire in 24 hours time.

+
+

You can also copy & paste this URL into your browser:

+

${url}

+
+
+ + + + + + +
+
 
+ + +`; diff --git a/core/server/services/newsletters/index.js b/core/server/services/newsletters/index.js index 7cf6d53891..b6b125c07f 100644 --- a/core/server/services/newsletters/index.js +++ b/core/server/services/newsletters/index.js @@ -1,10 +1,14 @@ const NewslettersService = require('./service.js'); +const SingleUseTokenProvider = require('../members/SingleUseTokenProvider'); +const mail = require('../mail'); +const models = require('../../models'); +const urlUtils = require('../../../shared/url-utils'); -/** - * @returns {NewslettersService} instance of the NewslettersService - */ -const getNewslettersServiceInstance = ({NewsletterModel}) => { - return new NewslettersService({NewsletterModel}); -}; +const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; -module.exports = getNewslettersServiceInstance; +module.exports = new NewslettersService({ + NewsletterModel: models.Newsletter, + mail, + singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY), + urlUtils +}); diff --git a/core/server/services/newsletters/service.js b/core/server/services/newsletters/service.js index 98ce782c1e..e7b2135104 100644 --- a/core/server/services/newsletters/service.js +++ b/core/server/services/newsletters/service.js @@ -1,11 +1,70 @@ +const _ = require('lodash'); +const MagicLink = require('@tryghost/magic-link'); +const logging = require('@tryghost/logging'); +const verifyEmailTemplate = require('./emails/verify-email'); + class NewslettersService { /** * * @param {Object} options * @param {Object} options.NewsletterModel + * @param {Object} options.mail + * @param {Object} options.singleUseTokenProvider + * @param {Object} options.urlUtils */ - constructor({NewsletterModel}) { + constructor({NewsletterModel, mail, singleUseTokenProvider, urlUtils}) { this.NewsletterModel = NewsletterModel; + this.urlUtils = urlUtils; + + /* email verification setup */ + + this.ghostMailer = new mail.GhostMailer(); + + const {transporter, getSubject, getText, getHTML, getSigninURL} = { + transporter: { + sendMail() { + // noop - overridden in `sendEmailVerificationMagicLink` + } + }, + getSubject() { + // not used - overridden in `sendEmailVerificationMagicLink` + return `Verify email address`; + }, + getText(url, type, email) { + return ` + Hey there, + + Please confirm your email address with this link: + + ${url} + + For your security, the link will expire in 24 hours time. + + --- + + Sent to ${email} + If you did not make this request, you can simply delete this message. This email address will not be used. + `; + }, + getHTML(url, type, email) { + return verifyEmailTemplate({url, email}); + }, + getSigninURL(token) { + const adminUrl = urlUtils.urlFor('admin', true); + const signinURL = new URL(adminUrl); + signinURL.hash = `/settings/members-email-labs/?verifyEmail=${token}`; + return signinURL.href; + } + }; + + this.magicLinkService = new MagicLink({ + transporter, + tokenProvider: singleUseTokenProvider, + getSigninURL, + getText, + getHTML, + getSubject + }); } /** @@ -18,7 +77,109 @@ class NewslettersService { return newsletters.toJSON(); } + + async add(attrs, options) { + // remove any email properties that are not allowed to be set without verification + const {cleanedAttrs, emailsToVerify} = await this.prepAttrsForEmailVerification(attrs); + + // add the model now because we need the ID for sending verification emails + const newsletter = await this.NewsletterModel.add(cleanedAttrs, options); + + // send any verification emails and respond with the appropriate meta added + return this.respondWithEmailVerification(newsletter, emailsToVerify); + } + + async edit(attrs, options) { + // fetch newsletter first so we can compare changed emails + const originalNewsletter = await this.NewsletterModel.findOne(options, {require: true}); + + const {cleanedAttrs, emailsToVerify} = await this.prepAttrsForEmailVerification(attrs, originalNewsletter); + + const updatedNewsletter = await this.NewsletterModel.edit(cleanedAttrs, options); + + return this.respondWithEmailVerification(updatedNewsletter, emailsToVerify); + } + + async verifyPropertyUpdate(token) { + const data = await this.magicLinkService.getDataFromToken(token); + const {id, property, value} = data; + + const attrs = {}; + attrs[property] = value; + + return this.NewsletterModel.edit(attrs, {id}); + } + + /* Email verification (private) */ + + async prepAttrsForEmailVerification(attrs, newsletter) { + const cleanedAttrs = _.cloneDeep(attrs); + const emailsToVerify = []; + + for (const property of ['sender_email']) { + const email = cleanedAttrs[property]; + const hasChanged = !newsletter || newsletter.get(property) !== email; + + if (await this.requiresEmailVerification({email, hasChanged})) { + delete cleanedAttrs[property]; + emailsToVerify.push({email, property}); + } + } + + return {cleanedAttrs, emailsToVerify}; + } + + async requiresEmailVerification({email, hasChanged}) { + if (!email || !hasChanged) { + return false; + } + + // TODO: check other newsletters for known/verified email + + return true; + } + + async respondWithEmailVerification(newsletter, emailsToVerify) { + if (emailsToVerify.length > 0) { + for (const {email, property} of emailsToVerify) { + await this.sendEmailVerificationMagicLink({id: newsletter.get('id'), email, property}); + } + + newsletter.meta = { + sent_email_verification: emailsToVerify.map(v => v.property) + }; + } + + return newsletter; + } + + async sendEmailVerificationMagicLink({id, email, property = 'sender_from'}) { + const [,toDomain] = email.split('@'); + + let fromEmail = `noreply@${toDomain}`; + if (fromEmail === email) { + fromEmail = `no-reply@${toDomain}`; + } + + const {ghostMailer} = this; + + this.magicLinkService.transporter = { + sendMail(message) { + if (process.env.NODE_ENV !== 'production') { + logging.warn(message.text); + } + let msg = Object.assign({ + from: fromEmail, + subject: 'Verify email address', + forceTextContent: true + }, message); + + return ghostMailer.send(msg); + } + }; + + return this.magicLinkService.sendMagicLink({email, tokenData: {id, property, value: email}}); + } } module.exports = NewslettersService; - diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 2fb5c47104..3e227bef80 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -315,6 +315,7 @@ module.exports = function apiRoutes() { router.get('/newsletters', mw.authAdminApi, http(api.newsletters.browse)); router.get('/newsletters/:id', mw.authAdminApi, http(api.newsletters.read)); router.post('/newsletters', mw.authAdminApi, http(api.newsletters.add)); + router.put('/newsletters/verifications/', mw.authAdminApi, http(api.newsletters.verifyPropertyUpdate)); router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit)); return router; diff --git a/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap b/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap index 8b4333f58f..a2fd0021f2 100644 --- a/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap +++ b/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap @@ -1,5 +1,197 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Newsletters API Can add a newsletter - with custom sender_email 1: [body] 1`] = ` +Object { + "meta": Object { + "sent_email_verification": Array [ + "sender_email", + ], + }, + "newsletters": Array [ + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "My test newsletter with custom sender_email", + "sender_email": null, + "sender_name": "Test", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "my-test-newsletter-with-custom-sender_email", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Can add a newsletter - with custom sender_email 2: [headers] 1`] = ` +Object { + "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", + "content-length": "628", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": Any, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Can add a newsletter - with custom sender_email 3: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 15, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 5, + }, + }, + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Default Newsletter", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "visibility": "members", + }, + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "My test newsletter", + "sender_email": null, + "sender_name": "Test", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "my-test-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "My test newsletter with custom sender_email", + "sender_email": null, + "sender_name": "Test", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "my-test-newsletter-with-custom-sender_email", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Weekly newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "weekly-newsletter", + "sort_order": 2, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Can add a newsletter - with custom sender_email 4: [headers] 1`] = ` +Object { + "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", + "content-length": "2735", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Newsletters API Can add a newsletter 1: [body] 1`] = ` Object { "newsletters": Array [ @@ -10,7 +202,7 @@ Object { "header_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "My test newsletter", - "sender_email": "test@example.com", + "sender_email": null, "sender_name": "Test", "sender_reply_to": "newsletter", "show_badge": true, @@ -34,7 +226,7 @@ exports[`Newsletters API Can add a newsletter 2: [headers] 1`] = ` Object { "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", - "content-length": "540", + "content-length": "526", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": Any, @@ -86,7 +278,7 @@ Object { "header_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "My test newsletter", - "sender_email": "test@example.com", + "sender_email": null, "sender_name": "Test", "sender_reply_to": "newsletter", "show_badge": true, @@ -156,7 +348,7 @@ exports[`Newsletters API Can add a newsletter 4: [headers] 1`] = ` Object { "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", - "content-length": "2190", + "content-length": "2176", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -173,7 +365,7 @@ Object { "page": 1, "pages": 1, "prev": null, - "total": 4, + "total": 5, }, }, "newsletters": Array [ @@ -207,7 +399,7 @@ Object { "header_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "My test newsletter", - "sender_email": "test@example.com", + "sender_email": null, "sender_name": "Test", "sender_reply_to": "newsletter", "show_badge": true, @@ -223,6 +415,29 @@ Object { "title_font_category": "serif", "visibility": "members", }, + Object { + "body_font_category": "serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "My test newsletter with custom sender_email", + "sender_email": null, + "sender_name": "Test", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "my-test-newsletter-with-custom-sender_email", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "serif", + "visibility": "members", + }, Object { "body_font_category": "serif", "description": null, @@ -277,7 +492,7 @@ exports[`Newsletters API Can browse newsletters 2: [headers] 1`] = ` Object { "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", - "content-length": "2190", + "content-length": "2735", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -292,9 +507,9 @@ Object { "limit": 1, "next": 2, "page": 1, - "pages": 4, + "pages": 5, "prev": null, - "total": 4, + "total": 5, }, }, "newsletters": Array [ @@ -379,6 +594,105 @@ Object { } `; +exports[`Newsletters API Can edit newsletters with updated sender_email 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 1, + "next": 2, + "page": 1, + "pages": 5, + "prev": null, + "total": 5, + }, + }, + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Updated newsletter name", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Can edit newsletters with updated sender_email 2: [headers] 1`] = ` +Object { + "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", + "content-length": "623", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Can edit newsletters with updated sender_email 3: [body] 1`] = ` +Object { + "meta": Object { + "sent_email_verification": Array [ + "sender_email", + ], + }, + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Updated newsletter name", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Can edit newsletters with updated sender_email 4: [headers] 1`] = ` +Object { + "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", + "content-length": "591", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Newsletters API Can read a newsletter 1: [body] 1`] = ` Object { "newsletters": Array [ @@ -421,57 +735,61 @@ Object { } `; -exports[`Newsletters API Cannot add newsletter with same name 1: [body] 1`] = ` +exports[`Newsletters API Can verify property updates 1: [body] 1`] = ` Object { "newsletters": Array [ Object { - "body_font_category": "serif", + "body_font_category": "sans_serif", "description": null, "footer_content": null, "header_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "name": "My test newsletter", - "recipient_filter": "members", - "sender_email": "test@example.com", - "sender_name": "Test", + "name": "Updated newsletter name", + "sender_email": "verify@example.com", + "sender_name": null, "sender_reply_to": "newsletter", "show_badge": true, "show_feature_image": true, "show_header_icon": true, + "show_header_name": true, "show_header_title": true, - "slug": "my-test-newsletter-2", + "slug": "default-newsletter", "sort_order": 0, "status": "active", "subscribe_on_signup": true, "title_alignment": "center", - "title_font_category": "serif", + "title_font_category": "sans_serif", + "visibility": "members", }, ], } `; -exports[`Newsletters API Cannot add newsletter with same name 1: [headers] 1`] = ` +exports[`Newsletters API Can verify restricted property updates 1: [body] 1`] = ` Object { - "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", - "content-length": "524", - "content-type": "application/json; charset=utf-8", - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": Any, - "vary": "Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Newsletters API Cannot add newsletter with same name 2: [headers] 1`] = ` -Object { - "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", - "content-length": "524", - "content-type": "application/json; charset=utf-8", - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": Any, - "vary": "Origin, Accept-Encoding", - "x-powered-by": "Express", + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "description": null, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Updated newsletter name", + "sender_email": "verify@example.com", + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "visibility": "members", + }, + ], } `; diff --git a/test/e2e-api/admin/newsletters.test.js b/test/e2e-api/admin/newsletters.test.js index 470abfd47b..4c85ca27e4 100644 --- a/test/e2e-api/admin/newsletters.test.js +++ b/test/e2e-api/admin/newsletters.test.js @@ -9,6 +9,8 @@ const newsletterSnapshot = { let agent; describe('Newsletters API', function () { + let mailMocks; + before(async function () { agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('newsletters'); @@ -16,6 +18,7 @@ describe('Newsletters API', function () { }); beforeEach(function () { + mailMocks = mockManager.mockMail(); }); afterEach(function () { @@ -26,7 +29,7 @@ describe('Newsletters API', function () { const newsletter = { name: 'My test newsletter', sender_name: 'Test', - sender_email: 'test@example.com', + sender_email: null, sender_reply_to: 'newsletter', status: 'active', subscribe_on_signup: true, @@ -60,11 +63,57 @@ describe('Newsletters API', function () { }); }); + it('Can add a newsletter - with custom sender_email', async function () { + const newsletter = { + name: 'My test newsletter with custom sender_email', + sender_name: 'Test', + sender_email: 'test@example.com', + sender_reply_to: 'newsletter', + status: 'active', + subscribe_on_signup: true, + title_font_category: 'serif', + body_font_category: 'serif', + show_header_icon: true, + show_header_title: true, + show_badge: true, + sort_order: 0 + }; + + await agent + .post(`newsletters/`) + .body({newsletters: [newsletter]}) + .expectStatus(201) + .matchBodySnapshot({ + newsletters: new Array(1).fill(newsletterSnapshot), + meta: { + sent_email_verification: ['sender_email'] + } + }) + .matchHeaderSnapshot({ + etag: anyEtag, + location: anyString + }); + + mockManager.assert.sentEmail({ + subject: 'Verify email address', + to: 'test@example.com' + }); + + await agent.get('newsletters/') + .expectStatus(200) + .matchBodySnapshot({ + newsletters: new Array(5).fill(newsletterSnapshot) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + }); + it('Can browse newsletters', async function () { await agent.get('newsletters/') .expectStatus(200) .matchBodySnapshot({ - newsletters: new Array(4).fill(newsletterSnapshot) + newsletters: new Array(5).fill(newsletterSnapshot) }) .matchHeaderSnapshot({ etag: anyEtag @@ -76,7 +125,8 @@ describe('Newsletters API', function () { .get(`newsletters/${testUtils.DataGenerator.Content.newsletters[0].id}/`) .expectStatus(200) .matchBodySnapshot({ - newsletters: new Array(1).fill(newsletterSnapshot) + newsletters: [newsletterSnapshot] + }) .matchHeaderSnapshot({ etag: anyEtag @@ -109,4 +159,74 @@ describe('Newsletters API', function () { etag: anyEtag }); }); + + it('Can edit newsletters with updated sender_email', async function () { + const res = await agent.get('newsletters?limit=1') + .expectStatus(200) + .matchBodySnapshot({ + newsletters: [newsletterSnapshot] + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + + const id = res.body.newsletters[0].id; + + await agent.put(`newsletters/${id}`) + .body({ + newsletters: [{ + name: 'Updated newsletter name', + sender_email: 'updated@example.com' + }] + }) + .expectStatus(200) + .matchBodySnapshot({ + newsletters: [newsletterSnapshot], + meta: { + sent_email_verification: ['sender_email'] + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + + mockManager.assert.sentEmail({ + subject: 'Verify email address', + to: 'updated@example.com' + }); + }); + + it('Can verify property updates', async function () { + const cheerio = require('cheerio'); + + const res = await agent.get('newsletters?limit=1') + .expectStatus(200); + + const id = res.body.newsletters[0].id; + + await agent.put(`newsletters/${id}`) + .body({ + newsletters: [{ + name: 'Updated newsletter name', + sender_email: 'verify@example.com' + }] + }) + .expectStatus(200); + + const mailHtml = mailMocks.getCall(0).args[0].html; + const $mailHtml = cheerio.load(mailHtml); + + const verifyUrl = new URL($mailHtml('[data-test-verify-link]').attr('href')); + // convert Admin URL hash to native URL for easier token param extraction + const token = (new URL(verifyUrl.hash.replace('#', ''), 'http://example.com')).searchParams.get('verifyEmail'); + + await agent.put(`newsletters/verifications`) + .body({ + token + }) + .expectStatus(200) + .matchBodySnapshot({ + newsletters: [newsletterSnapshot] + }); + }); }); diff --git a/test/unit/server/services/newsletters/index.test.js b/test/unit/server/services/newsletters/index.test.js index 040a450bd3..b2f5fa4ff7 100644 --- a/test/unit/server/services/newsletters/index.test.js +++ b/test/unit/server/services/newsletters/index.test.js @@ -1,43 +1,21 @@ -const should = require('should'); -const sinon = require('sinon'); -const getNewslettersServiceInstance = require('../../../../../core/server/services/newsletters'); const models = require('../../../../../core/server/models'); +const assert = require('assert'); describe('Newsletters Service', function () { + let newslettersService; + before(function () { models.init(); }); - afterEach(function () { - sinon.restore(); - }); + describe('Newsletter Service', function () { + it('Provides expected public API', async function () { + newslettersService = require('../../../../../core/server/services/newsletters'); - describe('browse', function () { - it('lists all newsletters', async function () { - const findAllStub = { - toJSON: function () { - return [ - { - id: 'newsletter-1' - }, - { - id: 'newsletter-2' - } - ]; - } - }; - sinon.stub(models.Newsletter, 'findAll').returns(Promise.resolve(findAllStub)); - - const NewslettersService = getNewslettersServiceInstance({NewsletterModel: models.Newsletter}); - const newsletters = await NewslettersService.browse({}); - should(newsletters).deepEqual([ - { - id: 'newsletter-1' - }, - { - id: 'newsletter-2' - } - ]); + assert.ok(newslettersService.browse); + assert.ok(newslettersService.edit); + assert.ok(newslettersService.add); + assert.ok(newslettersService.verifyPropertyUpdate); }); }); }); diff --git a/test/unit/server/services/newsletters/service.test.js b/test/unit/server/services/newsletters/service.test.js new file mode 100644 index 0000000000..1a67cae426 --- /dev/null +++ b/test/unit/server/services/newsletters/service.test.js @@ -0,0 +1,194 @@ +const sinon = require('sinon'); +const assert = require('assert'); + +// DI requirements +const models = require('../../../../../core/server/models'); +const mail = require('../../../../../core/server/services/mail'); + +// Mocked utilities +const urlUtils = require('../../../../utils/urlUtils'); +const {mockManager} = require('../../../../utils/e2e-framework'); + +const NewslettersService = require('../../../../../core/server/services/newsletters/service'); + +class TestTokenProvider { + async create(data) { + return JSON.stringify(data); + } + + async validate(token) { + return JSON.parse(token); + } +} + +describe('NewslettersService', function () { + let newsletterService, getStub, tokenProvider; + + before(function () { + models.init(); + + tokenProvider = new TestTokenProvider(); + + newsletterService = new NewslettersService({ + NewsletterModel: models.Newsletter, + mail, + singleUseTokenProvider: tokenProvider, + urlUtils: urlUtils.stubUrlUtilsFromConfig() + }); + }); + + beforeEach(function () { + getStub = sinon.stub(); + sinon.spy(tokenProvider, 'create'); + sinon.spy(tokenProvider, 'validate'); + mockManager.mockMail(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + // @TODO replace this with a specific function for fetching all available newsletters + describe('browse', function () { + it('lists all newsletters by calling findAll and toJSON', async function () { + const toJSONStub = sinon.stub(); + const findAllStub = sinon.stub(models.Newsletter, 'findAll').returns({toJSON: toJSONStub}); + + await newsletterService.browse({}); + + sinon.assert.calledOnce(findAllStub); + sinon.assert.calledOnce(toJSONStub); + }); + }); + + describe('add', function () { + let addStub; + beforeEach(function () { + // Stub add as a function that returns a get + addStub = sinon.stub(models.Newsletter, 'add').returns({get: getStub}); + }); + + it('rejects if called with no data', async function () { + assert.rejects(await newsletterService.add, {name: 'TypeError'}); + sinon.assert.notCalled(addStub); + }); + + it('will attempt to add empty object without verification', async function () { + const result = await newsletterService.add({}); + + assert.equal(result.meta, undefined); // meta property has not been added + sinon.assert.calledOnceWithExactly(addStub, {}, undefined); + }); + + it('will pass object and options through to model when there are no fields needing verification', async function () { + const data = {name: 'hello world'}; + const options = {foo: 'bar'}; + + const result = await newsletterService.add(data, options); + + assert.equal(result.meta, undefined); // meta property has not been added + sinon.assert.calledOnceWithExactly(addStub, data, options); + }); + + it('will trigger verification when sender_email is provided', async function () { + const data = {name: 'hello world', sender_email: 'test@example.com'}; + const options = {foo: 'bar'}; + + const result = await newsletterService.add(data, options); + + assert.deepEqual(result.meta, { + sent_email_verification: [ + 'sender_email' + ] + }); + sinon.assert.calledOnceWithExactly(addStub, {name: 'hello world'}, options); + mockManager.assert.sentEmail({to: 'test@example.com'}); + sinon.assert.calledOnceWithExactly(tokenProvider.create, {id: undefined, property: 'sender_email', value: 'test@example.com'}); + }); + }); + + describe('edit', function () { + let editStub, findOneStub; + beforeEach(function () { + // Stub edit as a function that returns its first argument + editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub}); + findOneStub = sinon.stub(models.Newsletter, 'findOne').returns({get: getStub}); + }); + + it('rejects if called with no data', async function () { + assert.rejects(await newsletterService.add, {name: 'TypeError'}); + sinon.assert.notCalled(editStub); + }); + + it('will attempt to add empty object without verification', async function () { + const result = await newsletterService.edit({}); + + assert.equal(result.meta, undefined); // meta property has not been added + sinon.assert.calledOnceWithExactly(editStub, {}, undefined); + }); + + it('will pass object and options through to model when there are no fields needing verification', async function () { + const data = {name: 'hello world'}; + const options = {foo: 'bar'}; + + const result = await newsletterService.edit(data, options); + + assert.equal(result.meta, undefined); // meta property has not been added + sinon.assert.calledOnceWithExactly(editStub, data, options); + sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true}); + }); + + it('will trigger verification when sender_email is provided', async function () { + const data = {name: 'hello world', sender_email: 'test@example.com'}; + const options = {foo: 'bar'}; + + const result = await newsletterService.edit(data, options); + + assert.deepEqual(result.meta, { + sent_email_verification: [ + 'sender_email' + ] + }); + sinon.assert.calledOnceWithExactly(editStub, {name: 'hello world'}, options); + sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true}); + mockManager.assert.sentEmail({to: 'test@example.com'}); + sinon.assert.calledOnceWithExactly(tokenProvider.create, {id: undefined, property: 'sender_email', value: 'test@example.com'}); + }); + + it('will NOT trigger verification when sender_email is provided but is already verified', async function () { + const data = {name: 'hello world', sender_email: 'test@example.com'}; + const options = {foo: 'bar'}; + + // The model says this is already verified + getStub.withArgs('sender_email').returns('test@example.com'); + + const result = await newsletterService.edit(data, options); + + assert.deepEqual(result.meta, undefined); + sinon.assert.calledOnceWithExactly(editStub, {name: 'hello world', sender_email: 'test@example.com'}, options); + sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true}); + mockManager.assert.sentEmailCount(0); + }); + }); + + describe('verifyPropertyUpdate', function () { + let editStub; + + beforeEach(function () { + editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub}); + sinon.assert.notCalled(editStub); + }); + + it('rejects if called with no data', async function () { + assert.rejects(await newsletterService.verifyPropertyUpdate, {name: 'TypeError'}); + }); + + it('Updates model with values from token', async function () { + const token = JSON.stringify({id: 'abc123', property: 'sender_email', value: 'test@example.com'}); + + await newsletterService.verifyPropertyUpdate(token); + + sinon.assert.calledOnceWithExactly(editStub, {sender_email: 'test@example.com'}, {id: 'abc123'}); + }); + }); +});