feat(email): reinstate account verification reminder emails

This commit is contained in:
Phil Booth 2019-03-21 10:17:57 +00:00
Родитель 57f58917e1
Коммит 7bd920e7e4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 36FBB106F9C32516
34 изменённых файлов: 1023 добавлений и 227 удалений

Просмотреть файл

@ -876,7 +876,47 @@ const conf = convict({
env: 'RECOVERY_CODE_NOTIFY_LOW_COUNT' env: 'RECOVERY_CODE_NOTIFY_LOW_COUNT'
} }
} }
} },
verificationReminders: {
rolloutRate: {
doc: 'Rollout rate for verification reminder emails, in the range 0 .. 1',
default: 1,
env: 'VERIFICATION_REMINDERS_ROLLOUT_RATE',
format: Number,
},
firstInterval: {
doc: 'Time since account creation after which the first reminder is sent',
default: '1 day',
env: 'VERIFICATION_REMINDERS_FIRST_INTERVAL',
format: 'duration',
},
secondInterval: {
doc: 'Time since account creation after which the second reminder is sent',
default: '5 days',
env: 'VERIFICATION_REMINDERS_SECOND_INTERVAL',
format: 'duration',
},
redis: {
prefix: {
default: 'verificationReminders:',
doc: 'Key prefix for the verification reminders Redis pool',
env: 'VERIFICATION_REMINDERS_REDIS_PREFIX',
format: String,
},
maxConnections: {
default: 10,
doc: 'Maximum connection count for the verification reminders Redis pool',
env: 'VERIFICATION_REMINDERS_REDIS_MAX_CONNECTIONS',
format: 'nat',
},
minConnections: {
default: 1,
doc: 'Minimum connection count for the verification reminders Redis pool',
env: 'VERIFICATION_REMINDERS_REDIS_MIN_CONNECTIONS',
format: 'nat',
},
},
},
}); });
// handle configuration files. you can specify a CSV list of configuration // handle configuration files. you can specify a CSV list of configuration

Просмотреть файл

@ -1785,10 +1785,10 @@ not just the one being attached to the Firefox account.
Opaque alphanumeric token to be included in verification links. Opaque alphanumeric token to be included in verification links.
<!--end-request-body-post-recovery_emailverify_code-service--> <!--end-request-body-post-recovery_emailverify_code-service-->
* `reminder`: *string, max(32), alphanum, optional* * `reminder`: *string, regex(/^(?:first|second)$/), optional*
<!--begin-request-body-post-recovery_emailverify_code-reminder--> <!--begin-request-body-post-recovery_emailverify_code-reminder-->
Deprecated. Indicates that verification originates from a reminder email.
<!--end-request-body-post-recovery_emailverify_code-reminder--> <!--end-request-body-post-recovery_emailverify_code-reminder-->
* `type`: *string, max(32), alphanum, optional* * `type`: *string, max(32), alphanum, optional*

Просмотреть файл

@ -168,6 +168,7 @@ in a sign-in or sign-up flow:
|`signinCode.consumed`|A sign-in code has been consumed on the server.| |`signinCode.consumed`|A sign-in code has been consumed on the server.|
|`account.confirmed`|Sign-in to an existing account has been confirmed via email.| |`account.confirmed`|Sign-in to an existing account has been confirmed via email.|
|`account.verified`|A new account has been verified via email.| |`account.verified`|A new account has been verified via email.|
|`account.reminder.${reminder}`|A new account has been verified via a reminder email.|
|`account.keyfetch`|Sync encryption keys have been fetched.| |`account.keyfetch`|Sync encryption keys have been fetched.|
|`account.signed`|A certificate has been signed.| |`account.signed`|A certificate has been signed.|
|`account.reset`|An account has been reset.| |`account.reset`|An account has been reset.|

Просмотреть файл

@ -105,6 +105,11 @@ module.exports = (log, config) => {
throw new TypeError('Missing argument'); throw new TypeError('Missing argument');
} }
const verificationReminders = require('../verification-reminders')(log, config);
verificationReminders.keys.forEach(key => {
EMAIL_TYPES[`verificationReminder${key[0].toUpperCase()}${key.substr(1)}Email`] = 'registration';
});
const transformEvent = initialize(config.oauth.clientIds, EVENTS, FUZZY_EVENTS); const transformEvent = initialize(config.oauth.clientIds, EVENTS, FUZZY_EVENTS);
return receiveEvent; return receiveEvent;

Просмотреть файл

