1426 строки
59 KiB
JavaScript
1426 строки
59 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/. */
|
|
|
|
'use strict'
|
|
|
|
var sinon = require('sinon')
|
|
|
|
const assert = require('insist')
|
|
var mocks = require('../../mocks')
|
|
var getRoute = require('../../routes_helpers').getRoute
|
|
var proxyquire = require('proxyquire')
|
|
|
|
var P = require('../../../lib/promise')
|
|
var uuid = require('uuid')
|
|
var crypto = require('crypto')
|
|
var error = require('../../../lib/error')
|
|
var log = require('../../../lib/log')
|
|
|
|
var TEST_EMAIL = 'foo@gmail.com'
|
|
|
|
function hexString(bytes) {
|
|
return crypto.randomBytes(bytes).toString('hex')
|
|
}
|
|
|
|
var makeRoutes = function (options = {}, requireMocks) {
|
|
|
|
const config = options.config || {}
|
|
config.verifierVersion = config.verifierVersion || 0
|
|
config.smtp = config.smtp || {}
|
|
config.memcached = config.memcached || {
|
|
address: 'none',
|
|
idle: 500,
|
|
lifetime: 30
|
|
}
|
|
config.lastAccessTimeUpdates = {}
|
|
config.signinConfirmation = config.signinConfirmation || {}
|
|
config.signinConfirmation.tokenVerificationCode = config.signinConfirmation.tokenVerificationCode || {}
|
|
config.signinUnblock = config.signinUnblock || {}
|
|
config.secondaryEmail = config.secondaryEmail || {}
|
|
|
|
const log = options.log || mocks.mockLog()
|
|
const mailer = options.mailer || {}
|
|
const Password = options.Password || require('../../../lib/crypto/password')(log, config)
|
|
const db = options.db || mocks.mockDB()
|
|
const customs = options.customs || {
|
|
check: () => { return P.resolve(true) }
|
|
}
|
|
const signinUtils = options.signinUtils || require('../../../lib/routes/utils/signin')(log, config, customs, db, mailer)
|
|
if (options.checkPassword) {
|
|
signinUtils.checkPassword = options.checkPassword
|
|
}
|
|
const push = options.push || require('../../../lib/push')(log, db, {})
|
|
return proxyquire('../../../lib/routes/account', requireMocks || {})(
|
|
log,
|
|
db,
|
|
mailer,
|
|
Password,
|
|
config,
|
|
customs,
|
|
signinUtils,
|
|
push
|
|
)
|
|
}
|
|
|
|
function runTest(route, request, assertions) {
|
|
return new P(function (resolve, reject) {
|
|
try {
|
|
return route.handler(request).then(resolve, reject)
|
|
} catch (err) {
|
|
reject(err)
|
|
}
|
|
})
|
|
.then(assertions)
|
|
}
|
|
|
|
describe('/account/reset', function () {
|
|
let uid, mockLog, mockMetricsContext, mockRequest, keyFetchTokenId, sessionTokenId,
|
|
mockDB, mockCustoms, mockPush, accountRoutes, route, clientAddress, mailer
|
|
|
|
beforeEach(() => {
|
|
uid = uuid.v4('binary').toString('hex')
|
|
mockLog = mocks.mockLog()
|
|
mockMetricsContext = mocks.mockMetricsContext()
|
|
mockRequest = mocks.mockRequest({
|
|
credentials: {
|
|
uid: uid
|
|
},
|
|
log: mockLog,
|
|
metricsContext: mockMetricsContext,
|
|
payload: {
|
|
authPW: hexString(32),
|
|
sessionToken: true,
|
|
metricsContext: {
|
|
flowBeginTime: Date.now(),
|
|
flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
|
}
|
|
},
|
|
query: {
|
|
keys: 'true'
|
|
},
|
|
uaBrowser: 'Firefox',
|
|
uaBrowserVersion: '57',
|
|
uaOS: 'Mac OS X',
|
|
uaOSVersion: '10.11'
|
|
})
|
|
keyFetchTokenId = hexString(16)
|
|
sessionTokenId = hexString(16)
|
|
mockDB = mocks.mockDB({
|
|
uid: uid,
|
|
email: TEST_EMAIL,
|
|
keyFetchTokenId: keyFetchTokenId,
|
|
sessionTokenId: sessionTokenId,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
mockCustoms = mocks.mockCustoms()
|
|
mockPush = mocks.mockPush()
|
|
mailer = mocks.mockMailer()
|
|
accountRoutes = makeRoutes({
|
|
config: {
|
|
securityHistory: {
|
|
enabled: true
|
|
}
|
|
},
|
|
customs: mockCustoms,
|
|
db: mockDB,
|
|
log: mockLog,
|
|
push: mockPush,
|
|
mailer
|
|
})
|
|
route = getRoute(accountRoutes, '/account/reset')
|
|
|
|
clientAddress = mockRequest.app.clientAddress
|
|
})
|
|
|
|
describe('reset account with recovery key', () => {
|
|
let res
|
|
beforeEach(() => {
|
|
mockRequest.payload.wrapKb = hexString(32)
|
|
mockRequest.payload.recoveryKeyId = hexString(16)
|
|
return runTest(route, mockRequest, (result) => res = result)
|
|
})
|
|
|
|
it('should return response', () => {
|
|
assert.ok(res.sessionToken, 'return sessionToken')
|
|
assert.ok(res.keyFetchToken, 'return keyFetchToken')
|
|
})
|
|
|
|
it('should have checked for recovery key', () => {
|
|
assert.equal(mockDB.getRecoveryKey.callCount, 1)
|
|
const args = mockDB.getRecoveryKey.args[0]
|
|
assert.equal(args.length, 2, 'db.getRecoveryKey passed correct number of args')
|
|
assert.equal(args[0], uid, 'uid passed')
|
|
assert.equal(args[1], mockRequest.payload.recoveryKeyId, 'recovery key id passed')
|
|
})
|
|
|
|
it('should have reset account with recovery key', () => {
|
|
assert.equal(mockDB.resetAccount.callCount, 1)
|
|
assert.equal(mockDB.resetAccountTokens.callCount, 1)
|
|
assert.equal(mockDB.createKeyFetchToken.callCount, 1)
|
|
const args = mockDB.createKeyFetchToken.args[0]
|
|
assert.equal(args.length, 1, 'db.createKeyFetchToken passed correct number of args')
|
|
assert.equal(args[0].uid, uid, 'uid passed')
|
|
assert.equal(args[0].wrapKb, mockRequest.payload.wrapKb, 'wrapKb passed')
|
|
})
|
|
|
|
it('should have deleted recovery key', () => {
|
|
assert.equal(mockDB.deleteRecoveryKey.callCount, 1)
|
|
const args = mockDB.deleteRecoveryKey.args[0]
|
|
assert.equal(args.length, 1, 'db.deleteRecoveryKey passed correct number of args')
|
|
assert.equal(args[0], uid, 'uid passed')
|
|
})
|
|
|
|
it('called mailer.sendPasswordResetAccountRecoveryNotification correctly', () => {
|
|
assert.equal(mailer.sendPasswordResetAccountRecoveryNotification.callCount, 1)
|
|
const args = mailer.sendPasswordResetAccountRecoveryNotification.args[0]
|
|
assert.equal(args.length, 3)
|
|
assert.equal(args[0][0].email, TEST_EMAIL)
|
|
})
|
|
|
|
it('should have reset custom server', () => {
|
|
assert.equal(mockCustoms.reset.callCount, 1)
|
|
})
|
|
|
|
it('should have recorded security event', () => {
|
|
assert.equal(mockDB.securityEvent.callCount, 1, 'db.securityEvent was called')
|
|
const securityEvent = mockDB.securityEvent.args[0][0]
|
|
assert.equal(securityEvent.uid, uid)
|
|
assert.equal(securityEvent.ipAddr, clientAddress)
|
|
assert.equal(securityEvent.name, 'account.reset')
|
|
})
|
|
|
|
it('should have emitted metrics', () => {
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
let args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.reset',
|
|
service: undefined,
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
|
|
assert.equal(mockMetricsContext.validate.callCount, 1, 'metricsContext.validate was called')
|
|
assert.equal(mockMetricsContext.validate.args[0].length, 0, 'validate was called without arguments')
|
|
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1, 'metricsContext.setFlowCompleteSignal was called once')
|
|
args = mockMetricsContext.setFlowCompleteSignal.args[0]
|
|
assert.equal(args.length, 1, 'metricsContext.setFlowCompleteSignal was passed one argument')
|
|
assert.equal(args[0], 'account.signed', 'argument was event name')
|
|
assert.equal(mockMetricsContext.stash.callCount, 2, 'metricsContext.stash was called twice')
|
|
})
|
|
|
|
it('should have created session', () => {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called once')
|
|
const args = mockDB.createSessionToken.args[0]
|
|
assert.equal(args.length, 1, 'db.createSessionToken was passed one argument')
|
|
assert.equal(args[0].uaBrowser, 'Firefox', 'db.createSessionToken was passed correct browser')
|
|
assert.equal(args[0].uaBrowserVersion, '57', 'db.createSessionToken was passed correct browser version')
|
|
assert.equal(args[0].uaOS, 'Mac OS X', 'db.createSessionToken was passed correct os')
|
|
assert.equal(args[0].uaOSVersion, '10.11', 'db.createSessionToken was passed correct os version')
|
|
assert.equal(args[0].uaDeviceType, null, 'db.createSessionToken was passed correct device type')
|
|
assert.equal(args[0].uaFormFactor, null, 'db.createSessionToken was passed correct form factor')
|
|
})
|
|
})
|
|
|
|
it('should reset account', () => {
|
|
return runTest(route, mockRequest, function (res) {
|
|
assert.equal(mockDB.resetAccount.callCount, 1)
|
|
assert.equal(mockDB.resetAccountTokens.callCount, 1)
|
|
|
|
assert.equal(mockPush.notifyPasswordReset.callCount, 1)
|
|
assert.deepEqual(mockPush.notifyPasswordReset.firstCall.args[0], uid)
|
|
|
|
assert.equal(mockDB.account.callCount, 1)
|
|
assert.equal(mockCustoms.reset.callCount, 1)
|
|
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
var args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.reset',
|
|
service: undefined,
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
|
|
assert.equal(mockDB.securityEvent.callCount, 1, 'db.securityEvent was called')
|
|
var securityEvent = mockDB.securityEvent.args[0][0]
|
|
assert.equal(securityEvent.uid, uid)
|
|
assert.equal(securityEvent.ipAddr, clientAddress)
|
|
assert.equal(securityEvent.name, 'account.reset')
|
|
|
|
assert.equal(mockMetricsContext.validate.callCount, 1, 'metricsContext.validate was called')
|
|
assert.equal(mockMetricsContext.validate.args[0].length, 0, 'validate was called without arguments')
|
|
|
|
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1, 'metricsContext.setFlowCompleteSignal was called once')
|
|
args = mockMetricsContext.setFlowCompleteSignal.args[0]
|
|
assert.equal(args.length, 1, 'metricsContext.setFlowCompleteSignal was passed one argument')
|
|
assert.equal(args[0], 'account.signed', 'argument was event name')
|
|
|
|
assert.equal(mockMetricsContext.stash.callCount, 2, 'metricsContext.stash was called twice')
|
|
|
|
args = mockMetricsContext.stash.args[0]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument first time')
|
|
assert.deepEqual(args[0].id, sessionTokenId, 'argument was session token')
|
|
assert.deepEqual(args[0].uid, uid, 'sessionToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[0], mockRequest, 'this was request')
|
|
|
|
args = mockMetricsContext.stash.args[1]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument second time')
|
|
assert.deepEqual(args[0].id, keyFetchTokenId, 'argument was key fetch token')
|
|
assert.deepEqual(args[0].uid, uid, 'keyFetchToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[1], mockRequest, 'this was request')
|
|
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called once')
|
|
args = mockDB.createSessionToken.args[0]
|
|
assert.equal(args.length, 1, 'db.createSessionToken was passed one argument')
|
|
assert.equal(args[0].uaBrowser, 'Firefox', 'db.createSessionToken was passed correct browser')
|
|
assert.equal(args[0].uaBrowserVersion, '57', 'db.createSessionToken was passed correct browser version')
|
|
assert.equal(args[0].uaOS, 'Mac OS X', 'db.createSessionToken was passed correct os')
|
|
assert.equal(args[0].uaOSVersion, '10.11', 'db.createSessionToken was passed correct os version')
|
|
assert.equal(args[0].uaDeviceType, null, 'db.createSessionToken was passed correct device type')
|
|
assert.equal(args[0].uaFormFactor, null, 'db.createSessionToken was passed correct form factor')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('/account/create', () => {
|
|
|
|
function setup() {
|
|
const mockLog = log('ERROR', 'test')
|
|
mockLog.activityEvent = sinon.spy(() => {
|
|
return P.resolve()
|
|
})
|
|
mockLog.flowEvent = sinon.spy(() => {
|
|
return P.resolve()
|
|
})
|
|
mockLog.error = sinon.spy()
|
|
mockLog.notifier.send = sinon.spy()
|
|
|
|
const mockMetricsContext = mocks.mockMetricsContext()
|
|
const mockRequest = mocks.mockRequest({
|
|
locale: 'en-GB',
|
|
log: mockLog,
|
|
metricsContext: mockMetricsContext,
|
|
payload: {
|
|
email: TEST_EMAIL,
|
|
authPW: hexString(32),
|
|
service: 'sync',
|
|
metricsContext: {
|
|
flowBeginTime: Date.now(),
|
|
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
utmCampaign: 'utm campaign',
|
|
utmContent: 'utm content',
|
|
utmMedium: 'utm medium',
|
|
utmSource: 'utm source',
|
|
utmTerm: 'utm term'
|
|
}
|
|
},
|
|
query: {
|
|
keys: 'true'
|
|
},
|
|
uaBrowser: 'Firefox Mobile',
|
|
uaBrowserVersion: '9',
|
|
uaOS: 'iOS',
|
|
uaOSVersion: '11',
|
|
uaDeviceType: 'tablet',
|
|
uaFormFactor: 'iPad'
|
|
})
|
|
const clientAddress = mockRequest.app.clientAddress
|
|
const emailCode = hexString(16)
|
|
const keyFetchTokenId = hexString(16)
|
|
const sessionTokenId = hexString(16)
|
|
const uid = uuid.v4('binary').toString('hex')
|
|
const mockDB = mocks.mockDB({
|
|
email: TEST_EMAIL,
|
|
emailCode: emailCode,
|
|
emailVerified: false,
|
|
locale: 'en',
|
|
keyFetchTokenId: keyFetchTokenId,
|
|
sessionTokenId: sessionTokenId,
|
|
uaBrowser: 'Firefox',
|
|
uaBrowserVersion: 52,
|
|
uaOS: 'Mac OS X',
|
|
uaOSVersion: '10.10',
|
|
uid: uid,
|
|
wrapWrapKb: 'wibble'
|
|
}, {
|
|
emailRecord: new error.unknownAccount()
|
|
})
|
|
const mockMailer = mocks.mockMailer()
|
|
const mockPush = mocks.mockPush()
|
|
const accountRoutes = makeRoutes({
|
|
config: {
|
|
securityHistory: {
|
|
enabled: true
|
|
}
|
|
},
|
|
db: mockDB,
|
|
log: mockLog,
|
|
mailer: mockMailer,
|
|
Password: function () {
|
|
return {
|
|
unwrap: function () {
|
|
return P.resolve('wibble')
|
|
},
|
|
verifyHash: function () {
|
|
return P.resolve('wibble')
|
|
}
|
|
}
|
|
},
|
|
push: mockPush
|
|
})
|
|
const route = getRoute(accountRoutes, '/account/create')
|
|
|
|
return {
|
|
clientAddress,
|
|
emailCode,
|
|
keyFetchTokenId,
|
|
mockDB,
|
|
mockLog,
|
|
mockMailer,
|
|
mockMetricsContext,
|
|
mockRequest,
|
|
route,
|
|
sessionTokenId,
|
|
uid
|
|
}
|
|
}
|
|
|
|
it('should create an account', () => {
|
|
const mocked = setup()
|
|
const clientAddress = mocked.clientAddress
|
|
const emailCode = mocked.emailCode
|
|
const keyFetchTokenId = mocked.keyFetchTokenId
|
|
const mockDB = mocked.mockDB
|
|
const mockLog = mocked.mockLog
|
|
const mockMailer = mocked.mockMailer
|
|
const mockMetricsContext = mocked.mockMetricsContext
|
|
const mockRequest = mocked.mockRequest
|
|
const route = mocked.route
|
|
const sessionTokenId = mocked.sessionTokenId
|
|
const uid = mocked.uid
|
|
|
|
const now = Date.now()
|
|
sinon.stub(Date, 'now', () => now)
|
|
|
|
return runTest(route, mockRequest, () => {
|
|
assert.equal(mockDB.createAccount.callCount, 1, 'createAccount was called')
|
|
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called once')
|
|
let args = mockDB.createSessionToken.args[0]
|
|
assert.equal(args.length, 1, 'db.createSessionToken was passed one argument')
|
|
assert.equal(args[0].uaBrowser, 'Firefox Mobile', 'db.createSessionToken was passed correct browser')
|
|
assert.equal(args[0].uaBrowserVersion, '9', 'db.createSessionToken was passed correct browser version')
|
|
assert.equal(args[0].uaOS, 'iOS', 'db.createSessionToken was passed correct os')
|
|
assert.equal(args[0].uaOSVersion, '11', 'db.createSessionToken was passed correct os version')
|
|
assert.equal(args[0].uaDeviceType, 'tablet', 'db.createSessionToken was passed correct device type')
|
|
assert.equal(args[0].uaFormFactor, 'iPad', 'db.createSessionToken was passed correct form factor')
|
|
|
|
assert.equal(mockLog.notifier.send.callCount, 1, 'an sqs event was logged')
|
|
var eventData = mockLog.notifier.send.getCall(0).args[0]
|
|
assert.equal(eventData.event, 'login', 'it was a login event')
|
|
assert.equal(eventData.data.service, 'sync', 'it was for sync')
|
|
assert.equal(eventData.data.email, TEST_EMAIL, 'it was for the correct email')
|
|
assert.ok(eventData.data.ts, 'timestamp of event set')
|
|
assert.deepEqual(eventData.data.metricsContext, {
|
|
flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: 'account.signed',
|
|
flowType: undefined,
|
|
flow_id: mockRequest.payload.metricsContext.flowId,
|
|
flow_time: now - mockRequest.payload.metricsContext.flowBeginTime,
|
|
time: now,
|
|
utm_campaign: 'utm campaign',
|
|
utm_content: 'utm content',
|
|
utm_medium: 'utm medium',
|
|
utm_source: 'utm source',
|
|
utm_term: 'utm term'
|
|
}, 'it contained the correct metrics context metadata')
|
|
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.created',
|
|
service: 'sync',
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
|
|
assert.equal(mockLog.flowEvent.callCount, 1, 'log.flowEvent was called once')
|
|
args = mockLog.flowEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.flowEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.created',
|
|
flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: 'account.signed',
|
|
flowType: undefined,
|
|
flow_time: now - mockRequest.payload.metricsContext.flowBeginTime,
|
|
flow_id: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
locale: 'en-GB',
|
|
time: now,
|
|
uid: uid,
|
|
userAgent: 'test user-agent',
|
|
utm_campaign: 'utm campaign',
|
|
utm_content: 'utm content',
|
|
utm_medium: 'utm medium',
|
|
utm_source: 'utm source',
|
|
utm_term: 'utm term'
|
|
}, 'flow event data was correct')
|
|
|
|
assert.equal(mockMetricsContext.validate.callCount, 1, 'metricsContext.validate was called')
|
|
assert.equal(mockMetricsContext.validate.args[0].length, 0, 'validate was called without arguments')
|
|
|
|
assert.equal(mockMetricsContext.stash.callCount, 3, 'metricsContext.stash was called three times')
|
|
|
|
args = mockMetricsContext.stash.args[0]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument first time')
|
|
assert.deepEqual(args[0].id, sessionTokenId, 'argument was session token')
|
|
assert.deepEqual(args[0].uid, uid, 'sessionToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[0], mockRequest, 'this was request')
|
|
|
|
args = mockMetricsContext.stash.args[1]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument second time')
|
|
assert.equal(args[0].id, emailCode, 'argument was synthesized token')
|
|
assert.deepEqual(args[0].uid, uid, 'token.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[1], mockRequest, 'this was request')
|
|
|
|
args = mockMetricsContext.stash.args[2]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument third time')
|
|
assert.deepEqual(args[0].id, keyFetchTokenId, 'argument was key fetch token')
|
|
assert.deepEqual(args[0].uid, uid, 'keyFetchToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[2], mockRequest, 'this was request')
|
|
|
|
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1, 'metricsContext.setFlowCompleteSignal was called once')
|
|
args = mockMetricsContext.setFlowCompleteSignal.args[0]
|
|
assert.equal(args.length, 2, 'metricsContext.setFlowCompleteSignal was passed two arguments')
|
|
assert.equal(args[0], 'account.signed', 'first argument was event name')
|
|
assert.equal(args[1], 'registration', 'second argument was flow type')
|
|
|
|
var securityEvent = mockDB.securityEvent
|
|
assert.equal(securityEvent.callCount, 1, 'db.securityEvent is called')
|
|
securityEvent = securityEvent.args[0][0]
|
|
assert.equal(securityEvent.name, 'account.create')
|
|
assert.equal(securityEvent.uid, uid)
|
|
assert.equal(securityEvent.ipAddr, clientAddress)
|
|
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 1, 'mailer.sendVerifyCode was called')
|
|
args = mockMailer.sendVerifyCode.args[0]
|
|
assert.equal(args[2].location.city, 'Mountain View')
|
|
assert.equal(args[2].location.country, 'United States')
|
|
assert.equal(args[2].uaBrowser, 'Firefox Mobile')
|
|
assert.equal(args[2].uaBrowserVersion, '9')
|
|
assert.equal(args[2].uaOS, 'iOS')
|
|
assert.equal(args[2].uaOSVersion, '11')
|
|
assert.strictEqual(args[2].uaDeviceType, 'tablet')
|
|
assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId)
|
|
assert.equal(args[2].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime)
|
|
assert.equal(args[2].service, 'sync')
|
|
assert.equal(args[2].uid, uid)
|
|
|
|
assert.equal(mockLog.error.callCount, 0)
|
|
}).finally(() => Date.now.restore())
|
|
})
|
|
})
|
|
|
|
describe('/account/login', function () {
|
|
var config = {
|
|
securityHistory: {
|
|
ipProfiling: {}
|
|
},
|
|
signinConfirmation: {
|
|
tokenVerificationCode: {
|
|
codeLength: 8
|
|
}
|
|
},
|
|
signinUnblock: {
|
|
codeLifetime: 1000
|
|
}
|
|
}
|
|
const mockLog = log('ERROR', 'test')
|
|
mockLog.activityEvent = sinon.spy(() => {
|
|
return P.resolve()
|
|
})
|
|
mockLog.flowEvent = sinon.spy(() => {
|
|
return P.resolve()
|
|
})
|
|
mockLog.notifier.send = sinon.spy()
|
|
const mockMetricsContext = mocks.mockMetricsContext()
|
|
|
|
const mockRequest = mocks.mockRequest({
|
|
log: mockLog,
|
|
headers: {
|
|
dnt: '1',
|
|
'user-agent': 'test user-agent'
|
|
},
|
|
metricsContext: mockMetricsContext,
|
|
payload: {
|
|
authPW: hexString(32),
|
|
email: TEST_EMAIL,
|
|
service: 'sync',
|
|
reason: 'signin',
|
|
metricsContext: {
|
|
flowBeginTime: Date.now(),
|
|
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
utmCampaign: 'utm campaign',
|
|
utmContent: 'utm content',
|
|
utmMedium: 'utm medium',
|
|
utmSource: 'utm source',
|
|
utmTerm: 'utm term'
|
|
}
|
|
},
|
|
query: {
|
|
keys: 'true'
|
|
},
|
|
uaBrowser: 'Firefox',
|
|
uaBrowserVersion: '50',
|
|
uaOS: 'Android',
|
|
uaOSVersion: '6',
|
|
uaDeviceType: 'mobile'
|
|
})
|
|
const mockRequestNoKeys = mocks.mockRequest({
|
|
log: mockLog,
|
|
metricsContext: mockMetricsContext,
|
|
payload: {
|
|
authPW: hexString(32),
|
|
email: 'test@mozilla.com',
|
|
service: 'dcdb5ae7add825d2',
|
|
reason: 'signin',
|
|
metricsContext: {
|
|
flowBeginTime: Date.now(),
|
|
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
service: 'dcdb5ae7add825d2'
|
|
}
|
|
},
|
|
query: {}
|
|
})
|
|
const mockRequestWithUnblockCode = mocks.mockRequest({
|
|
log: mockLog,
|
|
payload: {
|
|
authPW: hexString(32),
|
|
email: TEST_EMAIL,
|
|
unblockCode: 'ABCD1234',
|
|
service: 'dcdb5ae7add825d2',
|
|
reason: 'signin',
|
|
metricsContext: {
|
|
flowBeginTime: Date.now(),
|
|
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103'
|
|
}
|
|
}
|
|
})
|
|
var keyFetchTokenId = hexString(16)
|
|
var sessionTokenId = hexString(16)
|
|
var uid = uuid.v4('binary').toString('hex')
|
|
var mockDB = mocks.mockDB({
|
|
email: TEST_EMAIL,
|
|
emailVerified: true,
|
|
keyFetchTokenId: keyFetchTokenId,
|
|
sessionTokenId: sessionTokenId,
|
|
uaBrowser: 'Firefox',
|
|
uaBrowserVersion: 50,
|
|
uaOS: 'Android',
|
|
uaOSVersion: '6',
|
|
uaDeviceType: 'mobile',
|
|
uid: uid
|
|
})
|
|
var mockMailer = mocks.mockMailer()
|
|
var mockPush = mocks.mockPush()
|
|
var mockCustoms = {
|
|
check: () => P.resolve(),
|
|
flag: () => P.resolve()
|
|
}
|
|
var accountRoutes = makeRoutes({
|
|
checkPassword: function () {
|
|
return P.resolve(true)
|
|
},
|
|
config: config,
|
|
customs: mockCustoms,
|
|
db: mockDB,
|
|
log: mockLog,
|
|
mailer: mockMailer,
|
|
push: mockPush
|
|
})
|
|
var route = getRoute(accountRoutes, '/account/login')
|
|
|
|
const defaultEmailRecord = mockDB.emailRecord
|
|
const defaultEmailAccountRecord = mockDB.accountRecord
|
|
|
|
afterEach(() => {
|
|
mockLog.activityEvent.reset()
|
|
mockLog.flowEvent.reset()
|
|
mockMailer.sendNewDeviceLoginNotification = sinon.spy(() => P.resolve([]))
|
|
mockMailer.sendVerifyLoginEmail.reset()
|
|
mockMailer.sendVerifyCode.reset()
|
|
mockDB.createSessionToken.reset()
|
|
mockDB.sessions.reset()
|
|
mockMetricsContext.stash.reset()
|
|
mockMetricsContext.validate.reset()
|
|
mockMetricsContext.setFlowCompleteSignal.reset()
|
|
mockDB.emailRecord = defaultEmailRecord
|
|
mockDB.emailRecord.reset()
|
|
mockDB.accountRecord = defaultEmailAccountRecord
|
|
mockDB.accountRecord.reset()
|
|
mockDB.getSecondaryEmail = sinon.spy(() => P.reject(error.unknownSecondaryEmail()))
|
|
mockDB.getSecondaryEmail.reset()
|
|
mockRequest.payload.email = TEST_EMAIL
|
|
})
|
|
|
|
it('emits the correct series of calls and events', function () {
|
|
const now = Date.now()
|
|
sinon.stub(Date, 'now', () => now)
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.accountRecord.callCount, 1, 'db.accountRecord was called')
|
|
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called once')
|
|
let args = mockDB.createSessionToken.args[0]
|
|
assert.equal(args.length, 1, 'db.createSessionToken was passed one argument')
|
|
assert.equal(args[0].uaBrowser, 'Firefox', 'db.createSessionToken was passed correct browser')
|
|
assert.equal(args[0].uaBrowserVersion, '50', 'db.createSessionToken was passed correct browser version')
|
|
assert.equal(args[0].uaOS, 'Android', 'db.createSessionToken was passed correct os')
|
|
assert.equal(args[0].uaOSVersion, '6', 'db.createSessionToken was passed correct os version')
|
|
assert.equal(args[0].uaDeviceType, 'mobile', 'db.createSessionToken was passed correct device type')
|
|
assert.equal(args[0].uaFormFactor, null, 'db.createSessionToken was passed correct form factor')
|
|
|
|
assert.equal(mockLog.notifier.send.callCount, 1, 'an sqs event was logged')
|
|
const eventData = mockLog.notifier.send.getCall(0).args[0]
|
|
assert.equal(eventData.event, 'login', 'it was a login event')
|
|
assert.equal(eventData.data.service, 'sync', 'it was for sync')
|
|
assert.equal(eventData.data.email, TEST_EMAIL, 'it was for the correct email')
|
|
assert.ok(eventData.data.ts, 'timestamp of event set')
|
|
assert.deepEqual(eventData.data.metricsContext, {
|
|
time: now,
|
|
flow_id: mockRequest.payload.metricsContext.flowId,
|
|
flow_time: now - mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: 'account.signed',
|
|
flowType: undefined
|
|
}, 'metrics context was correct')
|
|
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.login',
|
|
service: 'sync',
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
|
|
assert.equal(mockLog.flowEvent.callCount, 2, 'log.flowEvent was called twice')
|
|
args = mockLog.flowEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.flowEvent was passed one argument first time')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.login',
|
|
flow_time: now - mockRequest.payload.metricsContext.flowBeginTime,
|
|
flow_id: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: 'account.signed',
|
|
flowType: undefined,
|
|
locale: 'en-US',
|
|
time: now,
|
|
uid: uid,
|
|
userAgent: 'test user-agent'
|
|
}, 'first flow event was correct')
|
|
args = mockLog.flowEvent.args[1]
|
|
assert.equal(args.length, 1, 'log.flowEvent was passed one argument second time')
|
|
assert.deepEqual(args[0], {
|
|
event: 'email.confirmation.sent',
|
|
flow_time: now - mockRequest.payload.metricsContext.flowBeginTime,
|
|
flow_id: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
|
|
flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime,
|
|
flowCompleteSignal: 'account.signed',
|
|
flowType: undefined,
|
|
locale: 'en-US',
|
|
time: now,
|
|
userAgent: 'test user-agent'
|
|
}, 'second flow event was correct')
|
|
|
|
assert.equal(mockMetricsContext.validate.callCount, 1, 'metricsContext.validate was called')
|
|
assert.equal(mockMetricsContext.validate.args[0].length, 0, 'validate was called without arguments')
|
|
|
|
assert.equal(mockMetricsContext.stash.callCount, 3, 'metricsContext.stash was called three times')
|
|
|
|
args = mockMetricsContext.stash.args[0]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument first time')
|
|
assert.deepEqual(args[0].id, sessionTokenId, 'argument was session token')
|
|
assert.deepEqual(args[0].uid, uid, 'sessionToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[0], mockRequest, 'this was request')
|
|
|
|
args = mockMetricsContext.stash.args[1]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument second time')
|
|
assert.ok(/^[0-9a-f]{32}$/.test(args[0].id), 'argument was synthesized token verification id')
|
|
assert.deepEqual(args[0].uid, uid, 'tokenVerificationId uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[1], mockRequest, 'this was request')
|
|
|
|
args = mockMetricsContext.stash.args[2]
|
|
assert.equal(args.length, 1, 'metricsContext.stash was passed one argument third time')
|
|
assert.deepEqual(args[0].id, keyFetchTokenId, 'argument was key fetch token')
|
|
assert.deepEqual(args[0].uid, uid, 'keyFetchToken.uid was correct')
|
|
assert.equal(mockMetricsContext.stash.thisValues[1], mockRequest, 'this was request')
|
|
|
|
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1, 'metricsContext.setFlowCompleteSignal was called once')
|
|
args = mockMetricsContext.setFlowCompleteSignal.args[0]
|
|
assert.equal(args.length, 2, 'metricsContext.setFlowCompleteSignal was passed two arguments')
|
|
assert.equal(args[0], 'account.signed', 'argument was event name')
|
|
assert.equal(args[1], 'login', 'second argument was flow type')
|
|
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called')
|
|
args = mockMailer.sendVerifyLoginEmail.args[0]
|
|
assert.equal(args[2].location.city, 'Mountain View')
|
|
assert.equal(args[2].location.country, 'United States')
|
|
assert.equal(args[2].timeZone, 'America/Los_Angeles')
|
|
assert.equal(args[2].uaBrowser, 'Firefox')
|
|
assert.equal(args[2].uaBrowserVersion, '50')
|
|
assert.equal(args[2].uaOS, 'Android')
|
|
assert.equal(args[2].uaOSVersion, '6')
|
|
assert.equal(args[2].uaDeviceType, 'mobile')
|
|
assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId)
|
|
assert.equal(args[2].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime)
|
|
assert.equal(args[2].service, 'sync')
|
|
assert.equal(args[2].uid, uid)
|
|
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.ok(! response.verified, 'response indicates account is not verified')
|
|
assert.equal(response.verificationMethod, 'email', 'verificationMethod is email')
|
|
assert.equal(response.verificationReason, 'login', 'verificationReason is login')
|
|
}).finally(() => Date.now.restore())
|
|
})
|
|
|
|
describe('sign-in unverified account', function () {
|
|
it('sends email code', function () {
|
|
var emailCode = hexString(16)
|
|
mockDB.accountRecord = function () {
|
|
return P.resolve({
|
|
authSalt: hexString(32),
|
|
data: hexString(32),
|
|
email: TEST_EMAIL,
|
|
emailVerified: false,
|
|
emailCode: emailCode,
|
|
primaryEmail: {normalizedEmail: TEST_EMAIL.toLowerCase(), email: TEST_EMAIL, isVerified: false, isPrimary: true},
|
|
kA: hexString(32),
|
|
lastAuthAt: function () {
|
|
return Date.now()
|
|
},
|
|
uid: uid,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
}
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 1, 'mailer.sendVerifyCode was called')
|
|
|
|
// Verify that the email code was sent
|
|
var verifyCallArgs = mockMailer.sendVerifyCode.getCall(0).args
|
|
assert.notEqual(verifyCallArgs[1], emailCode, 'mailer.sendVerifyCode was called with a fresh verification code')
|
|
assert.equal(mockLog.flowEvent.callCount, 2, 'log.flowEvent was called twice')
|
|
assert.equal(mockLog.flowEvent.args[0][0].event, 'account.login', 'first event was login')
|
|
assert.equal(mockLog.flowEvent.args[1][0].event, 'email.verification.sent', 'second event was sent')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 0, 'mailer.sendVerifyLoginEmail was not called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.equal(response.verified, false, 'response indicates account is unverified')
|
|
assert.equal(response.verificationMethod, 'email', 'verificationMethod is email')
|
|
assert.equal(response.verificationReason, 'signup', 'verificationReason is signup')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sign-in confirmation', function () {
|
|
before(() => {
|
|
config.signinConfirmation.forcedEmailAddresses = /.+@mozilla\.com$/
|
|
|
|
mockDB.accountRecord = function () {
|
|
return P.resolve({
|
|
authSalt: hexString(32),
|
|
data: hexString(32),
|
|
email: TEST_EMAIL,
|
|
emailVerified: true,
|
|
primaryEmail: {normalizedEmail: TEST_EMAIL.toLowerCase(), email: TEST_EMAIL, isVerified: true, isPrimary: true},
|
|
kA: hexString(32),
|
|
lastAuthAt: function () {
|
|
return Date.now()
|
|
},
|
|
uid: uid,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
}
|
|
})
|
|
|
|
it('is enabled by default', function () {
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.ok(tokenData.mustVerify, 'sessionToken must be verified before use')
|
|
assert.ok(tokenData.tokenVerificationId, 'sessionToken was created unverified')
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 0, 'mailer.sendVerifyCode was not called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.ok(! response.verified, 'response indicates account is not verified')
|
|
assert.equal(response.verificationMethod, 'email', 'verificationMethod is email')
|
|
assert.equal(response.verificationReason, 'login', 'verificationReason is login')
|
|
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.city, 'Mountain View')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.country, 'United States')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].timeZone, 'America/Los_Angeles')
|
|
})
|
|
})
|
|
|
|
it('does not require verification when keys are not requested', function () {
|
|
const email = 'test@mozilla.com'
|
|
mockDB.accountRecord = function () {
|
|
return P.resolve({
|
|
authSalt: hexString(32),
|
|
data: hexString(32),
|
|
email: 'test@mozilla.com',
|
|
emailVerified: true,
|
|
primaryEmail: {normalizedEmail: email.toLowerCase(), email: email, isVerified: true, isPrimary: true},
|
|
kA: hexString(32),
|
|
lastAuthAt: function () {
|
|
return Date.now()
|
|
},
|
|
uid: uid,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
}
|
|
|
|
return runTest(route, mockRequestNoKeys, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.ok(! tokenData.mustVerify, 'sessionToken does not have to be verified')
|
|
assert.ok(tokenData.tokenVerificationId, 'sessionToken was created unverified')
|
|
// Note that *neither* email is sent in this case,
|
|
// since it can't have been a new device connection.
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 0, 'mailer.sendVerifyLoginEmail was not called')
|
|
|
|
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1, 'metricsContext.setFlowCompleteSignal was called once')
|
|
assert.deepEqual(mockMetricsContext.setFlowCompleteSignal.args[0][0], 'account.login', 'argument was event name')
|
|
|
|
assert.ok(response.verified, 'response indicates account is verified')
|
|
assert.ok(! response.verificationMethod, 'verificationMethod doesn\'t exist')
|
|
assert.ok(! response.verificationReason, 'verificationReason doesn\'t exist')
|
|
})
|
|
})
|
|
|
|
it('unverified account gets account confirmation email', function () {
|
|
const email = 'test@mozilla.com'
|
|
mockRequest.payload.email = email
|
|
mockDB.accountRecord = function () {
|
|
return P.resolve({
|
|
authSalt: hexString(32),
|
|
data: hexString(32),
|
|
email: mockRequest.payload.email,
|
|
emailVerified: false,
|
|
primaryEmail: {normalizedEmail: email.toLowerCase(), email: email, isVerified: false, isPrimary: true},
|
|
kA: hexString(32),
|
|
lastAuthAt: function () {
|
|
return Date.now()
|
|
},
|
|
uid: uid,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
}
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.ok(tokenData.mustVerify, 'sessionToken must be verified before use')
|
|
assert.ok(tokenData.tokenVerificationId, 'sessionToken was created unverified')
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 1, 'mailer.sendVerifyCode was called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 0, 'mailer.sendVerifyLoginEmail was not called')
|
|
assert.ok(! response.verified, 'response indicates account is not verified')
|
|
assert.equal(response.verificationMethod, 'email', 'verificationMethod is email')
|
|
assert.equal(response.verificationReason, 'signup', 'verificationReason is signup')
|
|
})
|
|
})
|
|
|
|
describe('skip for new accounts', function () {
|
|
function setup(enabled, accountCreatedSince) {
|
|
config.signinConfirmation.skipForNewAccounts = {
|
|
enabled: enabled,
|
|
maxAge: 5
|
|
}
|
|
|
|
const email = mockRequest.payload.email
|
|
|
|
mockDB.accountRecord = function () {
|
|
return P.resolve({
|
|
authSalt: hexString(32),
|
|
createdAt: Date.now() - accountCreatedSince,
|
|
data: hexString(32),
|
|
email: email,
|
|
emailVerified: true,
|
|
primaryEmail: {normalizedEmail: email.toLowerCase(), email: email, isVerified: true, isPrimary: true},
|
|
kA: hexString(32),
|
|
lastAuthAt: function () {
|
|
return Date.now()
|
|
},
|
|
uid: uid,
|
|
wrapWrapKb: hexString(32)
|
|
})
|
|
}
|
|
|
|
var accountRoutes = makeRoutes({
|
|
checkPassword: function () {
|
|
return P.resolve(true)
|
|
},
|
|
config: config,
|
|
customs: mockCustoms,
|
|
db: mockDB,
|
|
log: mockLog,
|
|
mailer: mockMailer,
|
|
push: mockPush
|
|
})
|
|
|
|
route = getRoute(accountRoutes, '/account/login')
|
|
}
|
|
|
|
it('is disabled', function () {
|
|
setup(false)
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.ok(tokenData.mustVerify, 'sessionToken must be verified before use')
|
|
assert.ok(tokenData.tokenVerificationId, 'sessionToken was created unverified')
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 0, 'mailer.sendVerifyCode was not called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.ok(! response.verified, 'response indicates account is not verified')
|
|
assert.equal(response.verificationMethod, 'email', 'verificationMethod is email')
|
|
assert.equal(response.verificationReason, 'login', 'verificationReason is login')
|
|
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.city, 'Mountain View')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.country, 'United States')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.getCall(0).args[2].timeZone, 'America/Los_Angeles')
|
|
})
|
|
})
|
|
|
|
|
|
it('skip sign-in confirmation on recently created account', function () {
|
|
setup(true, 0)
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.equal(tokenData.tokenVerificationId, null, 'sessionToken was created verified')
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 0, 'mailer.sendVerifyLoginEmail was not called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 1, 'mailer.sendNewDeviceLoginNotification was called')
|
|
assert.ok(response.verified, 'response indicates account is verified')
|
|
})
|
|
})
|
|
|
|
it('do not error if new device login notification is blocked', function () {
|
|
setup(true, 0)
|
|
|
|
mockMailer.sendNewDeviceLoginNotification = sinon.spy(() => P.reject(error.emailBouncedHard()))
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.equal(tokenData.tokenVerificationId, null, 'sessionToken was created verified')
|
|
assert.equal(mockMailer.sendVerifyCode.callCount, 0, 'mailer.sendVerifyLoginEmail was not called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 1, 'mailer.sendNewDeviceLoginNotification was called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.args[0][2].flowId, mockRequest.payload.metricsContext.flowId)
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.args[0][2].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime)
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.args[0][2].service, 'sync')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.args[0][2].uid, uid)
|
|
assert.ok(response.verified, 'response indicates account is verified')
|
|
})
|
|
})
|
|
|
|
|
|
it('don\'t skip sign-in confirmation on older account', function () {
|
|
setup(true, 10)
|
|
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.createSessionToken.callCount, 1, 'db.createSessionToken was called')
|
|
var tokenData = mockDB.createSessionToken.getCall(0).args[0]
|
|
assert.ok(tokenData.tokenVerificationId, 'sessionToken was created unverified')
|
|
assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called')
|
|
assert.equal(mockMailer.sendNewDeviceLoginNotification.callCount, 0, 'mailer.sendNewDeviceLoginNotification was not called')
|
|
assert.ok(! response.verified, 'response indicates account is unverified')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('creating too many sessions causes an error to be logged', function () {
|
|
const oldSessions = mockDB.sessions
|
|
mockDB.sessions = sinon.spy(function () {
|
|
return P.resolve(new Array(200))
|
|
})
|
|
mockLog.error = sinon.spy()
|
|
mockRequest.app.clientAddress = '63.245.221.32'
|
|
return runTest(route, mockRequest, function () {
|
|
assert.equal(mockLog.error.callCount, 0, 'log.error was not called')
|
|
}).then(function () {
|
|
mockDB.sessions = sinon.spy(function () {
|
|
return P.resolve(new Array(201))
|
|
})
|
|
mockLog.error.reset()
|
|
return runTest(route, mockRequest, function () {
|
|
assert.equal(mockLog.error.callCount, 1, 'log.error was called')
|
|
assert.equal(mockLog.error.firstCall.args[0].op, 'Account.login')
|
|
assert.equal(mockLog.error.firstCall.args[0].numSessions, 201)
|
|
mockDB.sessions = oldSessions
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('checks security history', function () {
|
|
var record
|
|
var clientAddress = mockRequest.app.clientAddress
|
|
before(() => {
|
|
mockLog.info = sinon.spy(function (arg) {
|
|
if (arg.op.indexOf('Account.history') === 0) {
|
|
record = arg
|
|
}
|
|
})
|
|
})
|
|
|
|
it('with a seen ip address', function () {
|
|
record = undefined
|
|
var securityQuery
|
|
mockDB.securityEvents = sinon.spy(function (arg) {
|
|
securityQuery = arg
|
|
return P.resolve([
|
|
{
|
|
name: 'account.login',
|
|
createdAt: Date.now(),
|
|
verified: true
|
|
}
|
|
])
|
|
})
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.securityEvents.callCount, 1, 'db.securityEvents was called')
|
|
assert.equal(securityQuery.uid, uid)
|
|
assert.equal(securityQuery.ipAddr, clientAddress)
|
|
|
|
assert.equal(!! record, true, 'log.info was called for Account.history')
|
|
assert.equal(record.op, 'Account.history.verified')
|
|
assert.equal(record.uid, uid)
|
|
assert.equal(record.events, 1)
|
|
assert.equal(record.recency, 'day')
|
|
})
|
|
})
|
|
|
|
it('with a seen, unverified ip address', function () {
|
|
record = undefined
|
|
var securityQuery
|
|
mockDB.securityEvents = sinon.spy(function (arg) {
|
|
securityQuery = arg
|
|
return P.resolve([
|
|
{
|
|
name: 'account.login',
|
|
createdAt: Date.now(),
|
|
verified: false
|
|
}
|
|
])
|
|
})
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.securityEvents.callCount, 1, 'db.securityEvents was called')
|
|
assert.equal(securityQuery.uid, uid)
|
|
assert.equal(securityQuery.ipAddr, clientAddress)
|
|
|
|
assert.equal(!! record, true, 'log.info was called for Account.history')
|
|
assert.equal(record.op, 'Account.history.unverified')
|
|
assert.equal(record.uid, uid)
|
|
assert.equal(record.events, 1)
|
|
})
|
|
})
|
|
|
|
it('with a new ip address', function () {
|
|
record = undefined
|
|
|
|
var securityQuery
|
|
mockDB.securityEvents = sinon.spy(function (arg) {
|
|
securityQuery = arg
|
|
return P.resolve([])
|
|
})
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.securityEvents.callCount, 1, 'db.securityEvents was called')
|
|
assert.equal(securityQuery.uid, uid)
|
|
assert.equal(securityQuery.ipAddr, clientAddress)
|
|
|
|
assert.equal(record, undefined, 'log.info was not called for Account.history.verified')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('records security event', function () {
|
|
var clientAddress = mockRequest.app.clientAddress
|
|
var securityQuery
|
|
mockDB.securityEvent = sinon.spy(function (arg) {
|
|
securityQuery = arg
|
|
return P.resolve()
|
|
})
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.equal(mockDB.securityEvent.callCount, 1, 'db.securityEvent was called')
|
|
assert.equal(securityQuery.uid, uid)
|
|
assert.equal(securityQuery.ipAddr, clientAddress)
|
|
assert.equal(securityQuery.name, 'account.login')
|
|
})
|
|
})
|
|
|
|
describe('blocked by customs', () => {
|
|
|
|
describe('can unblock', () => {
|
|
const oldCheck = mockCustoms.check
|
|
|
|
before(() => {
|
|
mockCustoms.check = () => P.reject(error.requestBlocked(true))
|
|
})
|
|
|
|
beforeEach(() => {
|
|
mockLog.activityEvent.reset()
|
|
mockLog.flowEvent.reset()
|
|
})
|
|
|
|
after(() => {
|
|
mockCustoms.check = oldCheck
|
|
})
|
|
|
|
describe('signin unblock enabled', () => {
|
|
before(() => {
|
|
mockLog.flowEvent.reset()
|
|
})
|
|
|
|
it('without unblock code', () => {
|
|
return runTest(route, mockRequest).then(() => assert.ok(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
|
|
assert.equal(err.output.statusCode, 400, 'correct status code is returned')
|
|
assert.equal(err.output.payload.verificationMethod, 'email-captcha')
|
|
assert.equal(err.output.payload.verificationReason, 'login')
|
|
assert.equal(mockLog.flowEvent.callCount, 1, 'log.flowEvent called once')
|
|
assert.equal(mockLog.flowEvent.args[0][0].event, 'account.login.blocked', 'first event is blocked')
|
|
mockLog.flowEvent.reset()
|
|
})
|
|
})
|
|
|
|
describe('with unblock code', () => {
|
|
|
|
it('invalid code', () => {
|
|
mockDB.consumeUnblockCode = () => P.reject(error.invalidUnblockCode())
|
|
return runTest(route, mockRequestWithUnblockCode).then(() => assert.ok(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.INVALID_UNBLOCK_CODE, 'correct errno is returned')
|
|
assert.equal(err.output.statusCode, 400, 'correct status code is returned')
|
|
assert.equal(mockLog.flowEvent.callCount, 2, 'log.flowEvent called twice')
|
|
assert.equal(mockLog.flowEvent.args[1][0].event, 'account.login.invalidUnblockCode', 'second event is invalid')
|
|
|
|
mockLog.flowEvent.reset()
|
|
})
|
|
})
|
|
|
|
it('expired code', () => {
|
|
// test 5 seconds old, to reduce flakiness of test
|
|
mockDB.consumeUnblockCode = () => P.resolve({ createdAt: Date.now() - (config.signinUnblock.codeLifetime + 5000) })
|
|
return runTest(route, mockRequestWithUnblockCode).then(() => assert.ok(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.INVALID_UNBLOCK_CODE, 'correct errno is returned')
|
|
assert.equal(err.output.statusCode, 400, 'correct status code is returned')
|
|
|
|
assert.equal(mockLog.flowEvent.callCount, 2, 'log.flowEvent called twice')
|
|
assert.equal(mockLog.flowEvent.args[1][0].event, 'account.login.invalidUnblockCode', 'second event is invalid')
|
|
|
|
mockLog.activityEvent.reset()
|
|
mockLog.flowEvent.reset()
|
|
})
|
|
})
|
|
|
|
it('unknown account', () => {
|
|
mockDB.accountRecord = () => P.reject(new error.unknownAccount())
|
|
mockDB.emailRecord = () => P.reject(new error.unknownAccount())
|
|
return runTest(route, mockRequestWithUnblockCode).then(() => assert(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.REQUEST_BLOCKED)
|
|
assert.equal(err.output.statusCode, 400)
|
|
})
|
|
})
|
|
|
|
it('valid code', () => {
|
|
mockDB.consumeUnblockCode = () => P.resolve({ createdAt: Date.now() })
|
|
return runTest(route, mockRequestWithUnblockCode, (res) => {
|
|
assert.equal(mockLog.flowEvent.callCount, 4)
|
|
assert.equal(mockLog.flowEvent.args[0][0].event, 'account.login.blocked', 'first event was account.login.blocked')
|
|
assert.equal(mockLog.flowEvent.args[1][0].event, 'account.login.confirmedUnblockCode', 'second event was account.login.confirmedUnblockCode')
|
|
assert.equal(mockLog.flowEvent.args[2][0].event, 'account.login', 'third event was account.login')
|
|
assert.equal(mockLog.flowEvent.args[3][0].event, 'flow.complete', 'fourth event was flow.complete')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('cannot unblock', () => {
|
|
const oldCheck = mockCustoms.check
|
|
before(() => {
|
|
mockCustoms.check = () => P.reject(error.requestBlocked(false))
|
|
})
|
|
|
|
after(() => {
|
|
mockCustoms.check = oldCheck
|
|
})
|
|
|
|
it('without an unblock code', () => {
|
|
return runTest(route, mockRequest).then(() => assert.ok(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
|
|
assert.equal(err.output.statusCode, 400, 'correct status code is returned')
|
|
assert.equal(err.output.payload.verificationMethod, undefined, 'no verificationMethod')
|
|
assert.equal(err.output.payload.verificationReason, undefined, 'no verificationReason')
|
|
})
|
|
})
|
|
|
|
it('with unblock code', () => {
|
|
return runTest(route, mockRequestWithUnblockCode).then(() => assert.ok(false), err => {
|
|
assert.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
|
|
assert.equal(err.output.statusCode, 400, 'correct status code is returned')
|
|
assert.equal(err.output.payload.verificationMethod, undefined, 'no verificationMethod')
|
|
assert.equal(err.output.payload.verificationReason, undefined, 'no verificationReason')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('fails login with non primary email', function () {
|
|
const email = 'foo@mail.com'
|
|
mockDB.accountRecord = sinon.spy(function () {
|
|
return P.resolve({
|
|
primaryEmail: {normalizedEmail: email.toLowerCase(), email: email, isVerified: true, isPrimary: false},
|
|
})
|
|
})
|
|
return runTest(route, mockRequest).then(() => assert.ok(false), (err) => {
|
|
assert.equal(mockDB.accountRecord.callCount, 1, 'db.accountRecord was called')
|
|
assert.equal(err.errno, 142, 'correct errno called')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('/account/keys', function () {
|
|
var keyFetchTokenId = hexString(16)
|
|
var uid = uuid.v4('binary').toString('hex')
|
|
const mockLog = mocks.mockLog()
|
|
const mockRequest = mocks.mockRequest({
|
|
credentials: {
|
|
emailVerified: true,
|
|
id: keyFetchTokenId,
|
|
keyBundle: hexString(16),
|
|
tokenVerificationId: undefined,
|
|
tokenVerified: true,
|
|
uid: uid
|
|
},
|
|
log: mockLog
|
|
})
|
|
var mockDB = mocks.mockDB()
|
|
var accountRoutes = makeRoutes({
|
|
db: mockDB,
|
|
log: mockLog
|
|
})
|
|
var route = getRoute(accountRoutes, '/account/keys')
|
|
|
|
it('verified token', function () {
|
|
return runTest(route, mockRequest, function (response) {
|
|
assert.deepEqual(response, {bundle: mockRequest.auth.credentials.keyBundle}, 'response was correct')
|
|
|
|
assert.equal(mockDB.deleteKeyFetchToken.callCount, 1, 'db.deleteKeyFetchToken was called once')
|
|
var args = mockDB.deleteKeyFetchToken.args[0]
|
|
assert.equal(args.length, 1, 'db.deleteKeyFetchToken was passed one argument')
|
|
assert.equal(args[0], mockRequest.auth.credentials, 'db.deleteKeyFetchToken was passed key fetch token')
|
|
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.keyfetch',
|
|
service: undefined,
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
})
|
|
.then(function () {
|
|
mockLog.activityEvent.reset()
|
|
mockDB.deleteKeyFetchToken.reset()
|
|
})
|
|
})
|
|
|
|
it('unverified token', function () {
|
|
mockRequest.auth.credentials.tokenVerificationId = hexString(16)
|
|
mockRequest.auth.credentials.tokenVerified = false
|
|
return runTest(route, mockRequest).then(() => assert.ok(false), response => {
|
|
assert.equal(response.errno, 104, 'correct errno for unverified account')
|
|
assert.equal(response.message, 'Unverified account', 'correct error message')
|
|
})
|
|
.then(function () {
|
|
mockLog.activityEvent.reset()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('/account/destroy', function () {
|
|
it('should delete the account', () => {
|
|
var email = 'foo@example.com'
|
|
var uid = uuid.v4('binary').toString('hex')
|
|
var mockDB = mocks.mockDB({
|
|
email: email,
|
|
uid: uid
|
|
})
|
|
const mockLog = mocks.mockLog()
|
|
const mockRequest = mocks.mockRequest({
|
|
log: mockLog,
|
|
payload: {
|
|
email: email,
|
|
authPW: new Array(65).join('f')
|
|
}
|
|
})
|
|
const mockPush = mocks.mockPush()
|
|
var accountRoutes = makeRoutes({
|
|
checkPassword: function () {
|
|
return P.resolve(true)
|
|
},
|
|
config: {
|
|
domain: 'wibble'
|
|
},
|
|
db: mockDB,
|
|
log: mockLog,
|
|
push: mockPush
|
|
})
|
|
var route = getRoute(accountRoutes, '/account/destroy')
|
|
|
|
return runTest(route, mockRequest, function () {
|
|
assert.equal(mockDB.accountRecord.callCount, 1, 'db.emailRecord was called once')
|
|
var args = mockDB.accountRecord.args[0]
|
|
assert.equal(args.length, 2, 'db.emailRecord was passed two arguments')
|
|
assert.equal(args[0], email, 'first argument was email address')
|
|
assert.equal(args[1], true, 'second argument was customs.check result')
|
|
|
|
assert.equal(mockDB.deleteAccount.callCount, 1, 'db.deleteAccount was called once')
|
|
args = mockDB.deleteAccount.args[0]
|
|
assert.equal(args.length, 1, 'db.deleteAccount was passed one argument')
|
|
assert.equal(args[0].email, email, 'db.deleteAccount was passed email record')
|
|
assert.deepEqual(args[0].uid, uid, 'email record had correct uid')
|
|
|
|
assert.equal(mockPush.notifyAccountDestroyed.callCount, 1)
|
|
assert.equal(mockPush.notifyAccountDestroyed.firstCall.args[0], uid)
|
|
|
|
assert.equal(mockLog.notifyAttachedServices.callCount, 1, 'log.notifyAttachedServices was called once')
|
|
args = mockLog.notifyAttachedServices.args[0]
|
|
assert.equal(args.length, 3, 'log.notifyAttachedServices was passed three arguments')
|
|
assert.equal(args[0], 'delete', 'first argument was event name')
|
|
assert.equal(args[1], mockRequest, 'second argument was request object')
|
|
assert.equal(args[2].uid, uid, 'third argument was event data with a uid')
|
|
assert.equal(args[2].iss, 'wibble', 'third argument was event data with an issuer field')
|
|
|
|
assert.equal(mockLog.activityEvent.callCount, 1, 'log.activityEvent was called once')
|
|
args = mockLog.activityEvent.args[0]
|
|
assert.equal(args.length, 1, 'log.activityEvent was passed one argument')
|
|
assert.deepEqual(args[0], {
|
|
event: 'account.deleted',
|
|
service: undefined,
|
|
userAgent: 'test user-agent',
|
|
uid: uid
|
|
}, 'event data was correct')
|
|
})
|
|
})
|
|
})
|
|
|