570 строки
17 KiB
JavaScript
570 строки
17 KiB
JavaScript
/* 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/. */
|
|
|
|
/*
|
|
* Shared helpers for mocking things out in the tests.
|
|
*/
|
|
|
|
'use strict'
|
|
|
|
const assert = require('assert')
|
|
const sinon = require('sinon')
|
|
const P = require('../lib/promise')
|
|
const crypto = require('crypto')
|
|
const config = require('../config').getProperties()
|
|
const error = require('../lib/error')
|
|
|
|
const CUSTOMS_METHOD_NAMES = [
|
|
'check',
|
|
'checkAuthenticated',
|
|
'checkIpOnly',
|
|
'flag',
|
|
'reset'
|
|
]
|
|
|
|
const DB_METHOD_NAMES = [
|
|
'account',
|
|
'accountEmails',
|
|
'accountRecord',
|
|
'accountResetToken',
|
|
'checkPassword',
|
|
'consumeUnblockCode',
|
|
'consumeSigninCode',
|
|
'consumeRecoveryCode',
|
|
'createAccount',
|
|
'createDevice',
|
|
'createEmailBounce',
|
|
'createEmail',
|
|
'createKeyFetchToken',
|
|
'createPasswordForgotToken',
|
|
'createRecoveryKey',
|
|
'createSessionToken',
|
|
'createSigninCode',
|
|
'createTotpToken',
|
|
'createUnblockCode',
|
|
'deleteAccount',
|
|
'deleteDevice',
|
|
'deleteEmail',
|
|
'deleteKeyFetchToken',
|
|
'deletePasswordChangeToken',
|
|
'deleteSessionToken',
|
|
'deviceFromTokenVerificationId',
|
|
'deleteRecoveryKey',
|
|
'deleteTotpToken',
|
|
'devices',
|
|
'device',
|
|
'emailBounces',
|
|
'emailRecord',
|
|
'forgotPasswordVerified',
|
|
'getRecoveryKey',
|
|
'getSecondaryEmail',
|
|
'keyFetchToken',
|
|
'keyFetchTokenWithVerificationStatus',
|
|
'passwordChangeToken',
|
|
'passwordForgotToken',
|
|
'pruneSessionTokens',
|
|
'replaceRecoveryCodes',
|
|
'resetAccount',
|
|
'resetAccountTokens',
|
|
'securityEvent',
|
|
'securityEvents',
|
|
'sessions',
|
|
'sessionToken',
|
|
'setPrimaryEmail',
|
|
'touchSessionToken',
|
|
'totpToken',
|
|
'updateDevice',
|
|
'updateLocale',
|
|
'updateSessionToken',
|
|
'updateTotpToken',
|
|
'verifyEmail',
|
|
'verifyTokens',
|
|
'verifyTokenCode',
|
|
]
|
|
|
|
const LOG_METHOD_NAMES = [
|
|
'activityEvent',
|
|
'amplitudeEvent',
|
|
'begin',
|
|
'error',
|
|
'flowEvent',
|
|
'info',
|
|
'notifyAttachedServices',
|
|
'warn',
|
|
'summary',
|
|
'trace'
|
|
]
|
|
|
|
const MAILER_METHOD_NAMES = [
|
|
'sendNewDeviceLoginNotification',
|
|
'sendPasswordChangedNotification',
|
|
'sendPasswordResetNotification',
|
|
'sendPostAddTwoStepAuthNotification',
|
|
'sendPostChangePrimaryEmail',
|
|
'sendPostRemoveSecondaryEmail',
|
|
'sendPostVerifyEmail',
|
|
'sendPostRemoveTwoStepAuthNotification',
|
|
'sendPostVerifySecondaryEmail',
|
|
'sendUnblockCode',
|
|
'sendVerifyCode',
|
|
'sendVerifyLoginEmail',
|
|
'sendVerifyLoginCodeEmail',
|
|
'sendVerifySecondaryEmail',
|
|
'sendRecoveryCode',
|
|
'sendPostAddAccountRecoveryNotification',
|
|
'sendPostRemoveAccountRecoveryNotification',
|
|
'sendPasswordResetAccountRecoveryNotification'
|
|
]
|
|
|
|
const METRICS_CONTEXT_METHOD_NAMES = [
|
|
'clear',
|
|
'gather',
|
|
'setFlowCompleteSignal',
|
|
'stash',
|
|
'validate'
|
|
]
|
|
|
|
const PUSH_METHOD_NAMES = [
|
|
'notifyDeviceConnected',
|
|
'notifyDeviceDisconnected',
|
|
'notifyPasswordChanged',
|
|
'notifyPasswordReset',
|
|
'notifyAccountUpdated',
|
|
'notifyAccountDestroyed',
|
|
'notifyCommandReceived',
|
|
'notifyProfileUpdated',
|
|
'sendPush'
|
|
]
|
|
|
|
const PUSHBOX_METHOD_NAMES = [
|
|
'retrieve',
|
|
'store'
|
|
]
|
|
|
|
module.exports = {
|
|
MOCK_PUSH_KEY: 'BDLugiRzQCANNj5KI1fAqui8ELrE7qboxzfa5K_R0wnUoJ89xY1D_SOXI_QJKNmellykaW_7U2BZ7hnrPW3A3LM',
|
|
generateMetricsContext: generateMetricsContext,
|
|
mockBounces: mockObject(['check']),
|
|
mockCustoms,
|
|
mockDB,
|
|
mockDevices,
|
|
mockLog: mockObject(LOG_METHOD_NAMES),
|
|
mockMailer: mockObject(MAILER_METHOD_NAMES),
|
|
mockMetricsContext,
|
|
mockPush,
|
|
mockPushbox,
|
|
mockRequest
|
|
}
|
|
|
|
function mockCustoms (errors) {
|
|
errors = errors || {}
|
|
|
|
return mockObject(CUSTOMS_METHOD_NAMES)({
|
|
checkAuthenticated: optionallyThrow(errors, 'checkAuthenticated'),
|
|
checkIpOnly: optionallyThrow(errors, 'checkIpOnly')
|
|
})
|
|
}
|
|
|
|
function optionallyThrow (errors, methodName) {
|
|
return sinon.spy(() => {
|
|
if (errors[methodName]) {
|
|
return P.reject(errors[methodName])
|
|
}
|
|
return P.resolve()
|
|
})
|
|
}
|
|
|
|
function mockDB (data, errors) {
|
|
data = data || {}
|
|
errors = errors || {}
|
|
|
|
return mockObject(DB_METHOD_NAMES)({
|
|
account: sinon.spy((uid) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve({
|
|
email: data.email,
|
|
emailCode: data.emailCode,
|
|
emailVerified: data.emailVerified,
|
|
primaryEmail: {normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified || false, isPrimary: true},
|
|
emails: [{normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified || false, isPrimary: true}],
|
|
uid: data.uid,
|
|
verifierSetAt: Date.now(),
|
|
wrapWrapKb: data.wrapWrapKb
|
|
})
|
|
}),
|
|
accountEmails: sinon.spy((uid) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve([
|
|
{
|
|
email: data.email || 'primary@email.com',
|
|
normalizedEmail: (data.email || 'primary@email.com').toLowerCase(),
|
|
emailCode: data.emailCode,
|
|
isPrimary: true,
|
|
isVerified: data.emailVerified
|
|
},
|
|
{
|
|
email: data.secondEmail || 'secondEmail@email.com',
|
|
normalizedEmail: (data.secondEmail || 'secondEmail@email.com').toLowerCase(),
|
|
emailCode: data.secondEmailCode || crypto.randomBytes(16).toString('hex'),
|
|
isVerified: data.secondEmailisVerified || false,
|
|
isPrimary: false
|
|
}
|
|
])
|
|
}),
|
|
accountRecord: sinon.spy(() => {
|
|
if (errors.emailRecord) {
|
|
return P.reject(errors.emailRecord)
|
|
}
|
|
return P.resolve({
|
|
authSalt: crypto.randomBytes(32),
|
|
createdAt: data.createdAt || Date.now(),
|
|
data: crypto.randomBytes(32),
|
|
email: data.email,
|
|
emailVerified: data.emailVerified,
|
|
primaryEmail: {normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified, isPrimary: true},
|
|
emails: [{normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified, isPrimary: true}],
|
|
kA: crypto.randomBytes(32),
|
|
lastAuthAt: () => {
|
|
return Date.now()
|
|
},
|
|
uid: data.uid,
|
|
wrapWrapKb: crypto.randomBytes(32)
|
|
})
|
|
}),
|
|
consumeSigninCode: sinon.spy(() => {
|
|
if (errors.consumeSigninCode) {
|
|
return P.reject(errors.consumeSigninCode)
|
|
}
|
|
return P.resolve({
|
|
email: data.email,
|
|
flowId: data.flowId
|
|
})
|
|
}),
|
|
createAccount: sinon.spy(() => {
|
|
return P.resolve({
|
|
uid: data.uid,
|
|
email: data.email,
|
|
emailCode: data.emailCode,
|
|
emailVerified: data.emailVerified,
|
|
locale: data.locale,
|
|
wrapWrapKb: data.wrapWrapKb
|
|
})
|
|
}),
|
|
createDevice: sinon.spy((uid) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve(Object.keys(data.device).reduce((result, key) => {
|
|
result[key] = data.device[key]
|
|
return result
|
|
}, {
|
|
id: data.deviceId,
|
|
createdAt: data.deviceCreatedAt
|
|
}))
|
|
}),
|
|
createKeyFetchToken: sinon.spy(() => {
|
|
return P.resolve({
|
|
data: crypto.randomBytes(32).toString('hex'),
|
|
id: data.keyFetchTokenId,
|
|
uid: data.uid
|
|
})
|
|
}),
|
|
createPasswordForgotToken: sinon.spy(() => {
|
|
return P.resolve({
|
|
data: crypto.randomBytes(32).toString('hex'),
|
|
passCode: data.passCode,
|
|
id: data.passwordForgotTokenId,
|
|
uid: data.uid,
|
|
ttl: function () {
|
|
return data.passwordForgotTokenTTL || 100
|
|
}
|
|
})
|
|
}),
|
|
createSessionToken: sinon.spy((opts) => {
|
|
return P.resolve({
|
|
createdAt: opts.createdAt || Date.now(),
|
|
data: crypto.randomBytes(32).toString('hex'),
|
|
email: opts.email || data.email,
|
|
emailVerified: typeof opts.emailVerified !== 'undefined' ? opts.emailVerified : data.emailVerified,
|
|
lastAuthAt: () => {
|
|
return opts.createdAt || Date.now()
|
|
},
|
|
id: data.sessionTokenId,
|
|
tokenVerificationId: opts.tokenVerificationId || data.tokenVerificationId,
|
|
tokenVerified: ! (opts.tokenVerificationId || data.tokenVerificationId),
|
|
mustVerify: typeof opts.mustVerify !== 'undefined' ? opts.mustVerify : data.mustVerify,
|
|
uaBrowser: opts.uaBrowser || data.uaBrowser,
|
|
uaBrowserVersion: opts.uaBrowserVersion || data.uaBrowserVersion,
|
|
uaOS: opts.uaOS || data.uaOS,
|
|
uaOSVersion: opts.uaOSVersion || data.uaOSVersion,
|
|
uaDeviceType: opts.uaDeviceType || data.uaDeviceType,
|
|
uaFormFactor: opts.uaFormFactor || data.uaFormFactor,
|
|
uid: opts.uid || data.uid
|
|
})
|
|
}),
|
|
createSigninCode: sinon.spy((uid, flowId) => {
|
|
assert.ok(typeof uid === 'string')
|
|
assert.ok(typeof flowId === 'string')
|
|
return P.resolve(data.signinCode || [])
|
|
}),
|
|
devices: sinon.spy((uid) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve(data.devices || [])
|
|
}),
|
|
device: sinon.spy((uid, deviceId) => {
|
|
assert.ok(typeof uid === 'string')
|
|
assert.ok(typeof deviceId === 'string')
|
|
const device = data.devices.find(d => d.id === deviceId)
|
|
assert.ok(device)
|
|
return P.resolve(device)
|
|
}),
|
|
deleteSessionToken: sinon.spy(() => {
|
|
return P.resolve()
|
|
}),
|
|
emailRecord: sinon.spy(() => {
|
|
if (errors.emailRecord) {
|
|
return P.reject(errors.emailRecord)
|
|
}
|
|
return P.resolve({
|
|
authSalt: crypto.randomBytes(32).toString('hex'),
|
|
createdAt: data.createdAt || Date.now(),
|
|
data: crypto.randomBytes(32).toString('hex'),
|
|
email: data.email,
|
|
emailVerified: data.emailVerified,
|
|
primaryEmail: {normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified, isPrimary: true},
|
|
emails: [{normalizedEmail: data.email.toLowerCase(), email: data.email, isVerified: data.emailVerified, isPrimary: true}],
|
|
kA: crypto.randomBytes(32).toString('hex'),
|
|
lastAuthAt: () => {
|
|
return Date.now()
|
|
},
|
|
uid: data.uid,
|
|
wrapWrapKb: crypto.randomBytes(32).toString('hex')
|
|
})
|
|
}),
|
|
forgotPasswordVerified: sinon.spy(() => {
|
|
return P.resolve(data.accountResetToken)
|
|
}),
|
|
getSecondaryEmail: sinon.spy(() => {
|
|
return P.reject(error.unknownSecondaryEmail())
|
|
}),
|
|
getRecoveryKey: sinon.spy(() => {
|
|
return P.resolve({
|
|
recoveryKeyId: data.recoveryKeyId,
|
|
recoveryData: data.recoveryData
|
|
})
|
|
}),
|
|
securityEvents: sinon.spy(() => {
|
|
return P.resolve([])
|
|
}),
|
|
sessions: sinon.spy((uid) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve(data.sessions || [])
|
|
}),
|
|
updateDevice: sinon.spy((uid, sessionTokenId, device) => {
|
|
assert.ok(typeof uid === 'string')
|
|
return P.resolve(device)
|
|
}),
|
|
sessionToken: sinon.spy(() => {
|
|
const res = {
|
|
id: data.sessionTokenId || 'fake session token id',
|
|
uid: data.uid || 'fake uid',
|
|
tokenVerified: true,
|
|
uaBrowser: data.uaBrowser,
|
|
uaBrowserVersion: data.uaBrowserVersion,
|
|
uaOS: data.uaOS,
|
|
uaOSVersion: data.uaOSVersion,
|
|
uaDeviceType: data.uaDeviceType,
|
|
expired: () => data.expired || false
|
|
}
|
|
// SessionToken is a class, and tokenTypeID is a class attribute. Fake that.
|
|
res.constructor.tokenTypeID = 'sessionToken'
|
|
if (data.devices && data.devices.length > 0) {
|
|
Object.keys(data.devices[0]).forEach(key => {
|
|
var keyOnSession = 'device' + key.charAt(0).toUpperCase() + key.substr(1)
|
|
res[keyOnSession] = data.devices[0][key]
|
|
})
|
|
}
|
|
return P.resolve(res)
|
|
}),
|
|
verifyTokens: optionallyThrow(errors, 'verifyTokens'),
|
|
verifyTokenCode: sinon.spy(() => {
|
|
if (errors.verifyTokenCode) {
|
|
return P.reject(errors.verifyTokenCode)
|
|
}
|
|
return P.resolve({})
|
|
})
|
|
})
|
|
}
|
|
|
|
function mockObject (methodNames) {
|
|
return methods => {
|
|
return methodNames.reduce((object, name) => {
|
|
object[name] = methods && methods[name] || sinon.spy(() => P.resolve())
|
|
return object
|
|
}, {})
|
|
}
|
|
}
|
|
|
|
function mockPush (methods) {
|
|
const push = Object.assign({}, methods)
|
|
PUSH_METHOD_NAMES.forEach((name) => {
|
|
if (! push[name]) {
|
|
push[name] = sinon.spy(() => P.resolve())
|
|
}
|
|
})
|
|
return push
|
|
}
|
|
|
|
function mockPushbox (methods) {
|
|
const pushbox = Object.assign({}, methods)
|
|
PUSHBOX_METHOD_NAMES.forEach((name) => {
|
|
if (! pushbox[name]) {
|
|
pushbox[name] = sinon.spy(() => P.resolve())
|
|
}
|
|
})
|
|
return pushbox
|
|
}
|
|
|
|
function mockDevices (data, errors) {
|
|
data = data || {}
|
|
errors = errors || {}
|
|
|
|
return {
|
|
isSpuriousUpdate: sinon.spy(() => data.spurious || false),
|
|
upsert: sinon.spy(() => {
|
|
if (errors.upsert) {
|
|
return P.reject(errors.upsert)
|
|
}
|
|
return P.resolve({
|
|
id: data.deviceId || crypto.randomBytes(16).toString('hex'),
|
|
name: data.deviceName || 'mock device name',
|
|
type: data.deviceType || 'desktop'
|
|
})
|
|
}),
|
|
synthesizeName: sinon.spy(() => {
|
|
return data.deviceName || null
|
|
})
|
|
}
|
|
}
|
|
|
|
function mockMetricsContext (methods) {
|
|
methods = methods || {}
|
|
return mockObject(METRICS_CONTEXT_METHOD_NAMES)({
|
|
gather: methods.gather || sinon.spy(function (data) {
|
|
const time = Date.now()
|
|
return P.resolve()
|
|
.then(() => {
|
|
if (this.payload && this.payload.metricsContext) {
|
|
return Object.assign(data, {
|
|
time: time,
|
|
flow_id: this.payload.metricsContext.flowId,
|
|
flow_time: time - this.payload.metricsContext.flowBeginTime,
|
|
flowBeginTime: this.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: this.payload.metricsContext.flowCompleteSignal,
|
|
flowType: this.payload.metricsContext.flowType
|
|
}, this.headers && this.headers.dnt === '1' ? {} : {
|
|
utm_campaign: this.payload.metricsContext.utmCampaign,
|
|
utm_content: this.payload.metricsContext.utmContent,
|
|
utm_medium: this.payload.metricsContext.utmMedium,
|
|
utm_source: this.payload.metricsContext.utmSource,
|
|
utm_term: this.payload.metricsContext.utmTerm
|
|
})
|
|
}
|
|
|
|
return data
|
|
})
|
|
}),
|
|
|
|
setFlowCompleteSignal: sinon.spy(function (flowCompleteSignal) {
|
|
if (this.payload && this.payload.metricsContext) {
|
|
this.payload.metricsContext.flowCompleteSignal = flowCompleteSignal
|
|
}
|
|
}),
|
|
|
|
validate: methods.validate || sinon.spy(() => true)
|
|
})
|
|
}
|
|
|
|
function generateMetricsContext(){
|
|
const randomBytes = crypto.randomBytes(16).toString('hex')
|
|
const flowBeginTime = Date.now()
|
|
const flowSignature = crypto.createHmac('sha256', config.metrics.flow_id_key)
|
|
.update([
|
|
randomBytes,
|
|
flowBeginTime.toString(16)
|
|
].join('\n'))
|
|
.digest('hex')
|
|
.substr(0, 32)
|
|
|
|
return {
|
|
flowBeginTime: flowBeginTime,
|
|
flowId: randomBytes + flowSignature
|
|
}
|
|
}
|
|
|
|
function mockRequest (data, errors) {
|
|
const events = require('../lib/metrics/events')(data.log || module.exports.mockLog(), {
|
|
oauth: {
|
|
clientIds: data.clientIds || {}
|
|
}
|
|
})
|
|
const metricsContext = data.metricsContext || module.exports.mockMetricsContext()
|
|
|
|
const geo = data.geo || {
|
|
timeZone: 'America/Los_Angeles',
|
|
location: {
|
|
city: 'Mountain View',
|
|
country: 'United States',
|
|
countryCode: 'US',
|
|
state: 'California',
|
|
stateCode: 'CA'
|
|
}
|
|
}
|
|
|
|
let devices
|
|
if (errors && errors.devices) {
|
|
devices = P.reject(errors.devices)
|
|
} else {
|
|
devices = P.resolve(data.devices || [])
|
|
}
|
|
|
|
return {
|
|
app: {
|
|
acceptLanguage: data.acceptLanguage || 'en-US',
|
|
clientAddress: data.clientAddress || '63.245.221.32',
|
|
devices,
|
|
features: new Set(data.features),
|
|
geo,
|
|
locale: data.locale || 'en-US',
|
|
ua: {
|
|
browser: data.uaBrowser || 'Firefox',
|
|
browserVersion: data.uaBrowserVersion || '57.0',
|
|
os: data.uaOS || 'Mac OS X',
|
|
osVersion: data.uaOSVersion || '10.13',
|
|
deviceType: data.uaDeviceType || null,
|
|
formFactor: data.uaFormFactor || null
|
|
}
|
|
},
|
|
auth: {
|
|
credentials: data.credentials
|
|
},
|
|
clearMetricsContext: metricsContext.clear,
|
|
emitMetricsEvent: events.emit,
|
|
emitRouteFlowEvent: events.emitRouteFlowEvent,
|
|
gatherMetricsContext: metricsContext.gather,
|
|
headers: data.headers || {
|
|
'user-agent': 'test user-agent'
|
|
},
|
|
info: {
|
|
received: data.received || Date.now() - 1
|
|
},
|
|
path: data.path,
|
|
params: data.params || {},
|
|
method: data.method || undefined,
|
|
payload: data.payload || {},
|
|
query: data.query || {},
|
|
setMetricsFlowCompleteSignal: metricsContext.setFlowCompleteSignal,
|
|
stashMetricsContext: metricsContext.stash,
|
|
validateMetricsContext: metricsContext.validate
|
|
}
|
|
}
|