@ -23,7 +23,7 @@ const MS_ONE_DAY = MS_ONE_HOUR * 24;
const MS_ONE_WEEK = MS_ONE_DAY * 7; const MS_ONE_WEEK = MS_ONE_DAY * 7;
const MS_ONE_MONTH = MS_ONE_DAY * 30; const MS_ONE_MONTH = MS_ONE_DAY * 30;
module.exports = (log, db, mailer, Password, config, customs, signinUtils, push) => { module.exports = (log, db, mailer, Password, config, customs, signinUtils, push, verificationReminders) => {
const tokenCodeConfig = config.signinConfirmation.tokenVerificationCode; const tokenCodeConfig = config.signinConfirmation.tokenVerificationCode;
const tokenCodeLifetime = tokenCodeConfig && tokenCodeConfig.codeLifetime || MS_ONE_HOUR; const tokenCodeLifetime = tokenCodeConfig && tokenCodeConfig.codeLifetime || MS_ONE_HOUR;
const tokenCodeLength = tokenCodeConfig && tokenCodeConfig.codeLength || 8; const tokenCodeLength = tokenCodeConfig && tokenCodeConfig.codeLength || 8;
@ -279,6 +279,8 @@ module.exports = (log, db, mailer, Password, config, customs, signinUtils, push)
tokenVerificationId: tokenVerificationId tokenVerificationId: tokenVerificationId
}); });
} }
return verificationReminders.create(account.uid);
}) })
.catch((err) => { .catch((err) => {
log.error('mailer.sendVerifyCode.1', { err: err}); log.error('mailer.sendVerifyCode.1', { err: err});

Просмотреть файл

@ -14,7 +14,9 @@ const validators = require('./validators');
const HEX_STRING = validators.HEX_STRING; const HEX_STRING = validators.HEX_STRING;
module.exports = (log, db, mailer, config, customs, push) => { module.exports = (log, db, mailer, config, customs, push, verificationReminders) => {
const REMINDER_PATTERN = new RegExp(`^(?:${verificationReminders.keys.join('|')})$`);
return [ return [
{ {
method: 'GET', method: 'GET',
@ -271,8 +273,7 @@ module.exports = (log, db, mailer, config, customs, push) => {
uid: isA.string().max(32).regex(HEX_STRING).required(), uid: isA.string().max(32).regex(HEX_STRING).required(),
code: isA.string().min(32).max(32).regex(HEX_STRING).required(), code: isA.string().min(32).max(32).regex(HEX_STRING).required(),
service: validators.service, service: validators.service,
// TODO: drop this param once it is no longer sent by clients reminder: isA.string().regex(REMINDER_PATTERN).optional(),
reminder: isA.string().max(32).alphanum().optional(),
type: isA.string().max(32).alphanum().optional(), type: isA.string().max(32).alphanum().optional(),
marketingOptIn: isA.boolean() marketingOptIn: isA.boolean()
} }
@ -281,7 +282,7 @@ module.exports = (log, db, mailer, config, customs, push) => {
handler: async function (request) { handler: async function (request) {
log.begin('Account.RecoveryEmailVerify', request); log.begin('Account.RecoveryEmailVerify', request);
const { code, marketingOptIn, service, type, uid } = request.payload; const { code, marketingOptIn, reminder, service, type, uid } = request.payload;
// verify_code because we don't know what type this is yet, but // verify_code because we don't know what type this is yet, but
// we want to record right away before anything could fail, so // we want to record right away before anything could fail, so
@ -429,7 +430,9 @@ module.exports = (log, db, mailer, config, customs, push) => {
// Force it so that we emit the appropriate newsletter state. // Force it so that we emit the appropriate newsletter state.
marketingOptIn: marketingOptIn || false, marketingOptIn: marketingOptIn || false,
uid uid
}) }),
reminder ? request.emitMetricsEvent(`account.reminder.${reminder}`, { uid }) : null,
verificationReminders.delete(uid),
]); ]);
}) })
.then(() => { .then(() => {

Просмотреть файл

@ -23,6 +23,7 @@ module.exports = function (
const pushbox = require('../pushbox')(log, config); const pushbox = require('../pushbox')(log, config);
const devicesImpl = require('../devices')(log, db, push); const devicesImpl = require('../devices')(log, db, push);
const signinUtils = require('./utils/signin')(log, config, customs, db, mailer); const signinUtils = require('./utils/signin')(log, config, customs, db, mailer);
const verificationReminders = require('../verification-reminders')(log, config);
// The routing modules themselves. // The routing modules themselves.
const defaults = require('./defaults')(log, db); const defaults = require('./defaults')(log, db);
const idp = require('./idp')(log, serverPublicKeys); const idp = require('./idp')(log, serverPublicKeys);
@ -34,11 +35,12 @@ module.exports = function (
config, config,
customs, customs,
signinUtils, signinUtils,
push push,
verificationReminders,
); );
const oauth = require('./oauth')(log, config, oauthdb); const oauth = require('./oauth')(log, config, oauthdb);
const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl, oauthdb); const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl, oauthdb);
const emails = require('./emails')(log, db, mailer, config, customs, push); const emails = require('./emails')(log, db, mailer, config, customs, push, verificationReminders);
const password = require('./password')( const password = require('./password')(
log, log,
db, db,

Просмотреть файл

@ -38,6 +38,7 @@ module.exports = function (log, config, oauthdb) {
// Fallback to a stub implementation if redis is disabled // Fallback to a stub implementation if redis is disabled
get: () => P.resolve() get: () => P.resolve()
}; };
const verificationReminders = require('../verification-reminders')(log, config);
// Email template to UTM campaign map, each of these should be unique and // Email template to UTM campaign map, each of these should be unique and
// map to exactly one email template. // map to exactly one email template.
@ -589,6 +590,44 @@ module.exports = function (log, config, oauthdb) {
})); }));
}; };
verificationReminders.keys.forEach(key => {
// Template names are generated in the form `verificationReminderFirstEmail`,
// where `First` is the key derived from config, with an initial capital letter.
const template = `verificationReminder${key[0].toUpperCase()}${key.substr(1)}Email`;
const subject = key === 'first' ? gettext('Hello again') : gettext('Still there?');
templateNameToCampaignMap[template] = `${key}-verification-reminder`;
templateNameToContentMap[template] = 'activate';
Mailer.prototype[template] = async function (message) {
const { code, email, uid } = message;
log.trace(`mailer.${template}`, { code, email, uid });
const query = { code, reminder: key, uid };
const links = this._generateLinks(this.verificationUrl, email, query, template);
const headers = {
'X-Link': links.link,
'X-Verify-Code': message.code
};
return this.send(Object.assign({}, message, {
headers,
subject,
template,
templateValues: {
email,
link: links.link,
oneClickLink: links.oneClickLink,
privacyUrl: links.privacyUrl,
supportUrl: links.supportUrl,
supportLinkAttributes: links.supportLinkAttributes,
},
}));
};
});
Mailer.prototype.unblockCodeEmail = function (message) { Mailer.prototype.unblockCodeEmail = function (message) {
log.trace('mailer.unblockCodeEmail', { email: message.email, uid: message.uid }); log.trace('mailer.unblockCodeEmail', { email: message.email, uid: message.uid });

Просмотреть файл

@ -11,6 +11,8 @@
"recoveryEmail": 1, "recoveryEmail": 1,
"sms.installFirefox": 1, "sms.installFirefox": 1,
"unblockCodeEmail": 1, "unblockCodeEmail": 1,
"verificationReminderFirstEmail": 2,
"verificationReminderSecondEmail": 2,
"verifyEmail": 2, "verifyEmail": 2,
"verifyPrimaryEmail": 3, "verifyPrimaryEmail": 3,
"verifyLoginEmail": 1, "verifyLoginEmail": 1,

Просмотреть файл

@ -79,6 +79,8 @@ module.exports = {
'recovery', 'recovery',
'sms.installFirefox', 'sms.installFirefox',
'unblock_code', 'unblock_code',
'verification_reminder_first',
'verification_reminder_second',
'verify', 'verify',
'verify_login', 'verify_login',
'verify_login_code', 'verify_login_code',

Просмотреть файл

@ -0,0 +1,83 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{t "Firefox Accounts"}}</title>
</head>
<body style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; margin: 0; padding: 0;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="310" style="-webkit-text-size-adjust: 100%; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 310px; margin: 0 auto;">
<!--Logo-->
<tr style="page-break-before: always">
<td align="center" id="firefox-logo" style="padding: 20px 0;">
{{^if sync}}
<img src="https://image.e.mozilla.org/lib/fe9915707361037e75/m/3/firefox57-logo.png" height="88" width="85" alt="" style="-ms-interpolation-mode: bicubic;" />
{{/if}}
{{#if sync}}
<img src="https://image.e.mozilla.org/lib/fe9915707361037e75/m/3/fxa_july2017_v2.png" height="137" width="270" alt="" style="-ms-interpolation-mode: bicubic;" />
{{/if}}
</td>
</tr>
<!--Header Area-->
<tr style="page-break-before: always">
<td valign="top">
<h1 style="font-family: sans-serif; font-size: 21px; line-height: 29px; font-weight: normal; margin: 0 0 11px 0; text-align: center;">{{t "Hello again."}}</h1>
<p class="primary" style="font-family: sans-serif; font-size: 14px; line-height: 21px; font-weight: normal; margin: 0 0 21px 0; text-align: center;">{{{t "A few days ago you created a Firefox Account, but never verified it. A verified account lets you access your tabs, bookmarks, passwords and history on any device connected to it. Simply confirm this email address to activate your account."}}}</p>
</td>
</tr>
<!--Button Area-->
<tr height="50">
<td align="center" valign="top">
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="email-button" style="-webkit-text-size-adjust: 100%; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #0a84ff; border-radius: 4px; height: 50px; width: 310px !important;">
<tr style="page-break-before: always">
<td align="center" valign="middle" id="button-content" style="font-family: sans-serif; font-weight: normal; text-align: center; margin: 0; color: #ffffff; font-size: 20px; line-height: 100%;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{{link}}}" style="width:280px;height:40px;v-text-anchor:middle;" arcsize="10%" stroke="f" fillcolor="#0a84ff">
<w:anchorlock/>
<center>
<![endif]-->
<a href="{{{link}}}" id="button-link" style="font-family:sans-serif; color: #fff; display: block; padding: 15px; text-decoration: none; width: 280px; font-size: 18px; line-height: 26px;">{{t "Activate now"}}</a>
<!--[if mso]>
</center>
</v:roundrect>
<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<!--Button Area-->
<tr style="page-break-before: always">
<td border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<br/>
<p class="secondary" style="font-family: sans-serif; font-weight: normal; margin: 0 0 12px 0; text-align: center; color: #737373; font-size: 11px; line-height: 18px; width: 310px !important; word-wrap:break-word">{{t "This is an automated email; if you received it in error, no action is required."}} {{{t "For more information, please visit <a %(supportLinkAttributes)s>Mozilla Support</a>."}}}</p>
</td>
</tr>
<tr style="page-break-before: always">
<td valign="top">
<p style="font-family: sans-serif; font-weight: normal; margin: 0; text-align: center; color: #737373; font-size: 11px; line-height: 18px; width: 310px !important; word-wrap:break-word">Mozilla. 331 E Evelyn Ave, Mountain View, CA 94041
<br />
<a href="{{{privacyUrl}}}" style="color: #0a84ff; text-decoration: none; font-family: sans-serif; font-size: 11px; line-height: 18px;">{{t "Mozilla Privacy Policy" }}</a></p>
</td>
</tr>
</table>
<div itemscope itemtype="https://schema.org/EmailMessage">
<div itemprop="potentialAction" itemscope itemtype="https://schema.org/ViewAction">
<link itemprop="target" href="{{{oneClickLink}}}"/>
<meta itemprop="name" content="{{t 'Verify Email'}}"/>
<meta itemprop="url" content="{{{oneClickLink}}}"/>
</div>
<meta itemprop="description" content="{{t 'Verify your email to finish your Firefox Account registration'}}"/>
</div>
</body>
</html>

Просмотреть файл

@ -0,0 +1,9 @@
{{t "Hello again."}}
{{t "A few days ago you created a Firefox Account, but never verified it. A verified account lets you access your tabs, bookmarks, passwords and history on any device connected to it. Simply confirm this email address to activate your account."}}
{{t "Activate now:"}} {{{link}}}
{{t "This is an automated email; if you received it in error, no action is required."}} {{{t "For more information, please visit %(supportUrl)s"}}}
Mozilla. 331 E Evelyn Ave, Mountain View, CA 94041
{{t "Mozilla Privacy Policy" }} {{{privacyUrl}}}

Просмотреть файл

@ -0,0 +1,83 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{t "Firefox Accounts"}}</title>
</head>
<body style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; margin: 0; padding: 0;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="310" style="-webkit-text-size-adjust: 100%; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 310px; margin: 0 auto;">
<!--Logo-->
<tr style="page-break-before: always">
<td align="center" id="firefox-logo" style="padding: 20px 0;">
{{^if sync}}
<img src="https://image.e.mozilla.org/lib/fe9915707361037e75/m/3/firefox57-logo.png" height="88" width="85" alt="" style="-ms-interpolation-mode: bicubic;" />
{{/if}}
{{#if sync}}
<img src="https://image.e.mozilla.org/lib/fe9915707361037e75/m/3/fxa_july2017_v2.png" height="137" width="270" alt="" style="-ms-interpolation-mode: bicubic;" />
{{/if}}
</td>
</tr>
<!--Header Area-->
<tr style="page-break-before: always">
<td valign="top">
<h1 style="font-family: sans-serif; font-size: 21px; line-height: 29px; font-weight: normal; margin: 0 0 11px 0; text-align: center;">{{t "Still there?"}}</h1>
<p class="primary" style="font-family: sans-serif; font-size: 14px; line-height: 21px; font-weight: normal; margin: 0 0 21px 0; text-align: center;">{{{t "A week ago you created a Firefox Account, but never verified it. Were worried about you. Confirm this email address to activate your account and let us know you're okay."}}}</p>
</td>
</tr>
<!--Button Area-->
<tr height="50">
<td align="center" valign="top">
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="email-button" style="-webkit-text-size-adjust: 100%; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #0a84ff; border-radius: 4px; height: 50px; width: 310px !important;">
<tr style="page-break-before: always">
<td align="center" valign="middle" id="button-content" style="font-family: sans-serif; font-weight: normal; text-align: center; margin: 0; color: #ffffff; font-size: 20px; line-height: 100%;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{{link}}}" style="width:280px;height:40px;v-text-anchor:middle;" arcsize="10%" stroke="f" fillcolor="#0a84ff">
<w:anchorlock/>
<center>
<![endif]-->
<a href="{{{link}}}" id="button-link" style="font-family:sans-serif; color: #fff; display: block; padding: 15px; text-decoration: none; width: 280px; font-size: 18px; line-height: 26px;">{{t "Activate now"}}</a>
<!--[if mso]>
</center>
</v:roundrect>
<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<!--Button Area-->
<tr style="page-break-before: always">
<td border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<br/>
<p class="secondary" style="font-family: sans-serif; font-weight: normal; margin: 0 0 12px 0; text-align: center; color: #737373; font-size: 11px; line-height: 18px; width: 310px !important; word-wrap:break-word">{{t "This is an automated email; if you received it in error, no action is required."}} {{{t "For more information, please visit <a %(supportLinkAttributes)s>Mozilla Support</a>."}}}</p>
</td>
</tr>
<tr style="page-break-before: always">
<td valign="top">
<p style="font-family: sans-serif; font-weight: normal; margin: 0; text-align: center; color: #737373; font-size: 11px; line-height: 18px; width: 310px !important; word-wrap:break-word">Mozilla. 331 E Evelyn Ave, Mountain View, CA 94041
<br />
<a href="{{{privacyUrl}}}" style="color: #0a84ff; text-decoration: none; font-family: sans-serif; font-size: 11px; line-height: 18px;">{{t "Mozilla Privacy Policy" }}</a></p>
</td>
</tr>
</table>
<div itemscope itemtype="https://schema.org/EmailMessage">
<div itemprop="potentialAction" itemscope itemtype="https://schema.org/ViewAction">
<link itemprop="target" href="{{{oneClickLink}}}"/>
<meta itemprop="name" content="{{t 'Verify Email'}}"/>
<meta itemprop="url" content="{{{oneClickLink}}}"/>
</div>
<meta itemprop="description" content="{{t 'Verify your email to finish your Firefox Account registration'}}"/>
</div>
</body>
</html>

Просмотреть файл

@ -0,0 +1,9 @@
{{t "Still there?"}}
{{t "A week ago you created a Firefox Account, but never verified it. Were worried about you. Confirm this email address to activate your account and let us know you're okay."}}
{{t "Activate now:"}} {{{link}}}
{{t "This is an automated email; if you received it in error, no action is required."}} {{{t "For more information, please visit %(supportUrl)s"}}}
Mozilla. 331 E Evelyn Ave, Mountain View, CA 94041
{{t "Mozilla Privacy Policy" }} {{{privacyUrl}}}

Просмотреть файл

@ -0,0 +1,143 @@
/* 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 https://mozilla.org/MPL/2.0/. */
// This module implements logic for managing account verification reminders.
//
// Reminder records are stored in Redis sorted sets on account creation and
// removed when an acount is verified. A separate script, running on the
// fxa-admin box, processes reminder records in a cron job by pulling the
// records that have ticked passed an expiry limit set in config and sending
// the appropriate reminder email to the address associated with each account.
//
// Right now, config determines how many reminder emails are sent and what
// the expiry intervals for them are. Ultimately though, that might be a good
// candidate to control with feature flags.
//
// More detail on sorted sets:
//
// * https://redis.io/topics/data-types#sorted-sets
// * https://redis.io/topics/data-types-intro#redis-sorted-sets
'use strict';
const P = require('./promise');
const INTERVAL_PATTERN = /^([a-z]+)Interval$/;
/**
* Initialise the verification reminders module.
*
* @param {Object} log
* @param {Object} config
* @returns {VerificationReminders}
*/
module.exports = (log, config) => {
const redis = require('fxa-shared/redis')({
...config.redis,
...config.verificationReminders.redis,
enabled: true,
}, log);
const { rolloutRate } = config.verificationReminders;
const { keys, intervals } = Object.entries(config.verificationReminders).reduce(({ keys, intervals }, [ key, value ]) => {
const matches = INTERVAL_PATTERN.exec(key);
if (matches && matches.length === 2) {
const key = matches[1];
keys.push(key);
intervals[key] = value;
}
return { keys, intervals };
}, { keys: [], intervals: {} });
/**
* @typedef {Object} VerificationReminders
* @property {Array} keys
* @property {Function} create
* @property {Function} delete
* @property {Function} process
*
* Each method below returns a promise that resolves to an object,
* the shape of which is determined by config. If config has settings
* for `firstInterval` and `secondInterval` (as at time of writing),
* the shape of those objects would be `{ first, second }`.
*/
return {
keys: keys.slice(),
/**
* Create verification reminder records for an account.
*
* @param {String} uid
* @returns {Promise} - Each property on the resolved object will be the number
* of elements added to that sorted set, i.e. the result of
* [`redis.zadd`](https://redis.io/commands/zadd).
*/
async create (uid) {
try {
if (rolloutRate <= 1 && Math.random() < rolloutRate) {
const now = Date.now();
const result = await P.props(keys.reduce((result, key) => {
result[key] = redis.zadd(key, now, uid);
return result;
}, {}));
log.info('verificationReminders.create', { uid });
return result;
}
} catch (err) {
log.error('verificationReminders.create.error', { err, uid });
throw err;
}
},
/**
* Delete verification reminder records for an account.
*
* @param {String} uid
* @returns {Promise} - Each property on the resolved object will be the number of
* elements removed from that sorted set, i.e. the result of
* [`redis.zrem`](https://redis.io/commands/zrem).
*/
async delete (uid) {
try {
const result = await P.props(keys.reduce((result, key) => {
result[key] = redis.zrem(key, uid);
return result;
}, {}));
log.info('verificationReminders.delete', { uid });
return result;
} catch (err) {
log.error('verificationReminders.delete.error', { err, uid });
throw err;
}
},
/**
* Read and remove all verification reminders that have
* ticked past the expiry intervals set in config.
*
* @returns {Promise} - Each property on the resolved object will be an array of uids that
* were found to have ticked past the relevant expiry interval, i.e.
* the result of [`redis.zrangebyscore`](https://redis.io/commands/zrangebyscore).
*/
async process () {
try {
const now = Date.now();
return await P.props(keys.reduce((result, key) => {
const cutoff = now - intervals[key];
result[key] = redis.zrangebyscore(key, 0, cutoff);
setImmediate(async () => {
await result[key];
redis.zremrangebyscore(key, 0, cutoff);
});
log.info('verificationReminders.process', { key, now, cutoff });
return result;
}, {}));
} catch (err) {
log.error('verificationReminders.process.error', { err });
throw err;
}
},
};
};

30
npm-shrinkwrap.json сгенерированный
Просмотреть файл

@ -8447,17 +8447,29 @@
} }
}, },
"fxa-shared": { "fxa-shared": {
"version": "1.0.19", "version": "1.0.21",
"resolved": "https://registry.npmjs.org/fxa-shared/-/fxa-shared-1.0.19.tgz", "resolved": "https://registry.npmjs.org/fxa-shared/-/fxa-shared-1.0.21.tgz",
"integrity": "sha512-WuKS50Z/Il+bjnlp6qHqc3qr6mS7q1sYQ8rRcFx+4R39m2FjbxwSG8f1BnF74q3rdb8nCH0BsmSNFsJ1QNaN8w==", "integrity": "sha512-jIvoGmulWWDa6i5MjfxBjIZrk3WiBU/tZziKN8VQtptnfKGEGOFRPtbnpWX3lElLmf/46+yOXcSyKkIoA2WzTg==",
"requires": { "requires": {
"accept-language": "2.0.17", "accept-language": "2.0.17",
"ajv": "6.10.0",
"bluebird": "3.5.3", "bluebird": "3.5.3",
"generic-pool": "3.6.1", "generic-pool": "3.6.1",
"moment": "2.20.1", "moment": "2.20.1",
"redis": "2.8.0" "redis": "2.8.0"
}, },
"dependencies": { "dependencies": {
"ajv": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
"integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==",
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"bluebird": { "bluebird": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.3.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.3.tgz",
@ -9749,9 +9761,9 @@
} }
}, },
"ieee754": { "ieee754": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
"integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==" "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
}, },
"ignore": { "ignore": {
"version": "3.3.10", "version": "3.3.10",
@ -11213,9 +11225,9 @@
} }
}, },
"nan": { "nan": {
"version": "2.13.1", "version": "2.13.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.13.1.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz",
"integrity": "sha512-I6YB/YEuDeUZMmhscXKxGgZlFnhsn5y0hgOZBadkzfTRrZBtJDZeg6eQf7PYMIEclwmorTKK8GztsyOUSVBREA==", "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==",
"optional": true "optional": true
}, },
"nanomatch": { "nanomatch": {

Просмотреть файл

@ -52,7 +52,7 @@
"fxa-geodb": "1.0.4", "fxa-geodb": "1.0.4",
"fxa-jwtool": "0.7.2", "fxa-jwtool": "0.7.2",
"fxa-notifier-aws": "1.0.0", "fxa-notifier-aws": "1.0.0",
"fxa-shared": "1.0.19", "fxa-shared": "1.0.21",
"generic-pool": "3.2.0", "generic-pool": "3.2.0",
"google-libphonenumber": "2.0.10", "google-libphonenumber": "2.0.10",
"grunt-nunjucks-2-html": "3.1.0", "grunt-nunjucks-2-html": "3.1.0",

Просмотреть файл

@ -270,8 +270,6 @@ describe('lib/devices:', () => {
is_placeholder: false is_placeholder: false
}, 'event data was correct'); }, 'event data was correct');
assert.equal(log.info.callCount, 0, 'log.info was not called');
assert.equal(log.notifyAttachedServices.callCount, 1, 'log.notifyAttachedServices was called once'); assert.equal(log.notifyAttachedServices.callCount, 1, 'log.notifyAttachedServices was called once');
args = log.notifyAttachedServices.args[0]; args = log.notifyAttachedServices.args[0];
assert.equal(args.length, 3, 'log.notifyAttachedServices was passed three arguments'); assert.equal(args.length, 3, 'log.notifyAttachedServices was passed three arguments');
@ -314,10 +312,10 @@ describe('lib/devices:', () => {
assert.equal(log.activityEvent.callCount, 1, 'log.activityEvent was called once'); assert.equal(log.activityEvent.callCount, 1, 'log.activityEvent was called once');
assert.equal(log.activityEvent.args[0][0].is_placeholder, true, 'is_placeholder was correct'); assert.equal(log.activityEvent.args[0][0].is_placeholder, true, 'is_placeholder was correct');
assert.equal(log.info.callCount, 1, 'log.info was called once'); assert.equal(log.info.callCount, 2);
assert.equal(log.info.args[0].length, 2); assert.equal(log.info.args[1].length, 2);
assert.equal(log.info.args[0][0], 'device:createPlaceholder'); assert.equal(log.info.args[1][0], 'device:createPlaceholder');
assert.deepEqual(log.info.args[0][1], { assert.deepEqual(log.info.args[1][1], {
uid: credentials.uid, uid: credentials.uid,
id: result.id id: result.id
}, 'argument was event data'); }, 'argument was event data');
@ -368,8 +366,6 @@ describe('lib/devices:', () => {
is_placeholder: false is_placeholder: false
}, 'event data was correct'); }, 'event data was correct');
assert.equal(log.info.callCount, 0, 'log.info was not called');
assert.equal(log.notifyAttachedServices.callCount, 0, 'log.notifyAttachedServices was not called'); assert.equal(log.notifyAttachedServices.callCount, 0, 'log.notifyAttachedServices was not called');
assert.equal(push.notifyDeviceConnected.callCount, 0, 'push.notifyDeviceConnected was not called'); assert.equal(push.notifyDeviceConnected.callCount, 0, 'push.notifyDeviceConnected was not called');
@ -424,8 +420,6 @@ describe('lib/devices:', () => {
is_placeholder: false is_placeholder: false
}, 'event data was correct'); }, 'event data was correct');
assert.equal(log.info.callCount, 0, 'log.info was not called');
assert.equal(log.notifyAttachedServices.callCount, 1, 'log.notifyAttachedServices was called once'); assert.equal(log.notifyAttachedServices.callCount, 1, 'log.notifyAttachedServices was called once');
args = log.notifyAttachedServices.args[0]; args = log.notifyAttachedServices.args[0];
assert.equal(args.length, 3, 'log.notifyAttachedServices was passed three arguments'); assert.equal(args.length, 3, 'log.notifyAttachedServices was passed three arguments');
@ -458,10 +452,10 @@ describe('lib/devices:', () => {
assert.equal(log.activityEvent.callCount, 1, 'log.activityEvent was called once'); assert.equal(log.activityEvent.callCount, 1, 'log.activityEvent was called once');
assert.equal(log.activityEvent.args[0][0].is_placeholder, true, 'is_placeholder was correct'); assert.equal(log.activityEvent.args[0][0].is_placeholder, true, 'is_placeholder was correct');
assert.equal(log.info.callCount, 1, 'log.info was called once'); assert.equal(log.info.callCount, 2);
assert.equal(log.info.args[0].length, 2); assert.equal(log.info.args[1].length, 2);
assert.equal(log.info.args[0][0], 'device:createPlaceholder'); assert.equal(log.info.args[1][0], 'device:createPlaceholder');
assert.deepEqual(log.info.args[0][1], { assert.deepEqual(log.info.args[1][1], {
uid: credentials.uid, uid: credentials.uid,
id: result.id id: result.id
}, 'argument was event data'); }, 'argument was event data');
@ -512,8 +506,6 @@ describe('lib/devices:', () => {
is_placeholder: false is_placeholder: false
}, 'event data was correct'); }, 'event data was correct');
assert.equal(log.info.callCount, 0, 'log.info was not called');
assert.equal(log.notifyAttachedServices.callCount, 0, 'log.notifyAttachedServices was not called'); assert.equal(log.notifyAttachedServices.callCount, 0, 'log.notifyAttachedServices was not called');
assert.equal(push.notifyDeviceConnected.callCount, 0, 'push.notifyDeviceConnected was not called'); assert.equal(push.notifyDeviceConnected.callCount, 0, 'push.notifyDeviceConnected was not called');

