From 2f17f539fb414b91a4678cd5b775cc1239b359a0 Mon Sep 17 00:00:00 2001 From: Phil Booth Date: Mon, 12 Aug 2019 09:43:58 +0100 Subject: [PATCH] refactor(email): pull selectEmailServices out to its own module --- packages/fxa-auth-server/lib/senders/email.js | 229 +--- .../lib/senders/select_email_services.js | 214 ++++ .../test/local/senders/email.js | 1025 +---------------- .../local/senders/select_email_services.js | 949 +++++++++++++++ 4 files changed, 1173 insertions(+), 1244 deletions(-) create mode 100644 packages/fxa-auth-server/lib/senders/select_email_services.js create mode 100644 packages/fxa-auth-server/test/local/senders/select_email_services.js diff --git a/packages/fxa-auth-server/lib/senders/email.js b/packages/fxa-auth-server/lib/senders/email.js index 67219783664..d0dde9683e2 100644 --- a/packages/fxa-auth-server/lib/senders/email.js +++ b/packages/fxa-auth-server/lib/senders/email.js @@ -9,9 +9,7 @@ const moment = require('moment-timezone'); const nodemailer = require('nodemailer'); const P = require('bluebird'); const qs = require('querystring'); -const safeRegex = require('safe-regex'); const safeUserAgent = require('../userAgent/safe'); -const Sandbox = require('sandbox'); const url = require('url'); const TEMPLATE_VERSIONS = { @@ -26,24 +24,8 @@ const UTM_PREFIX = 'fx-'; const X_SES_CONFIGURATION_SET = 'X-SES-CONFIGURATION-SET'; const X_SES_MESSAGE_TAGS = 'X-SES-MESSAGE-TAGS'; -const SERVICES = { - internal: Symbol(), - external: { - sendgrid: Symbol(), - socketlabs: Symbol(), - ses: Symbol(), - }, -}; - module.exports = function(log, config, oauthdb) { const oauthClientInfo = require('./oauth_client_info')(log, config, oauthdb); - const redis = require('../redis')( - Object.assign({}, config.redis, config.redis.email), - log - ) || { - // Fallback to a stub implementation if redis is disabled - get: () => P.resolve(), - }; const verificationReminders = require('../verification-reminders')( log, config @@ -189,6 +171,12 @@ module.exports = function(log, config, oauthdb) { this.privacyUrl = mailerConfig.privacyUrl; this.reportSignInUrl = mailerConfig.reportSignInUrl; this.revokeAccountRecoveryUrl = mailerConfig.revokeAccountRecoveryUrl; + this.selectEmailServices = require('./select_email_services')( + log, + config, + this.mailer, + this.emailService + ); this.sender = mailerConfig.sender; this.sesConfigurationSet = mailerConfig.sesConfigurationSet; this.subscriptionDownloadUrl = mailerConfig.subscriptionDownloadUrl; @@ -454,211 +442,6 @@ module.exports = function(log, config, oauthdb) { }); }; - // Based on the to and cc email addresses of a message, return an array of - // `Service` objects that control how email traffic will be routed. - // - // It will attempt to read live config data from Redis and live config takes - // precedence over local static config. If no config is found at all, email - // will be routed locally via the auth server. - // - // Live config looks like this (every property is optional): - // - // { - // sendgrid: { - // percentage: 100, - // regex: "^.+@example\.com$" - // }, - // socketlabs: { - // percentage: 100, - // regex: "^.+@example\.org$" - // }, - // ses: { - // percentage: 10, - // regex: ".*", - // } - // } - // - // Where a percentage and a regex are both present, an email address must - // satisfy both criteria to count as a match. Where an email address matches - // sendgrid and ses, sendgrid wins. Where an email address matches socketlabs - // and ses, socketlabs wins. Where an email address matches sendgrid and - // socketlabs, sendgrid wins. - // - // If a regex has a star height greater than 1, the email address will be - // treated as a non-match without executing the regex (to prevent us redosing - // ourselves). If a regex takes longer than 100 milliseconds to execute, - // it will be killed and the email address will be treated as a non-match. - // - // @param {Object} message - // - // @returns {Promise} Resolves to an array of `Service` objects. - // - // @typedef {Object} Service - // - // @property {Object} mailer The object on which to invoke the `sendMail` - // method. - // - // @property {String[]} emailAddresses The array of email addresses to send to. - // The address at index 0 will be used as the - // `to` address and any remaining addresses - // will be included as `cc` addresses. - // - // @property {String} emailService The name of the email service for metrics. - // - // @property {String} emailSender The name of the underlying email sender, - // used for both metrics and sent as the - // `provider` param in external requests. - Mailer.prototype.selectEmailServices = function(message) { - const emailAddresses = [message.email]; - if (Array.isArray(message.ccEmails)) { - emailAddresses.push(...message.ccEmails); - } - - return redis - .get('config') - .catch(err => log.error('emailConfig.read.error', { err: err.message })) - .then(liveConfig => { - if (liveConfig) { - try { - liveConfig = JSON.parse(liveConfig); - } catch (err) { - log.error('emailConfig.parse.error', { err: err.message }); - } - } - - return emailAddresses.reduce((promise, emailAddress) => { - let services, isMatched; - - return promise - .then(s => { - services = s; - - if (liveConfig) { - return ['sendgrid', 'socketlabs', 'ses'].reduce( - (promise, key) => { - const senderConfig = liveConfig[key]; - - return promise - .then(() => { - if (senderConfig) { - return isLiveConfigMatch(senderConfig, emailAddress); - } - }) - .then(result => { - if (isMatched) { - return; - } - - isMatched = result; - - if (isMatched) { - upsertServicesMap( - services, - SERVICES.external[key], - emailAddress, - { - mailer: this.emailService, - emailService: 'fxa-email-service', - emailSender: key, - } - ); - } - }); - }, - promise - ); - } - }) - .then(() => { - if (isMatched) { - return services; - } - - if (config.emailService.forcedEmailAddresses.test(emailAddress)) { - return upsertServicesMap( - services, - SERVICES.external.ses, - emailAddress, - { - mailer: this.emailService, - emailService: 'fxa-email-service', - emailSender: 'ses', - } - ); - } - - return upsertServicesMap( - services, - SERVICES.internal, - emailAddress, - { - mailer: this.mailer, - emailService: 'fxa-auth-server', - emailSender: 'ses', - } - ); - }); - }, P.resolve(new Map())); - }) - .then(services => Array.from(services.values())); - - function isLiveConfigMatch(liveConfig, emailAddress) { - return new P(resolve => { - const { percentage, regex } = liveConfig; - - if ( - percentage >= 0 && - percentage < 100 && - Math.floor(Math.random() * 100) >= percentage - ) { - resolve(false); - return; - } - - if (regex) { - if ( - regex.indexOf('"') !== -1 || - emailAddress.indexOf('"') !== -1 || - !safeRegex(regex) - ) { - resolve(false); - return; - } - - // Execute the regex inside a sandbox and kill it if it takes > 100 ms - const sandbox = new Sandbox({ timeout: 100 }); - sandbox.run( - `new RegExp("${regex}").test("${emailAddress}")`, - output => { - resolve(output.result === 'true'); - } - ); - return; - } - - resolve(true); - }); - } - - function upsertServicesMap(services, service, emailAddress, data) { - if (services.has(service)) { - services.get(service).emailAddresses.push(emailAddress); - } else { - services.set( - service, - Object.assign( - { - emailAddresses: [emailAddress], - }, - data - ) - ); - } - - return services; - } - }; - Mailer.prototype.verifyEmail = async function(message) { log.trace('mailer.verifyEmail', { email: message.email, uid: message.uid }); diff --git a/packages/fxa-auth-server/lib/senders/select_email_services.js b/packages/fxa-auth-server/lib/senders/select_email_services.js new file mode 100644 index 00000000000..4a3ab302d64 --- /dev/null +++ b/packages/fxa-auth-server/lib/senders/select_email_services.js @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const Promise = require('../promise'); +const safeRegex = require('safe-regex'); +const Sandbox = require('sandbox'); + +const SERVICES = { + internal: Symbol(), + external: { + sendgrid: Symbol(), + socketlabs: Symbol(), + ses: Symbol(), + }, +}; + +module.exports = (log, config, mailer, emailService) => { + const redis = require('../redis')( + Object.assign({}, config.redis, config.redis.email), + log + ) || { + // Fallback to a stub implementation if redis is disabled + get: () => Promise.resolve(), + }; + + // Based on the to and cc email addresses of a message, return an array of + // `Service` objects that control how email traffic will be routed. + // + // It will attempt to read live config data from Redis and live config takes + // precedence over local static config. If no config is found at all, email + // will be routed locally via the auth server. + // + // Live config looks like this (every property is optional): + // + // { + // sendgrid: { + // percentage: 100, + // regex: "^.+@example\.com$" + // }, + // socketlabs: { + // percentage: 100, + // regex: "^.+@example\.org$" + // }, + // ses: { + // percentage: 10, + // regex: ".*", + // } + // } + // + // Where a percentage and a regex are both present, an email address must + // satisfy both criteria to count as a match. Where an email address matches + // sendgrid and ses, sendgrid wins. Where an email address matches socketlabs + // and ses, socketlabs wins. Where an email address matches sendgrid and + // socketlabs, sendgrid wins. + // + // If a regex has a star height greater than 1, the email address will be + // treated as a non-match without executing the regex (to prevent us redosing + // ourselves). If a regex takes longer than 100 milliseconds to execute, + // it will be killed and the email address will be treated as a non-match. + // + // @param {Object} message + // + // @returns {Promise} Resolves to an array of `Service` objects. + // + // @typedef {Object} Service + // + // @property {Object} mailer The object on which to invoke the `sendMail` + // method. + // + // @property {String[]} emailAddresses The array of email addresses to send to. + // The address at index 0 will be used as the + // `to` address and any remaining addresses + // will be included as `cc` addresses. + // + // @property {String} emailService The name of the email service for metrics. + // + // @property {String} emailSender The name of the underlying email sender, + // used for both metrics and sent as the + // `provider` param in external requests. + return async message => { + const emailAddresses = [message.email]; + if (Array.isArray(message.ccEmails)) { + emailAddresses.push(...message.ccEmails); + } + + let liveConfig; + try { + liveConfig = await redis.get('config'); + } catch (err) { + log.error('emailConfig.read.error', { err: err.message }); + } + + if (liveConfig) { + try { + liveConfig = JSON.parse(liveConfig); + } catch (err) { + log.error('emailConfig.parse.error', { err: err.message }); + } + } + + const services = await emailAddresses.reduce( + async (promise, emailAddress) => { + const services = await promise; + + if (liveConfig) { + const isMatched = await ['sendgrid', 'socketlabs', 'ses'].reduce( + async (promise, key) => { + if (await promise) { + return true; + } + + const senderConfig = liveConfig[key]; + + if ( + senderConfig && + (await isLiveConfigMatch(senderConfig, emailAddress)) + ) { + upsertServicesMap( + services, + SERVICES.external[key], + emailAddress, + { + mailer: emailService, + emailService: 'fxa-email-service', + emailSender: key, + } + ); + + return true; + } + + return false; + }, + Promise.resolve() + ); + + if (isMatched) { + return services; + } + } + + if (config.emailService.forcedEmailAddresses.test(emailAddress)) { + return upsertServicesMap( + services, + SERVICES.external.ses, + emailAddress, + { + mailer: emailService, + emailService: 'fxa-email-service', + emailSender: 'ses', + } + ); + } + + return upsertServicesMap(services, SERVICES.internal, emailAddress, { + mailer, + emailService: 'fxa-auth-server', + emailSender: 'ses', + }); + }, + Promise.resolve(new Map()) + ); + + return Array.from(services.values()); + }; +}; + +async function isLiveConfigMatch(liveConfig, emailAddress) { + return new Promise(resolve => { + const { percentage, regex } = liveConfig; + + if ( + percentage >= 0 && + percentage < 100 && + Math.floor(Math.random() * 100) >= percentage + ) { + resolve(false); + return; + } + + if (regex) { + if ( + regex.indexOf('"') !== -1 || + emailAddress.indexOf('"') !== -1 || + !safeRegex(regex) + ) { + resolve(false); + return; + } + + // Execute the regex inside a sandbox and kill it if it takes > 100 ms + const sandbox = new Sandbox({ timeout: 100 }); + sandbox.run(`new RegExp("${regex}").test("${emailAddress}")`, output => { + resolve(output.result === 'true'); + }); + return; + } + + resolve(true); + }); +} + +function upsertServicesMap(services, service, emailAddress, data) { + if (services.has(service)) { + services.get(service).emailAddresses.push(emailAddress); + } else { + services.set(service, { emailAddresses: [emailAddress], ...data }); + } + + return services; +} diff --git a/packages/fxa-auth-server/test/local/senders/email.js b/packages/fxa-auth-server/test/local/senders/email.js index 799ac41be6e..ac1619da41c 100644 --- a/packages/fxa-auth-server/test/local/senders/email.js +++ b/packages/fxa-auth-server/test/local/senders/email.js @@ -7,15 +7,11 @@ const ROOT_DIR = '../../..'; const { assert } = require('chai'); -const cp = require('child_process'); const mocks = require('../../mocks'); const P = require('bluebird'); -const path = require('path'); -const proxyquire = require('proxyquire').noPreserveCache(); +const proxyquire = require('proxyquire'); const sinon = require('sinon'); -cp.execAsync = P.promisify(cp.exec); - const config = require(`${ROOT_DIR}/config`).getProperties(); if (!config.smtp.prependVerificationSubdomain.enabled) { config.smtp.prependVerificationSubdomain.enabled = true; @@ -806,20 +802,16 @@ const TRAILHEAD_TESTS = new Map([ ]); describe('lib/senders/email:', () => { - let mockLog, redis, mailer, localize, selectEmailServices, sendMail; + let mockLog, mailer, localize, selectEmailServices, sendMail; before(async () => { mockLog = mocks.mockLog(); - redis = { - get: sinon.spy(() => P.resolve()), - }; mailer = await setup(mockLog, config, { './oauth_client_info': () => ({ async fetch() { return { name: 'Mock Relier' }; }, }), - '../redis': () => redis, }); // These tests do a lot of ad hoc mocking. Rather than try and clean up // after each case, give them carte blanche to do what they want then @@ -840,7 +832,6 @@ describe('lib/senders/email:', () => { fn.resetHistory(); } }); - redis.get.resetHistory(); if (mailer.localize !== localize) { mailer.localize = localize; } @@ -1141,8 +1132,6 @@ describe('lib/senders/email:', () => { assert.equal(headers['X-SES-CONFIGURATION-SET'], 'wibble'); assert.equal(typeof args[1], 'function'); - - assert.equal(redis.get.callCount, 1); }); }); @@ -1165,8 +1154,6 @@ describe('lib/senders/email:', () => { assert.equal(args[0].headers['X-Uid'], 'foo'); assert.equal(args[0].provider, undefined); assert.equal(typeof mailer.mailer.sendMail.args[0][1], 'function'); - - assert.equal(redis.get.callCount, 1); }); }); @@ -1309,793 +1296,6 @@ describe('lib/senders/email:', () => { }); }); }); - - describe('single email address:', () => { - const emailAddress = 'foo@example.com'; - - describe('redis.get returns sendgrid percentage-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { percentage: 11 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.109); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid percentage-only mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { percentage: 11 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.11); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid regex-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ sendgrid: { regex: '^foo@example.com$' } }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid regex-only mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: '^fo@example.com$' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid combined match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { - percentage: 1, - regex: '^foo@example.com$', - }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.009); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid combined mismatch (percentage):', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { - percentage: 1, - regex: '^foo@example.com$', - }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.01); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid combined mismatch (regex):', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { - percentage: 1, - regex: '^ffoo@example.com$', - }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns socketlabs percentage-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ socketlabs: { percentage: 42 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.419); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'socketlabs', - }, - ]) - ); - }); - }); - - describe('redis.get returns socketlabs percentage-only mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ socketlabs: { percentage: 42 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.42); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns socketlabs regex-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ socketlabs: { regex: '^foo@example.com$' } }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'socketlabs', - }, - ]) - ); - }); - }); - - describe('redis.get returns ses percentage-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ ses: { percentage: 100 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.999); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns ses percentage-only mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ ses: { percentage: 99 } })) - ); - sinon.stub(Math, 'random').callsFake(() => 0.999); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns ses regex-only match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ ses: { regex: '^foo@example.com$' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid and ses matches:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { percentage: 10 }, - ses: { regex: '^foo@example.com$' }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.09); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid match and ses mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { percentage: 10 }, - ses: { regex: '^ffoo@example.com$' }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.09); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid mismatch and ses match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { percentage: 10 }, - ses: { regex: '^foo@example.com$' }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.1); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid and ses mismatches:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { percentage: 10 }, - ses: { regex: '^ffoo@example.com$' }, - }) - ) - ); - sinon.stub(Math, 'random').callsFake(() => 0.1); - }); - - afterEach(() => Math.random.restore()); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns undefined:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => P.resolve()); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns unsafe regex:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ sendgrid: { regex: '^(.+)+@example.com$' } }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns quote-terminating regex:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: '"@example.com$' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('email address contains quote-terminator:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: '@example.com$' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: '"@example.com' }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: ['"@example.com'], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get fails:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => P.reject({ message: 'wibble' })); - }); - - it('selectEmailServices returns fallback data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => { - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]); - assert.equal(mockLog.error.callCount, 1); - const args = mockLog.error.args[0]; - assert.equal(args.length, 2); - assert.equal(args[0], 'emailConfig.read.error'); - assert.deepEqual(args[1], { - err: 'wibble', - }); - }); - }); - }); - - describe('redis.get returns invalid JSON:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => P.resolve('wibble')); - }); - - it('selectEmailServices returns fallback data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => { - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]); - assert.equal(mockLog.error.callCount, 1); - assert.equal(mockLog.error.args[0][0], 'emailConfig.parse.error'); - }); - }); - }); - }); - - describe('single email address matching local static email service config:', () => { - const emailAddress = 'emailservice.1@restmail.net'; - - describe('redis.get returns sendgrid match:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: 'restmail' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]) - ); - }); - }); - - describe('redis.get returns sendgrid mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: 'rustmail' } })) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ email: emailAddress }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - ]) - ); - }); - }); - }); - - describe('multiple email addresses:', () => { - const emailAddresses = ['a@example.com', 'b@example.com', 'c@example.com']; - - describe('redis.get returns sendgrid and ses matches and a mismatch:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { regex: '^a' }, - ses: { regex: '^b' }, - }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ - email: emailAddresses[0], - ccEmails: emailAddresses.slice(1), - }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: emailAddresses.slice(0, 1), - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - { - mailer: mailer.emailService, - emailAddresses: emailAddresses.slice(1, 2), - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - { - mailer: mailer.mailer, - emailAddresses: emailAddresses.slice(2), - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns a sendgrid match and two ses matches:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { regex: '^a' }, - ses: { regex: '^b|c' }, - }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ - email: emailAddresses[0], - ccEmails: emailAddresses.slice(1), - }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.emailService, - emailAddresses: emailAddresses.slice(0, 1), - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - { - mailer: mailer.emailService, - emailAddresses: emailAddresses.slice(1), - emailService: 'fxa-email-service', - emailSender: 'ses', - }, - ]) - ); - }); - }); - - describe('redis.get returns three mismatches:', () => { - beforeEach(() => { - redis.get = sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { regex: 'wibble' }, - ses: { regex: 'blee' }, - }) - ) - ); - }); - - it('selectEmailServices returns the correct data', () => { - return mailer - .selectEmailServices({ - email: emailAddresses[0], - ccEmails: emailAddresses.slice(1), - }) - .then(result => - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: emailAddresses, - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]) - ); - }); - }); - }); }); describe('mailer constructor:', () => { @@ -2148,170 +1348,15 @@ describe('mailer constructor:', () => { }); }); -describe('call selectEmailServices with mocked sandbox:', () => { - const emailAddress = 'foo@example.com'; - let mockLog, redis, Sandbox, sandbox, mailer, promise, result, failed; - - before(async () => { - mockLog = mocks.mockLog(); - redis = { - get: sinon.spy(() => - P.resolve(JSON.stringify({ sendgrid: { regex: '^foo@example.com$' } })) - ), - }; - // eslint-disable-next-line prefer-arrow-callback - Sandbox = sinon.spy(function() { - return sandbox; - }); - mailer = await setup(mockLog, config, { - '../redis': () => redis, - sandbox: Sandbox, - }); - }); - - after(() => mailer.stop()); - - beforeEach(done => { - sandbox = { - run: sinon.spy(), - }; - promise = mailer - .selectEmailServices({ email: emailAddress }) - .then(r => (result = r)) - .catch(() => (failed = true)); - setImmediate(done); - }); - - it('called the sandbox correctly', () => { - assert.equal(Sandbox.callCount, 1); - - let args = Sandbox.args[0]; - assert.equal(args.length, 1); - assert.deepEqual(args[0], { - timeout: 100, - }); - - assert.equal(sandbox.run.callCount, 1); - - args = sandbox.run.args[0]; - assert.equal(args.length, 2); - assert.equal( - args[0], - 'new RegExp("^foo@example.com$").test("foo@example.com")' - ); - assert.equal(typeof args[1], 'function'); - }); - - describe('call sandbox result handler with match:', () => { - beforeEach(() => { - sandbox.run.args[0][1]({ result: 'true' }); - return promise; - }); - - it('resolved', () => { - assert.deepEqual(result, [ - { - emailAddresses: ['foo@example.com'], - mailer: mailer.emailService, - emailService: 'fxa-email-service', - emailSender: 'sendgrid', - }, - ]); - }); - - it('did not fail', () => { - assert.equal(failed, undefined); - }); - }); - - describe('call sandbox result handler with timeout:', () => { - beforeEach(() => { - sandbox.run.args[0][1]({ result: 'TimeoutError' }); - return promise; - }); - - it('resolved', () => { - assert.deepEqual(result, [ - { - emailAddresses: ['foo@example.com'], - mailer: mailer.mailer, - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]); - }); - - it('did not fail', () => { - assert.equal(failed, undefined); - }); - }); -}); - -describe('call selectEmailServices with mocked safe-regex, regex-only match and redos regex:', () => { - const emailAddress = 'foo@example.com'; - let mockLog, redis, safeRegex, mailer; - - before(async () => { - mockLog = mocks.mockLog(); - redis = { - get: sinon.spy(() => - P.resolve( - JSON.stringify({ - sendgrid: { regex: '^((((.*)*)*)*)*@example.com$' }, - }) - ) - ), - }; - // eslint-disable-next-line prefer-arrow-callback - safeRegex = sinon.spy(function() { - return true; - }); - mailer = await setup(mockLog, config, { - '../redis': () => redis, - 'safe-regex': safeRegex, - }); - }); - - after(() => mailer.stop()); - - it('email address was treated as mismatch', () => { - return mailer.selectEmailServices({ email: emailAddress }).then(result => { - assert.deepEqual(result, [ - { - mailer: mailer.mailer, - emailAddresses: [emailAddress], - emailService: 'fxa-auth-server', - emailSender: 'ses', - }, - ]); - - assert.equal(safeRegex.callCount, 1); - const args = safeRegex.args[0]; - assert.equal(args.length, 1); - assert.equal(args[0], '^((((.*)*)*)*)*@example.com$'); - }); - }); -}); - describe('email translations', () => { - let mockLog, redis, mailer; + let mockLog, mailer; const message = { email: 'a@b.com', }; async function setupMailerWithTranslations(locale) { mockLog = mocks.mockLog(); - redis = { - get: sinon.spy(() => P.resolve()), - }; - mailer = await setup( - mockLog, - config, - { - '../redis': () => redis, - }, - locale - ); + mailer = await setup(mockLog, config, {}, locale); } afterEach(() => mailer.stop()); @@ -2351,53 +1396,6 @@ describe('email translations', () => { }); }); -if (config.redis.email.enabled) { - const emailAddress = 'foo@example.com'; - - ['sendgrid', 'ses', 'socketlabs'].reduce(async (promise, service) => { - await promise; - - return new P((resolve, reject) => { - describe(`call selectEmailServices with real redis containing ${service} config:`, () => { - let mailer, result; - - before(async () => { - const mockLog = mocks.mockLog(); - mailer = await setup(mockLog, config, {}); - await redisWrite({ - [service]: { - regex: '^foo@example.com$', - percentage: 100, - }, - }); - result = await mailer.selectEmailServices({ email: emailAddress }); - }); - - after(async () => { - try { - await redisRevert(); - await mailer.stop(); - resolve(); - } catch (err) { - reject(err); - } - }); - - it('returned the correct result', () => { - assert.deepEqual(result, [ - { - emailAddresses: [emailAddress], - emailService: 'fxa-email-service', - emailSender: service, - mailer: mailer.emailService, - }, - ]); - }); - }); - }); - }, P.resolve()); -} - function sesMessageTagsHeaderValue(templateName, serviceName) { return `messageType=fxa-${templateName}, app=fxa, service=${serviceName || 'fxa-auth-server'}`; @@ -2492,18 +1490,3 @@ function applyAssertions(type, target, property, assertions) { assert[test](target, expected, `${type}: ${property}`); }); } - -function redisWrite(config) { - return cp.execAsync( - `echo '${JSON.stringify(config)}' | node scripts/email-config write`, - { - cwd: path.resolve(__dirname, '../../..'), - } - ); -} - -function redisRevert() { - return cp.execAsync('node scripts/email-config revert', { - cwd: path.resolve(__dirname, '../../..'), - }); -} diff --git a/packages/fxa-auth-server/test/local/senders/select_email_services.js b/packages/fxa-auth-server/test/local/senders/select_email_services.js new file mode 100644 index 00000000000..e05fd22b27c --- /dev/null +++ b/packages/fxa-auth-server/test/local/senders/select_email_services.js @@ -0,0 +1,949 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const ROOT_DIR = '../../..'; + +const { assert } = require('chai'); +const config = require(`${ROOT_DIR}/config`).getProperties(); +const cp = require('child_process'); +const mocks = require('../../mocks'); +const path = require('path'); +const Promise = require(`${ROOT_DIR}/lib/promise`); +const proxyquire = require('proxyquire').noPreserveCache(); +const sinon = require('sinon'); + +cp.execAsync = Promise.promisify(cp.exec, { multiArgs: true }); + +describe('selectEmailServices:', () => { + const emailAddress = 'foo@example.com'; + const emailAddresses = ['a@example.com', 'b@example.com', 'c@example.com']; + + let log, redis, mailer, emailService, selectEmailServices, random; + + before(() => { + log = mocks.mockLog(); + redis = {}; + mailer = { mailer: true }; + emailService = { emailService: true }; + selectEmailServices = proxyquire( + `${ROOT_DIR}/lib/senders/select_email_services`, + { + '../redis': () => redis, + } + )(log, config, mailer, emailService); + random = Math.random; + }); + + after(() => (Math.random = random)); + + describe('redis.get returns sendgrid percentage-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ sendgrid: { percentage: 11 } })) + ); + Math.random = () => 0.109; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid percentage-only mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ sendgrid: { percentage: 11 } })) + ); + Math.random = () => 0.11; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + + describe('redis.get returns sendgrid regex-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '^foo@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid regex-only mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '^fo@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid combined match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { + percentage: 1, + regex: '^foo@example\\.com$', + }, + }) + ) + ); + Math.random = () => 0.009; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid combined mismatch (percentage):', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { + percentage: 1, + regex: '^foo@example\\.com$', + }, + }) + ) + ); + Math.random = () => 0.01; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid combined mismatch (regex):', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { + percentage: 1, + regex: '^ffoo@example\\.com$', + }, + }) + ) + ); + Math.random = () => 0; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns socketlabs percentage-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ socketlabs: { percentage: 42 } })) + ); + Math.random = () => 0.419; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'socketlabs', + }, + ]); + }); + }); + + describe('redis.get returns socketlabs percentage-only mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ socketlabs: { percentage: 42 } })) + ); + Math.random = () => 0.42; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns socketlabs regex-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ socketlabs: { regex: '^foo@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'socketlabs', + }, + ]); + }); + }); + + describe('redis.get returns ses percentage-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ ses: { percentage: 100 } })) + ); + Math.random = () => 0.999; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns ses percentage-only mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve(JSON.stringify({ ses: { percentage: 99 } })) + ); + Math.random = () => 0.999; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns ses regex-only match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ ses: { regex: '^foo@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid and ses matches:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { percentage: 10 }, + ses: { regex: '^foo@example\\.com$' }, + }) + ) + ); + Math.random = () => 0.09; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid match and ses mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { percentage: 10 }, + ses: { regex: '^ffoo@example\\.com$' }, + }) + ) + ); + Math.random = () => 0.09; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid mismatch and ses match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { percentage: 10 }, + ses: { regex: '^foo@example\\.com$' }, + }) + ) + ); + Math.random = () => 0.1; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid and ses mismatches:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { percentage: 10 }, + ses: { regex: '^ffoo@example\\.com$' }, + }) + ) + ); + Math.random = () => 0.1; + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns undefined:', () => { + before(() => { + redis.get = sinon.spy(() => Promise.resolve()); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns unsafe regex:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '^(.+)+@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns quote-terminating regex:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '"@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('email address contains quote-terminator:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '@example\\.com$' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: '"@example.com' }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: ['"@example.com'], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get fails:', () => { + before(() => { + log.error.resetHistory(); + redis.get = sinon.spy(() => Promise.reject({ message: 'wibble' })); + }); + + it('selectEmailServices returns fallback data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + assert.equal(log.error.callCount, 1); + const args = log.error.args[0]; + assert.equal(args.length, 2); + assert.equal(args[0], 'emailConfig.read.error'); + assert.deepEqual(args[1], { + err: 'wibble', + }); + }); + }); + + describe('redis.get returns invalid JSON:', () => { + before(() => { + log.error.resetHistory(); + redis.get = sinon.spy(() => Promise.resolve('wibble')); + }); + + it('selectEmailServices returns fallback data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + assert.equal(log.error.callCount, 1); + assert.equal(log.error.args[0][0], 'emailConfig.parse.error'); + }); + }); + }); + + describe('redis.get returns sendgrid match:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: 'example\\.com' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: 'example\\.org' } }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns sendgrid and ses matches and a mismatch:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { regex: '^a' }, + ses: { regex: '^b' }, + }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ + email: emailAddresses[0], + ccEmails: emailAddresses.slice(1), + }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: emailAddresses.slice(0, 1), + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + { + mailer: emailService, + emailAddresses: emailAddresses.slice(1, 2), + emailService: 'fxa-email-service', + emailSender: 'ses', + }, + { + mailer: mailer, + emailAddresses: emailAddresses.slice(2), + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns a sendgrid match and two ses matches:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { regex: '^a' }, + ses: { regex: '^b|c' }, + }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ + email: emailAddresses[0], + ccEmails: emailAddresses.slice(1), + }); + assert.deepEqual(result, [ + { + mailer: emailService, + emailAddresses: emailAddresses.slice(0, 1), + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + { + mailer: emailService, + emailAddresses: emailAddresses.slice(1), + emailService: 'fxa-email-service', + emailSender: 'ses', + }, + ]); + }); + }); + + describe('redis.get returns three mismatches:', () => { + before(() => { + redis.get = sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { regex: 'wibble' }, + ses: { regex: 'blee' }, + }) + ) + ); + }); + + it('selectEmailServices returns the correct data', async () => { + const result = await selectEmailServices({ + email: emailAddresses[0], + ccEmails: emailAddresses.slice(1), + }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: emailAddresses, + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + }); +}); + +describe('selectEmailServices with mocked sandbox:', () => { + const emailAddress = 'foo@example.com'; + + let log, redis, Sandbox, sandbox, mailer, emailService, selectEmailServices; + + before(() => { + log = mocks.mockLog(); + redis = { + get: sinon.spy(() => + Promise.resolve( + JSON.stringify({ sendgrid: { regex: '^foo@example\\.com$' } }) + ) + ), + }; + // eslint-disable-next-line prefer-arrow-callback + Sandbox = sinon.spy(function() { + return sandbox; + }); + sandbox = { + run: sinon.spy(), + }; + mailer = { mailer: true }; + emailService = { emailService: true }; + + selectEmailServices = proxyquire( + `${ROOT_DIR}/lib/senders/select_email_services`, + { + '../redis': () => redis, + sandbox: Sandbox, + } + )(log, config, mailer, emailService); + }); + + afterEach(() => { + sandbox.run.resetHistory(); + }); + + describe('call selectEmailServices:', () => { + let promise, result, failed; + + beforeEach(done => { + promise = selectEmailServices({ email: emailAddress }) + .then(r => (result = r)) + .catch(() => (failed = true)); + + // HACK: Ensure enough ticks pass to reach the sandbox step + setImmediate(() => + setImmediate(() => setImmediate(() => setImmediate(done))) + ); + }); + + it('called the sandbox correctly', () => { + assert.equal(Sandbox.callCount, 1); + + let args = Sandbox.args[0]; + assert.equal(args.length, 1); + assert.deepEqual(args[0], { + timeout: 100, + }); + + assert.equal(sandbox.run.callCount, 1); + + args = sandbox.run.args[0]; + assert.equal(args.length, 2); + assert.equal( + args[0], + 'new RegExp("^foo@example\\.com$").test("foo@example.com")' + ); + assert.equal(typeof args[1], 'function'); + }); + + describe('call sandbox result handler with match:', () => { + beforeEach(() => { + sandbox.run.args[0][1]({ result: 'true' }); + return promise; + }); + + it('resolved', () => { + assert.deepEqual(result, [ + { + emailAddresses: ['foo@example.com'], + mailer: emailService, + emailService: 'fxa-email-service', + emailSender: 'sendgrid', + }, + ]); + }); + + it('did not fail', () => { + assert.equal(failed, undefined); + }); + }); + + describe('call sandbox result handler with timeout:', () => { + beforeEach(() => { + sandbox.run.args[0][1]({ result: 'TimeoutError' }); + return promise; + }); + + it('resolved', () => { + assert.deepEqual(result, [ + { + emailAddresses: ['foo@example.com'], + mailer: mailer, + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + }); + + it('did not fail', () => { + assert.equal(failed, undefined); + }); + }); + }); +}); + +describe('call selectEmailServices with mocked safe-regex, regex-only match and redos regex:', () => { + const emailAddress = 'foo@example.com'; + + let log, redis, safeRegex, mailer, emailService, selectEmailServices; + + before(() => { + log = mocks.mockLog(); + redis = { + get: sinon.spy(() => + Promise.resolve( + JSON.stringify({ + sendgrid: { regex: '^((((.*)*)*)*)*@example\\.com$' }, + }) + ) + ), + }; + // eslint-disable-next-line prefer-arrow-callback + safeRegex = sinon.spy(function() { + return true; + }); + mailer = { mailer: true }; + emailService = { emailService: true }; + + selectEmailServices = proxyquire( + `${ROOT_DIR}/lib/senders/select_email_services`, + { + '../redis': () => redis, + 'safe-regex': safeRegex, + } + )(log, config, mailer, emailService); + }); + + it('email address was treated as mismatch', async () => { + const result = await selectEmailServices({ email: emailAddress }); + assert.deepEqual(result, [ + { + mailer: mailer, + emailAddresses: [emailAddress], + emailService: 'fxa-auth-server', + emailSender: 'ses', + }, + ]); + + assert.equal(safeRegex.callCount, 1); + const args = safeRegex.args[0]; + assert.equal(args.length, 1); + assert.equal(args[0], '^((((.*)*)*)*)*@example\\.com$'); + }); +}); + +if (config.redis.email.enabled) { + describe('selectEmailServices with real redis:', () => { + const emailAddress = 'foo@example.com'; + + let emailService, selectEmailServices; + + this.timeout(10000); + + before(() => { + emailService = { emailService: true }; + selectEmailServices = require(`${ROOT_DIR}/lib/senders/select_email_services`)( + mocks.mockLog(), + config, + { mailer: true }, + emailService + ); + }); + + it('returned the correct results', async () => { + await ['sendgrid', 'ses', 'socketlabs'].reduce( + async (promise, service) => { + await promise; + + await redisWrite({ + [service]: { + regex: '^foo@example.com$', + percentage: 100, + }, + }); + const result = await selectEmailServices({ email: emailAddress }); + await redisRevert(); + + assert.deepEqual(result, [ + { + emailAddresses: [emailAddress], + emailService: 'fxa-email-service', + emailSender: service, + mailer: emailService, + }, + ]); + }, + Promise.resolve() + ); + }); + }); +} + +function redisWrite(emailConfig) { + return cp.execAsync( + `echo '${JSON.stringify(emailConfig)}' | node scripts/email-config write`, + { + cwd: path.resolve(__dirname, '../../..'), + } + ); +} + +function redisRevert() { + return cp.execAsync('node scripts/email-config revert', { + cwd: path.resolve(__dirname, '../../..'), + }); +}