Просмотреть файл

@ -93,9 +93,9 @@ describe('bounce messages', () => {
assert.equal(mockDB.deleteAccount.callCount, 2); assert.equal(mockDB.deleteAccount.callCount, 2);
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com'); assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com');
assert.equal(mockDB.accountRecord.args[1][0], 'foobar@example.com'); assert.equal(mockDB.accountRecord.args[1][0], 'foobar@example.com');
assert.equal(log.info.callCount, 6); assert.equal(log.info.callCount, 7);
assert.equal(log.info.args[5][0], 'accountDeleted'); assert.equal(log.info.args[6][0], 'accountDeleted');
assert.equal(log.info.args[5][1].email, 'foobar@example.com'); assert.equal(log.info.args[6][1].email, 'foobar@example.com');
assert.equal(mockMsg.del.callCount, 1); assert.equal(mockMsg.del.callCount, 1);
}); });
}); });

Просмотреть файл

@ -43,7 +43,7 @@ function makeRoutes (options = {}, requireMocks) {
customs, customs,
signinUtils, signinUtils,
mocks.mockPush(), mocks.mockPush(),
mocks.mockDevices() mocks.mockVerificationReminders(),
); );
} }

Просмотреть файл

@ -19,11 +19,11 @@ describe('metrics/amplitude', () => {
}); });
it('throws if log argument is missing', () => { it('throws if log argument is missing', () => {
assert.throws(() => amplitudeModule(null, { oauth: { clientIds: {} } })); assert.throws(() => amplitudeModule(null, { oauth: { clientIds: {} }, verificationReminders: {} }));
}); });
it('throws if config argument is missing', () => { it('throws if config argument is missing', () => {
assert.throws(() => amplitudeModule({}, { oauth: { clientIds: null } })); assert.throws(() => amplitudeModule({}, { oauth: { clientIds: null }, verificationReminders: {} }));
}); });
describe('instantiate', () => { describe('instantiate', () => {
@ -37,7 +37,12 @@ describe('metrics/amplitude', () => {
0: 'amo', 0: 'amo',
1: 'pocket' 1: 'pocket'
} }
} },
verificationReminders: {
firstInterval: 1000,
secondInterval: 2000,
thirdInterval: 3000,
},
}); });
}); });
@ -826,6 +831,131 @@ describe('metrics/amplitude', () => {
}); });
}); });
describe('email.verificationReminderFirstEmail.bounced', () => {
beforeEach(() => {
return amplitude('email.verificationReminderFirstEmail.bounced', mocks.mockRequest({}));
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - bounced');
assert.equal(args[0].event_properties.email_type, 'registration');
});
});
describe('email.verificationReminderFirstEmail.sent', () => {
beforeEach(() => {
return amplitude('email.verificationReminderFirstEmail.sent', mocks.mockRequest({}), {
templateVersion: 1,
});
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - sent');
assert.equal(args[0].event_properties.email_type, 'registration');
assert.equal(args[0].event_properties.email_template, 'verificationReminderFirstEmail');
});
});
describe('email.verificationReminderSecondEmail.bounced', () => {
beforeEach(() => {
return amplitude('email.verificationReminderSecondEmail.bounced', mocks.mockRequest({}));
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - bounced');
assert.equal(args[0].event_properties.email_type, 'registration');
});
});
describe('email.verificationReminderSecondEmail.sent', () => {
beforeEach(() => {
return amplitude('email.verificationReminderSecondEmail.sent', mocks.mockRequest({}));
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - sent');
assert.equal(args[0].event_properties.email_type, 'registration');
});
});
describe('email.verificationReminderThirdEmail.bounced', () => {
beforeEach(() => {
return amplitude('email.verificationReminderThirdEmail.bounced', mocks.mockRequest({}));
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - bounced');
assert.equal(args[0].event_properties.email_type, 'registration');
});
});
describe('email.verificationReminderThirdEmail.sent', () => {
beforeEach(() => {
return amplitude('email.verificationReminderThirdEmail.sent', mocks.mockRequest({}));
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.amplitudeEvent correctly', () => {
assert.equal(log.amplitudeEvent.callCount, 1);
const args = log.amplitudeEvent.args[0];
assert.equal(args[0].event_type, 'fxa_email - sent');
assert.equal(args[0].event_properties.email_type, 'registration');
});
});
describe('email.verificationReminderFourthEmail.bounced', () => {
beforeEach(() => {
return amplitude('email.verificationReminderFourthEmail.bounced', mocks.mockRequest({}));
});
it('did not call log.amplitudeEvent', () => {
assert.equal(log.amplitudeEvent.callCount, 0);
});
});
describe('email.verificationReminderFourthEmail.sent', () => {
beforeEach(() => {
return amplitude('email.verificationReminderFourthEmail.sent', mocks.mockRequest({}));
});
it('did not call log.amplitudeEvent', () => {
assert.equal(log.amplitudeEvent.callCount, 0);
});
});
describe('email.verifyEmail.bounced', () => { describe('email.verifyEmail.bounced', () => {
beforeEach(() => { beforeEach(() => {
return amplitude('email.verifyEmail.bounced', mocks.mockRequest({})); return amplitude('email.verifyEmail.bounced', mocks.mockRequest({}));

Просмотреть файл

@ -10,12 +10,14 @@ const log = {
activityEvent: sinon.spy(), activityEvent: sinon.spy(),
amplitudeEvent: sinon.spy(), amplitudeEvent: sinon.spy(),
error: sinon.spy(), error: sinon.spy(),
flowEvent: sinon.spy() flowEvent: sinon.spy(),
info: sinon.spy(),
}; };
const events = require('../../../lib/metrics/events')(log, { const events = require('../../../lib/metrics/events')(log, {
oauth: { oauth: {
clientIds: {} clientIds: {}
} },
verificationReminders: {},
}); });
const mocks = require('../../mocks'); const mocks = require('../../mocks');
const P = require('../../../lib/promise'); const P = require('../../../lib/promise');

Просмотреть файл

@ -51,6 +51,7 @@ const makeRoutes = function (options = {}, requireMocks) {
signinUtils.checkPassword = options.checkPassword; signinUtils.checkPassword = options.checkPassword;
} }
const push = options.push || require('../../../lib/push')(log, db, {}); const push = options.push || require('../../../lib/push')(log, db, {});
const verificationReminders = options.verificationReminders || mocks.mockVerificationReminders();
return proxyquire('../../../lib/routes/account', requireMocks || {})( return proxyquire('../../../lib/routes/account', requireMocks || {})(
log, log,
db, db,
@ -59,7 +60,8 @@ const makeRoutes = function (options = {}, requireMocks) {
config, config,
customs, customs,
signinUtils, signinUtils,
push push,
verificationReminders,
); );
}; };
@ -384,6 +386,7 @@ describe('/account/create', () => {
}); });
const mockMailer = mocks.mockMailer(); const mockMailer = mocks.mockMailer();
const mockPush = mocks.mockPush(); const mockPush = mocks.mockPush();
const verificationReminders = mocks.mockVerificationReminders();
const accountRoutes = makeRoutes({ const accountRoutes = makeRoutes({
config: { config: {
securityHistory: { securityHistory: {
@ -403,7 +406,8 @@ describe('/account/create', () => {
} }
}; };
}, },
push: mockPush push: mockPush,
verificationReminders,
}); });
const route = getRoute(accountRoutes, '/account/create'); const route = getRoute(accountRoutes, '/account/create');
@ -418,23 +422,26 @@ describe('/account/create', () => {
mockRequest, mockRequest,
route, route,
sessionTokenId, sessionTokenId,
uid uid,
verificationReminders,
}; };
} }
it('should create a sync account', () => { it('should create a sync account', () => {
const mocked = setup(); const {
const clientAddress = mocked.clientAddress; clientAddress,
const emailCode = mocked.emailCode; emailCode,
const keyFetchTokenId = mocked.keyFetchTokenId; keyFetchTokenId,
const mockDB = mocked.mockDB; mockDB,
const mockLog = mocked.mockLog; mockLog,
const mockMailer = mocked.mockMailer; mockMailer,
const mockMetricsContext = mocked.mockMetricsContext; mockMetricsContext,
const mockRequest = mocked.mockRequest; mockRequest,
const route = mocked.route; route,
const sessionTokenId = mocked.sessionTokenId; sessionTokenId,
const uid = mocked.uid; uid,
verificationReminders,
} = setup();
const now = Date.now(); const now = Date.now();
sinon.stub(Date, 'now').callsFake(() => now); sinon.stub(Date, 'now').callsFake(() => now);
@ -560,17 +567,24 @@ describe('/account/create', () => {
assert.equal(args[2].service, 'sync'); assert.equal(args[2].service, 'sync');
assert.equal(args[2].uid, uid); assert.equal(args[2].uid, uid);
assert.equal(verificationReminders.create.callCount, 1);
args = verificationReminders.create.args[0];
assert.lengthOf(args, 1);
assert.equal(args[0], uid);
assert.equal(mockLog.error.callCount, 0); assert.equal(mockLog.error.callCount, 0);
}).finally(() => Date.now.restore()); }).finally(() => Date.now.restore());
}); });
it('should create a non-sync account', () => { it('should create a non-sync account', () => {
const mocked = setup(); const {
const mockLog = mocked.mockLog; mockLog,
const mockMailer = mocked.mockMailer; mockMailer,
const mockRequest = mocked.mockRequest; mockRequest,
const route = mocked.route; route,
const uid = mocked.uid; uid,
verificationReminders,
} = setup();
const now = Date.now(); const now = Date.now();
sinon.stub(Date, 'now').callsFake(() => now); sinon.stub(Date, 'now').callsFake(() => now);
@ -598,15 +612,20 @@ describe('/account/create', () => {
assert.equal(mockMailer.sendVerifyCode.callCount, 1, 'mailer.sendVerifyCode was called'); assert.equal(mockMailer.sendVerifyCode.callCount, 1, 'mailer.sendVerifyCode was called');
args = mockMailer.sendVerifyCode.args[0]; args = mockMailer.sendVerifyCode.args[0];
assert.equal(args[2].service, 'foo'); assert.equal(args[2].service, 'foo');
assert.equal(verificationReminders.create.callCount, 1);
assert.equal(mockLog.error.callCount, 0); assert.equal(mockLog.error.callCount, 0);
}).finally(() => Date.now.restore()); }).finally(() => Date.now.restore());
}); });
it('should return an error if email fails to send', () => { it('should return an error if email fails to send', () => {
const mocked = setup(); const {
const mockMailer = mocked.mockMailer; mockMailer,
const mockRequest = mocked.mockRequest; mockRequest,
const route = mocked.route; route,
verificationReminders,
} = setup();
mockMailer.sendVerifyCode = sinon.spy(() => P.reject()); mockMailer.sendVerifyCode = sinon.spy(() => P.reject());
@ -615,14 +634,18 @@ describe('/account/create', () => {
assert.equal(err.output.payload.code, 422); assert.equal(err.output.payload.code, 422);
assert.equal(err.output.payload.errno, 151); assert.equal(err.output.payload.errno, 151);
assert.equal(err.output.payload.error, 'Unprocessable Entity'); assert.equal(err.output.payload.error, 'Unprocessable Entity');
assert.equal(verificationReminders.create.callCount, 0);
}); });
}); });
it('should return a bounce error if send fails with one', () => { it('should return a bounce error if send fails with one', () => {
const mocked = setup(); const {
const mockMailer = mocked.mockMailer; mockMailer,
const mockRequest = mocked.mockRequest; mockRequest,
const route = mocked.route; route,
verificationReminders,
} = setup();
mockMailer.sendVerifyCode = sinon.spy(() => P.reject(error.emailBouncedHard(42))); mockMailer.sendVerifyCode = sinon.spy(() => P.reject(error.emailBouncedHard(42)));
@ -631,6 +654,8 @@ describe('/account/create', () => {
assert.equal(err.output.payload.code, 400); assert.equal(err.output.payload.code, 400);
assert.equal(err.output.payload.errno, 134); assert.equal(err.output.payload.errno, 134);
assert.equal(err.output.payload.error, 'Bad Request'); assert.equal(err.output.payload.error, 'Bad Request');
assert.equal(verificationReminders.create.callCount, 0);
}); });
}); });
}); });
@ -1612,8 +1637,8 @@ describe('/account/destroy', () => {
assert.equal(args[0].email, email, 'db.deleteAccount was passed email record'); assert.equal(args[0].email, email, 'db.deleteAccount was passed email record');
assert.deepEqual(args[0].uid, uid, 'email record had correct uid'); assert.deepEqual(args[0].uid, uid, 'email record had correct uid');
assert.equal(mockLog.info.callCount, 1); assert.equal(mockLog.info.callCount, 2);
args = mockLog.info.args[0]; args = mockLog.info.args[1];
assert.lengthOf(args, 2); assert.lengthOf(args, 2);
assert.equal(args[0], 'accountDeleted.byRequest'); assert.equal(args[0], 'accountDeleted.byRequest');
assert.equal(args[1].email, email); assert.equal(args[1].email, email);

Просмотреть файл

@ -52,13 +52,15 @@ const makeRoutes = function (options = {}, requireMocks) {
check: function () { return P.resolve(true); } check: function () { return P.resolve(true); }
}; };
const push = options.push || require('../../../lib/push')(log, db, {}); const push = options.push || require('../../../lib/push')(log, db, {});
const verificationReminders = options.verificationReminders || mocks.mockVerificationReminders();
return proxyquire('../../../lib/routes/emails', requireMocks || {})( return proxyquire('../../../lib/routes/emails', requireMocks || {})(
log, log,
db, db,
options.mailer || {}, options.mailer || {},
config, config,
customs, customs,
push push,
verificationReminders,
); );
}; };
@ -453,6 +455,7 @@ describe('/recovery_email/verify_code', () => {
const mockMailer = mocks.mockMailer(); const mockMailer = mocks.mockMailer();
const mockPush = mocks.mockPush(); const mockPush = mocks.mockPush();
const mockCustoms = mocks.mockCustoms(); const mockCustoms = mocks.mockCustoms();
const verificationReminders = mocks.mockVerificationReminders();
const accountRoutes = makeRoutes({ const accountRoutes = makeRoutes({
checkPassword: function () { checkPassword: function () {
return P.resolve(true); return P.resolve(true);
@ -462,9 +465,22 @@ describe('/recovery_email/verify_code', () => {
db: mockDB, db: mockDB,
log: mockLog, log: mockLog,
mailer: mockMailer, mailer: mockMailer,
push: mockPush push: mockPush,
verificationReminders,
}); });
const route = getRoute(accountRoutes, '/recovery_email/verify_code'); const route = getRoute(accountRoutes, '/recovery_email/verify_code');
afterEach(() => {
mockDB.verifyTokens.resetHistory();
mockDB.verifyEmail.resetHistory();
mockLog.activityEvent.resetHistory();
mockLog.flowEvent.resetHistory();
mockLog.notifyAttachedServices.resetHistory();
mockMailer.sendPostVerifyEmail.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
verificationReminders.delete.resetHistory();
});
describe('verifyTokens rejects with INVALID_VERIFICATION_CODE', () => { describe('verifyTokens rejects with INVALID_VERIFICATION_CODE', () => {
it('without a reminder payload', () => { it('without a reminder payload', () => {
@ -513,16 +529,12 @@ describe('/recovery_email/verify_code', () => {
assert.ok(Array.isArray(args[1]), 'second argument should have been devices array'); assert.ok(Array.isArray(args[1]), 'second argument should have been devices array');
assert.equal(args[2], 'accountVerify', 'third argument should have been reason'); assert.equal(args[2], 'accountVerify', 'third argument should have been reason');
assert.equal(verificationReminders.delete.callCount, 1);
args = verificationReminders.delete.args[0];
assert.lengthOf(args, 1);
assert.equal(args[0], uid);
assert.equal(JSON.stringify(response), '{}'); assert.equal(JSON.stringify(response), '{}');
})
.then(() => {
mockDB.verifyTokens.resetHistory();
mockDB.verifyEmail.resetHistory();
mockLog.activityEvent.resetHistory();
mockLog.flowEvent.resetHistory();
mockLog.notifyAttachedServices.resetHistory();
mockMailer.sendPostVerifyEmail.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
}); });
}); });
@ -542,16 +554,6 @@ describe('/recovery_email/verify_code', () => {
assert.equal(args[0].user_properties.newsletter_state, 'subscribed', 'newsletter_state was correct'); assert.equal(args[0].user_properties.newsletter_state, 'subscribed', 'newsletter_state was correct');
assert.equal(JSON.stringify(response), '{}'); assert.equal(JSON.stringify(response), '{}');
})
.then(() => {
delete mockRequest.payload.marketingOptIn;
mockDB.verifyTokens.resetHistory();
mockDB.verifyEmail.resetHistory();
mockLog.activityEvent.resetHistory();
mockLog.flowEvent.resetHistory();
mockLog.notifyAttachedServices.resetHistory();
mockMailer.sendPostVerifyEmail.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
}); });
}); });
@ -559,25 +561,18 @@ describe('/recovery_email/verify_code', () => {
mockRequest.payload.reminder = 'second'; mockRequest.payload.reminder = 'second';
return runTest(route, mockRequest, (response) => { return runTest(route, mockRequest, (response) => {
assert.equal(mockLog.activityEvent.callCount, 1, 'activityEvent was called once'); assert.equal(mockLog.activityEvent.callCount, 1);
assert.equal(mockLog.flowEvent.callCount, 2, 'flowEvent was called twice'); assert.equal(mockLog.flowEvent.callCount, 3);
assert.equal(mockLog.flowEvent.args[0][0].event, 'email.verify_code.clicked', 'first event was email.verify_code.clicked'); assert.equal(mockLog.flowEvent.args[0][0].event, 'email.verify_code.clicked');
assert.equal(mockLog.flowEvent.args[1][0].event, 'account.verified', 'second event was account.verified'); assert.equal(mockLog.flowEvent.args[1][0].event, 'account.verified');
assert.equal(mockLog.flowEvent.args[2][0].event, 'account.reminder.second');
assert.equal(mockMailer.sendPostVerifyEmail.callCount, 1, 'sendPostVerifyEmail was called once'); assert.equal(verificationReminders.delete.callCount, 1);
assert.equal(mockPush.notifyAccountUpdated.callCount, 1, 'mockPush.notifyAccountUpdated should have been called once'); assert.equal(mockMailer.sendPostVerifyEmail.callCount, 1);
assert.equal(mockPush.notifyAccountUpdated.callCount, 1);
assert.equal(JSON.stringify(response), '{}'); assert.equal(JSON.stringify(response), '{}');
})
.then(() => {
mockDB.verifyTokens.resetHistory();
mockDB.verifyEmail.resetHistory();
mockLog.activityEvent.resetHistory();
mockLog.flowEvent.resetHistory();
mockLog.notifyAttachedServices.resetHistory();
mockMailer.sendPostVerifyEmail.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
}); });
}); });
}); });
@ -597,9 +592,6 @@ describe('/recovery_email/verify_code', () => {
assert.equal(mockLog.activityEvent.callCount, 0, 'log.activityEvent was not called'); assert.equal(mockLog.activityEvent.callCount, 0, 'log.activityEvent was not called');
assert.equal(mockPush.notifyAccountUpdated.callCount, 0, 'mockPush.notifyAccountUpdated should not have been called'); assert.equal(mockPush.notifyAccountUpdated.callCount, 0, 'mockPush.notifyAccountUpdated should not have been called');
assert.equal(mockPush.notifyDeviceConnected.callCount, 0, 'mockPush.notifyDeviceConnected should not have been called (no devices)'); assert.equal(mockPush.notifyDeviceConnected.callCount, 0, 'mockPush.notifyDeviceConnected should not have been called (no devices)');
})
.then(() => {
mockDB.verifyTokens.resetHistory();
}); });
}); });
@ -618,9 +610,6 @@ describe('/recovery_email/verify_code', () => {
assert.equal(mockLog.activityEvent.callCount, 0, 'log.activityEvent was not called'); assert.equal(mockLog.activityEvent.callCount, 0, 'log.activityEvent was not called');
assert.equal(mockPush.notifyAccountUpdated.callCount, 0, 'mockPush.notifyAccountUpdated should not have been called'); assert.equal(mockPush.notifyAccountUpdated.callCount, 0, 'mockPush.notifyAccountUpdated should not have been called');
assert.equal(mockPush.notifyDeviceConnected.callCount, 1, 'mockPush.notifyDeviceConnected should have been called'); assert.equal(mockPush.notifyDeviceConnected.callCount, 1, 'mockPush.notifyDeviceConnected should have been called');
})
.then(() => {
mockDB.verifyTokens.resetHistory();
}); });
}); });
@ -650,11 +639,6 @@ describe('/recovery_email/verify_code', () => {
assert.equal(args[0].toString('hex'), uid, 'first argument should have been uid'); assert.equal(args[0].toString('hex'), uid, 'first argument should have been uid');
assert.ok(Array.isArray(args[1]), 'second argument should have been devices array'); assert.ok(Array.isArray(args[1]), 'second argument should have been devices array');
assert.equal(args[2], 'accountConfirm', 'third argument should have been reason'); assert.equal(args[2], 'accountConfirm', 'third argument should have been reason');
})
.then(() => {
mockDB.verifyTokens.resetHistory();
mockLog.activityEvent.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
}); });
}); });
@ -678,12 +662,6 @@ describe('/recovery_email/verify_code', () => {
assert.equal(args[2].secondaryEmail, dbData.secondEmail, 'correct secondary email was passed'); assert.equal(args[2].secondaryEmail, dbData.secondEmail, 'correct secondary email was passed');
assert.equal(args[2].service, mockRequest.payload.service); assert.equal(args[2].service, mockRequest.payload.service);
assert.equal(args[2].uid, uid); assert.equal(args[2].uid, uid);
})
.then(() => {
mockDB.verifyEmail.resetHistory();
mockLog.activityEvent.resetHistory();
mockMailer.sendPostVerifySecondaryEmail.resetHistory();
mockPush.notifyAccountUpdated.resetHistory();
}); });
}); });
}); });
@ -799,8 +777,8 @@ describe('/recovery_email', () => {
assert.equal(args[1].email, TEST_EMAIL_ADDITIONAL, 'call db.createEmail with correct email'); assert.equal(args[1].email, TEST_EMAIL_ADDITIONAL, 'call db.createEmail with correct email');
assert.equal(mockMailer.sendVerifySecondaryEmail.callCount, 1, 'call mailer.sendVerifySecondaryEmail'); assert.equal(mockMailer.sendVerifySecondaryEmail.callCount, 1, 'call mailer.sendVerifySecondaryEmail');
assert.equal(mockLog.info.callCount, 1); assert.equal(mockLog.info.callCount, 5);
args = mockLog.info.args[0]; args = mockLog.info.args[4];
assert.lengthOf(args, 2); assert.lengthOf(args, 2);
assert.equal(args[0], 'accountDeleted.unverifiedSecondaryEmail'); assert.equal(args[0], 'accountDeleted.unverifiedSecondaryEmail');
assert.equal(args[1].normalizedEmail, TEST_EMAIL); assert.equal(args[1].normalizedEmail, TEST_EMAIL);

Просмотреть файл

@ -63,8 +63,8 @@ describe('recovery codes', () => {
assert.equal(args[0], UID, 'called with uid'); assert.equal(args[0], UID, 'called with uid');
assert.equal(args[1], 8, 'called with recovery code count'); assert.equal(args[1], 8, 'called with recovery code count');
assert.equal(log.info.callCount, 1); assert.equal(log.info.callCount, 2);
args = log.info.args[0]; args = log.info.args[1];
assert.equal(args[0], 'account.recoveryCode.replaced'); assert.equal(args[0], 'account.recoveryCode.replaced');
assert.equal(args[1].uid, UID); assert.equal(args[1].uid, UID);
}); });

Просмотреть файл

@ -45,8 +45,8 @@ describe('/session/verify/token', () => {
}); });
it('called log.info correctly', () => { it('called log.info correctly', () => {
assert.equal(log.info.callCount, 1); assert.equal(log.info.callCount, 2);
const args = log.info.args[0]; const args = log.info.args[1];
assert.equal(args.length, 2); assert.equal(args.length, 2);
assert.equal(args[0], 'account.token.code.verified'); assert.equal(args[0], 'account.token.code.verified');
}); });

Просмотреть файл

@ -399,8 +399,8 @@ describe('checkCustomsAndLoadAccount', () => {
assert.calledWithExactly(request.emitMetricsEvent.getCall(0), 'account.login.blocked'); assert.calledWithExactly(request.emitMetricsEvent.getCall(0), 'account.login.blocked');
assert.calledWithExactly(request.emitMetricsEvent.getCall(1), 'account.login.invalidUnblockCode'); assert.calledWithExactly(request.emitMetricsEvent.getCall(1), 'account.login.invalidUnblockCode');
assert.calledOnce(log.info); assert.equal(log.info.callCount, 2);
assert.calledWithMatch(log.info, 'Account.login.unblockCode.expired'); assert.equal(log.info.args[1][0], 'Account.login.unblockCode.expired');
assert.calledOnce(customs.flag); assert.calledOnce(customs.flag);
assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, {

Просмотреть файл

@ -35,6 +35,8 @@ const messageTypes = [
'postVerifySecondaryEmail', 'postVerifySecondaryEmail',
'recoveryEmail', 'recoveryEmail',
'unblockCodeEmail', 'unblockCodeEmail',
'verificationReminderFirstEmail',
'verificationReminderSecondEmail',
'verifyEmail', 'verifyEmail',
'verifyLoginEmail', 'verifyLoginEmail',
'verifyLoginCodeEmail', 'verifyLoginCodeEmail',
@ -56,6 +58,8 @@ const typesContainSupportLinks = [
'postRemoveTwoStepAuthenticationEmail', 'postRemoveTwoStepAuthenticationEmail',
'postVerifyEmail', 'postVerifyEmail',
'recoveryEmail', 'recoveryEmail',
'verificationReminderFirstEmail',
'verificationReminderSecondEmail',
'verifyEmail', 'verifyEmail',
'verifyLoginCodeEmail', 'verifyLoginCodeEmail',
'verifyPrimaryEmail', 'verifyPrimaryEmail',
@ -716,10 +720,9 @@ describe(
}); });
} }
if (type === 'verifyEmail') { switch (type) {
it( case 'verifyEmail':
'passes the OAuth relier name to the template', it('passes the OAuth relier name to the template', () => {
() => {
mailer.mailer.sendMail = stubSendMail(emailConfig => { mailer.mailer.sendMail = stubSendMail(emailConfig => {
assert.equal(oauthClientInfo.fetch.callCount, 1); assert.equal(oauthClientInfo.fetch.callCount, 1);
assert.equal(oauthClientInfo.fetch.args[0][0], 'foo'); assert.equal(oauthClientInfo.fetch.args[0][0], 'foo');
@ -728,11 +731,8 @@ describe(
}); });
message.service = 'foo'; message.service = 'foo';
return mailer[type](message); return mailer[type](message);
} });
); it('works without a service', () => {
it(
'works without a service',
() => {
mailer.mailer.sendMail = stubSendMail(emailConfig => { mailer.mailer.sendMail = stubSendMail(emailConfig => {
assert.isFalse(oauthClientInfo.fetch.called); assert.isFalse(oauthClientInfo.fetch.called);
assert.ok(! includes(emailConfig.html, 'and continue to')); assert.ok(! includes(emailConfig.html, 'and continue to'));
@ -740,12 +740,11 @@ describe(
}); });
delete message.service; delete message.service;
return mailer[type](message); return mailer[type](message);
} });
); break;
} else if (type === 'verifyLoginEmail') {
it( case 'verifyLoginEmail':
'test verify token email', it('test verify token email', () => {
() => {
mailer.mailer.sendMail = stubSendMail(emailConfig => { mailer.mailer.sendMail = stubSendMail(emailConfig => {
const verifyLoginUrl = config.get('smtp').verifyLoginUrl; const verifyLoginUrl = config.get('smtp').verifyLoginUrl;
assert.equal(emailConfig.subject, 'Confirm new sign-in to Firefox'); assert.equal(emailConfig.subject, 'Confirm new sign-in to Firefox');
@ -753,22 +752,20 @@ describe(
assert.ok(emailConfig.text.indexOf(verifyLoginUrl) > 0); assert.ok(emailConfig.text.indexOf(verifyLoginUrl) > 0);
}); });
return mailer[type](message); return mailer[type](message);
} });
); break;
} else if (type === 'newDeviceLoginEmail') {
it( case 'newDeviceLoginEmail':
'test new device login email', it('test new device login email', () => {
() => {
mailer.mailer.sendMail = stubSendMail(emailConfig => { mailer.mailer.sendMail = stubSendMail(emailConfig => {
assert.equal(emailConfig.subject, 'New sign-in to Firefox'); assert.equal(emailConfig.subject, 'New sign-in to Firefox');
}); });
return mailer[type](message); return mailer[type](message);
} });
); break;
} else if (type === 'postVerifyEmail') {
it( case 'postVerifyEmail':
`test utm params for ${ type}`, it(`test utm params for ${type}`, () => {
() => {
const syncLink = mailer._generateUTMLink(config.get('smtp').syncUrl, {}, type, 'connect-device'); const syncLink = mailer._generateUTMLink(config.get('smtp').syncUrl, {}, type, 'connect-device');
const androidLink = mailer._generateUTMLink(config.get('smtp').androidUrl, {}, type, 'connect-android'); const androidLink = mailer._generateUTMLink(config.get('smtp').androidUrl, {}, type, 'connect-android');
const iosLink = mailer._generateUTMLink(config.get('smtp').iosUrl, {}, type, 'connect-ios'); const iosLink = mailer._generateUTMLink(config.get('smtp').iosUrl, {}, type, 'connect-ios');
@ -780,9 +777,10 @@ describe(
assert.ok(includes(emailConfig.html, 'utm_source=email')); assert.ok(includes(emailConfig.html, 'utm_source=email'));
}); });
return mailer[type](message); return mailer[type](message);
} });
); break;
} else if (type === 'verifyPrimaryEmail') {
case 'verifyPrimaryEmail':
it('test verify token email', () => { it('test verify token email', () => {
mailer.mailer.sendMail = stubSendMail(emailConfig => { mailer.mailer.sendMail = stubSendMail(emailConfig => {
const verifyPrimaryEmailUrl = config.get('smtp').verifyPrimaryEmailUrl; const verifyPrimaryEmailUrl = config.get('smtp').verifyPrimaryEmailUrl;
@ -793,6 +791,37 @@ describe(
}); });
return mailer[type](message); return mailer[type](message);
}); });
break;
case 'verificationReminderFirstEmail':
it('emailConfig includes data specific to verificationReminderFirstEmail', () => {
mailer.mailer.sendMail = stubSendMail(emailConfig => {
assert.include(emailConfig.html, 'reminder=first');
assert.include(emailConfig.text, 'reminder=first');
assert.include(emailConfig.html, 'utm_campaign=fx-first-verification-reminder');
assert.include(emailConfig.text, 'utm_campaign=fx-first-verification-reminder');
assert.include(emailConfig.html, 'utm_content=fx-activate-oneclick');
assert.include(emailConfig.text, 'utm_content=fx-activate');
assert.equal(emailConfig.subject, 'Hello again');
});
return mailer[type](message);
});
break;
case 'verificationReminderSecondEmail':
it('emailConfig includes data specific to verificationReminderSecondEmail', () => {
mailer.mailer.sendMail = stubSendMail(emailConfig => {
assert.include(emailConfig.html, 'reminder=second');
assert.include(emailConfig.text, 'reminder=second');
assert.include(emailConfig.html, 'utm_campaign=fx-second-verification-reminder');
assert.include(emailConfig.text, 'utm_campaign=fx-second-verification-reminder');
assert.include(emailConfig.html, 'utm_content=fx-activate-oneclick');
assert.include(emailConfig.text, 'utm_content=fx-activate');
assert.equal(emailConfig.subject, 'Still there?');
});
return mailer[type](message);
});
break;
} }
} }
); );
@ -897,15 +926,15 @@ describe(
return mailer.send(message) return mailer.send(message)
.then(() => { .then(() => {
assert.equal(mockLog.info.callCount, 3); assert.equal(mockLog.info.callCount, 4);
const emailEventLog = mockLog.info.getCalls()[2]; const emailEventLog = mockLog.info.getCalls()[3];
assert.equal(emailEventLog.args[0], 'emailEvent'); assert.equal(emailEventLog.args[0], 'emailEvent');
assert.equal(emailEventLog.args[1].domain, 'other'); assert.equal(emailEventLog.args[1].domain, 'other');
assert.equal(emailEventLog.args[1].flow_id, 'wibble'); assert.equal(emailEventLog.args[1].flow_id, 'wibble');
assert.equal(emailEventLog.args[1].template, 'verifyLoginEmail'); assert.equal(emailEventLog.args[1].template, 'verifyLoginEmail');
assert.equal(emailEventLog.args[1].type, 'sent'); assert.equal(emailEventLog.args[1].type, 'sent');
assert.equal(emailEventLog.args[1].locale, 'en'); assert.equal(emailEventLog.args[1].locale, 'en');
const mailerSend1 = mockLog.info.getCalls()[1]; const mailerSend1 = mockLog.info.getCalls()[2];
assert.equal(mailerSend1.args[0], 'mailer.send.1'); assert.equal(mailerSend1.args[0], 'mailer.send.1');
assert.equal(mailerSend1.args[1].to, message.email); assert.equal(mailerSend1.args[1].to, message.email);
}); });

Просмотреть файл

@ -305,8 +305,8 @@ describe('lib/senders/index', () => {
assert.equal(errorBounces.check.callCount, 2); assert.equal(errorBounces.check.callCount, 2);
assert.equal(e.errno, error.ERRNO.BOUNCE_COMPLAINT); assert.equal(e.errno, error.ERRNO.BOUNCE_COMPLAINT);
assert.ok(log.info.callCount >= 2); assert.isAtLeast(log.info.callCount, 3);
const msg = log.info.args[1]; const msg = log.info.args[2];
assert.equal(msg[0], 'mailer.blocked'); assert.equal(msg[0], 'mailer.blocked');
assert.equal(msg[1].errno, e.errno); assert.equal(msg[1].errno, e.errno);
assert.equal(msg[1].bouncedAt, DATE); assert.equal(msg[1].bouncedAt, DATE);
@ -364,8 +364,8 @@ describe('lib/senders/index', () => {
assert.equal(errorBounces.check.callCount, 1); assert.equal(errorBounces.check.callCount, 1);
assert.equal(e.errno, error.ERRNO.BOUNCE_COMPLAINT); assert.equal(e.errno, error.ERRNO.BOUNCE_COMPLAINT);
assert.ok(log.info.callCount >= 2); assert.isAtLeast(log.info.callCount, 3);
const msg = log.info.args[1]; const msg = log.info.args[2];
assert.equal(msg[0], 'mailer.blocked'); assert.equal(msg[0], 'mailer.blocked');
assert.equal(msg[1].errno, e.errno); assert.equal(msg[1].errno, e.errno);
assert.equal(msg[1].bouncedAt, DATE); assert.equal(msg[1].bouncedAt, DATE);

Просмотреть файл

@ -40,7 +40,7 @@ describe('lib/senders/templates/index:', () => {
it('result is correct', () => { it('result is correct', () => {
assert.equal(typeof result, 'object'); assert.equal(typeof result, 'object');
const keys = Object.keys(result); const keys = Object.keys(result);
assert.equal(keys.length, 25); assert.equal(keys.length, 27);
keys.forEach(key => { keys.forEach(key => {
const fn = result[key]; const fn = result[key];
assert.equal(typeof fn, 'function'); assert.equal(typeof fn, 'function');

Просмотреть файл

@ -533,6 +533,7 @@ function getConfig () {
metrics: { metrics: {
flow_id_expiry: 7200000, flow_id_expiry: 7200000,
flow_id_key: 'wibble' flow_id_key: 'wibble'
} },
verificationReminders: {},
}; };
} }

Просмотреть файл

@ -0,0 +1,193 @@
/* 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 https://mozilla.org/MPL/2.0/. */
'use strict';
const ROOT_DIR = '../..';
const REMINDERS = [ 'first', 'second', 'third' ];
const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce((expected, reminder) => {
expected[reminder] = 1;
return expected;
}, {});
const { assert } = require('chai');
const config = require(`${ROOT_DIR}/config`).getProperties();
const mocks = require('../mocks');
describe('lib/verification-reminders:', () => {
let log, mockConfig, redis, verificationReminders;
beforeEach(() => {
log = mocks.mockLog();
mockConfig = {
redis: config.redis,
verificationReminders: {
rolloutRate: 1,
firstInterval: 1,
secondInterval: 2,
thirdInterval: 60000,
redis: {
maxConnections: 1,
minConnections: 1,
prefix: 'test-verification-reminders:',
},
},
};
redis = require('fxa-shared/redis')({
...config.redis,
...mockConfig.verificationReminders.redis,
enabled: true,
}, mocks.mockLog());
verificationReminders = require(`${ROOT_DIR}/lib/verification-reminders`)(log, mockConfig);
});
it('returned the expected interface', () => {
assert.isObject(verificationReminders);
assert.lengthOf(Object.keys(verificationReminders), 4);
assert.deepEqual(verificationReminders.keys, [ 'first', 'second', 'third' ]);
assert.isFunction(verificationReminders.create);
assert.lengthOf(verificationReminders.create, 1);
assert.isFunction(verificationReminders.delete);
assert.lengthOf(verificationReminders.delete, 1);
assert.isFunction(verificationReminders.process);
assert.lengthOf(verificationReminders.process, 0);
});
it('called log.info correctly', () => {
assert.equal(log.info.callCount, 1);
const args = log.info.args[0];
assert.lengthOf(args, 2);
assert.equal(args[0], 'redis.enabled');
assert.isObject(args[1]);
});
describe('create:', () => {
let createResult;
beforeEach(async () => {
// Clobber keys to assert that misbehaving callers can't wreck the internal behaviour
verificationReminders.keys = [];
createResult = await verificationReminders.create('wibble');
});
afterEach(() => {
return verificationReminders.delete('wibble');
});
it('returned the correct result', async () => {
assert.deepEqual(createResult, EXPECTED_CREATE_DELETE_RESULT);
});
REMINDERS.forEach(reminder => {
it(`wrote ${reminder} reminder to redis correctly`, async () => {
const reminders = await redis.zrange(reminder, 0, -1);
assert.deepEqual(reminders, [ 'wibble' ]);
});
});
it('called log.info correctly', () => {
assert.equal(log.info.callCount, 2);
const args = log.info.args[1];
assert.lengthOf(args, 2);
assert.equal(args[0], 'verificationReminders.create');
assert.deepEqual(args[1], { uid: 'wibble' });
});
describe('delete:', () => {
let deleteResult;
beforeEach(async () => {
deleteResult = await verificationReminders.delete('wibble');
});
it('returned the correct result', async () => {
assert.deepEqual(deleteResult, EXPECTED_CREATE_DELETE_RESULT);
});
REMINDERS.forEach(reminder => {
it(`removed ${reminder} reminder from redis correctly`, async () => {
const reminders = await redis.zrange(reminder, 0, -1);
assert.lengthOf(reminders, 0);
});
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.info correctly', () => {
assert.equal(log.info.callCount, 3);
const args = log.info.args[2];
assert.lengthOf(args, 2);
assert.equal(args[0], 'verificationReminders.delete');
assert.deepEqual(args[1], { uid: 'wibble' });
});
});
describe('process:', () => {
let processResult, after;
beforeEach(done => {
setTimeout(async () => {
processResult = await verificationReminders.process();
after = Date.now();
setImmediate(done);
}, 2);
});
it('returned the correct result', async () => {
assert.deepEqual(processResult, {
first: [ 'wibble' ],
second: [ 'wibble' ],
third: [],
});
});
REMINDERS.forEach(reminder => {
if (reminder !== 'third') {
it(`removed ${reminder} reminder from redis correctly`, async () => {
const reminders = await redis.zrange(reminder, 0, -1);
assert.lengthOf(reminders, 0);
});
} else {
it('left the third reminder in redis', async () => {
const reminders = await redis.zrange(reminder, 0, -1);
assert.deepEqual(reminders, [ 'wibble' ]);
});
}
});
it('did not call log.error', () => {
assert.equal(log.error.callCount, 0);
});
it('called log.info correctly', () => {
assert.equal(log.info.callCount, 5);
let args = log.info.args[2];
assert.lengthOf(args, 2);
assert.equal(args[0], 'verificationReminders.process');
assert.isObject(args[1]);
assert.equal(args[1].key, 'first');
assert.isAtMost(args[1].now, after);
assert.isAbove(args[1].now, after - 1000);
assert.equal(args[1].cutoff, args[1].now - mockConfig.verificationReminders.firstInterval);
args = log.info.args[3];
assert.equal(args[1].key, 'second');
assert.equal(args[1].now, log.info.args[2][1].now);
assert.equal(args[1].cutoff, args[1].now - mockConfig.verificationReminders.secondInterval);
args = log.info.args[4];
assert.equal(args[1].key, 'third');
assert.equal(args[1].now, log.info.args[2][1].now);
assert.equal(args[1].cutoff, args[1].now - mockConfig.verificationReminders.thirdInterval);
});
});
});
});

Просмотреть файл

@ -171,7 +171,8 @@ module.exports = {
mockMetricsContext, mockMetricsContext,
mockPush, mockPush,
mockPushbox, mockPushbox,
mockRequest mockRequest,
mockVerificationReminders,
}; };
function mockCustoms (errors) { function mockCustoms (errors) {
@ -543,7 +544,8 @@ function mockRequest (data, errors) {
const events = require('../lib/metrics/events')(data.log || module.exports.mockLog(), { const events = require('../lib/metrics/events')(data.log || module.exports.mockLog(), {
oauth: { oauth: {
clientIds: data.clientIds || {} clientIds: data.clientIds || {}
} },
verificationReminders: {},
}); });
const metricsContext = data.metricsContext || module.exports.mockMetricsContext(); const metricsContext = data.metricsContext || module.exports.mockMetricsContext();
@ -612,3 +614,12 @@ function mockRequest (data, errors) {
validateMetricsContext: metricsContext.validate validateMetricsContext: metricsContext.validate
}; };
} }
function mockVerificationReminders (data = {}) {
return {
keys: [ 'first', 'second', 'third' ],
create: sinon.spy(() => data.create || { first: 1, second: 1, third: 1 }),
delete: sinon.spy(() => data.delete || { first: 1, second: 1, third: 1 }),
process: sinon.spy(() => data.process || { first: [], second: [], third: [] }),
};
}

Просмотреть файл

@ -161,7 +161,7 @@ describe('reentrant updates of different keys:', () => {
let error; let error;
before(() => { before(() => {
const redisPool = require('fxa-shared/redis/pool')({ const { pool: redisPool } = require('fxa-shared/redis/pool')({
...config.redis, ...config.redis,
...config.redis.sessionTokens ...config.redis.sessionTokens
}, log); }, log);