|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
| 4 | + |
| 5 | +/** Migrated from test/local/routes/recovery-codes.js (Mocha → Jest). */ |
| 6 | + |
| 7 | +import sinon from 'sinon'; |
| 8 | +import { Container } from 'typedi'; |
| 9 | +import { AppError as error } from '@fxa/accounts/errors'; |
| 10 | +import { BackupCodeManager } from '@fxa/accounts/two-factor'; |
| 11 | + |
| 12 | +const mocks = require('../../test/mocks'); |
| 13 | +const getRoute = require('../../test/routes_helpers').getRoute; |
| 14 | +const { AccountEventsManager } = require('../account-events'); |
| 15 | + |
| 16 | +let log: any, |
| 17 | + db: any, |
| 18 | + customs: any, |
| 19 | + routes: any, |
| 20 | + route: any, |
| 21 | + request: any, |
| 22 | + requestOptions: any, |
| 23 | + mailer: any, |
| 24 | + fxaMailer: any, |
| 25 | + glean: any; |
| 26 | +const TEST_EMAIL = '[email protected]'; |
| 27 | +const UID = 'uid'; |
| 28 | + |
| 29 | +const sandbox = sinon.createSandbox(); |
| 30 | +const mockBackupCodeManager = { |
| 31 | + getCountForUserId: sandbox.fake(), |
| 32 | +}; |
| 33 | +const mockAccountEventsManager = { |
| 34 | + recordSecurityEvent: sandbox.fake(), |
| 35 | +}; |
| 36 | + |
| 37 | +function runTest(routePath: string, requestOptions: any, method?: string) { |
| 38 | + const config = { |
| 39 | + recoveryCodes: { |
| 40 | + count: 8, |
| 41 | + length: 10, |
| 42 | + notifyLowCount: 2, |
| 43 | + }, |
| 44 | + }; |
| 45 | + routes = require('./recovery-codes')(log, db, config, customs, mailer, glean); |
| 46 | + route = getRoute(routes, routePath, method); |
| 47 | + request = mocks.mockRequest(requestOptions); |
| 48 | + request.emitMetricsEvent = sandbox.spy(() => Promise.resolve({})); |
| 49 | + |
| 50 | + return route.handler(request); |
| 51 | +} |
| 52 | + |
| 53 | +describe('backup authentication codes', () => { |
| 54 | + beforeEach(() => { |
| 55 | + log = mocks.mockLog(); |
| 56 | + customs = mocks.mockCustoms(); |
| 57 | + mailer = mocks.mockMailer(); |
| 58 | + fxaMailer = mocks.mockFxaMailer(); |
| 59 | + db = mocks.mockDB({ |
| 60 | + uid: UID, |
| 61 | + email: TEST_EMAIL, |
| 62 | + }); |
| 63 | + glean = mocks.mockGlean(); |
| 64 | + requestOptions = { |
| 65 | + metricsContext: mocks.mockMetricsContext(), |
| 66 | + credentials: { |
| 67 | + uid: 'uid', |
| 68 | + email: TEST_EMAIL, |
| 69 | + }, |
| 70 | + log: log, |
| 71 | + payload: { |
| 72 | + metricsContext: { |
| 73 | + flowBeginTime: Date.now(), |
| 74 | + flowId: |
| 75 | + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', |
| 76 | + }, |
| 77 | + }, |
| 78 | + }; |
| 79 | + Container.set(BackupCodeManager, mockBackupCodeManager); |
| 80 | + Container.set(AccountEventsManager, mockAccountEventsManager); |
| 81 | + }); |
| 82 | + |
| 83 | + afterEach(() => { |
| 84 | + sandbox.reset(); |
| 85 | + }); |
| 86 | + |
| 87 | + afterAll(() => { |
| 88 | + Container.reset(); |
| 89 | + }); |
| 90 | + |
| 91 | + describe('GET /recoveryCodes', () => { |
| 92 | + it('should replace backup authentication codes in TOTP session', () => { |
| 93 | + requestOptions.credentials.authenticatorAssuranceLevel = 2; |
| 94 | + return runTest('/recoveryCodes', requestOptions, 'GET').then( |
| 95 | + (res: any) => { |
| 96 | + expect(res.recoveryCodes).toHaveLength(2); |
| 97 | + |
| 98 | + expect(db.replaceRecoveryCodes.callCount).toBe(1); |
| 99 | + const args = db.replaceRecoveryCodes.args[0]; |
| 100 | + expect(args[0]).toBe(UID); |
| 101 | + expect(args[1]).toBe(8); |
| 102 | + sinon.assert.calledOnceWithExactly( |
| 103 | + mockAccountEventsManager.recordSecurityEvent, |
| 104 | + db, |
| 105 | + { |
| 106 | + name: 'account.recovery_codes_replaced', |
| 107 | + uid: 'uid', |
| 108 | + ipAddr: '63.245.221.32', |
| 109 | + tokenId: undefined, |
| 110 | + additionalInfo: { |
| 111 | + userAgent: 'test user-agent', |
| 112 | + location: { |
| 113 | + city: 'Mountain View', |
| 114 | + country: 'United States', |
| 115 | + countryCode: 'US', |
| 116 | + state: 'California', |
| 117 | + stateCode: 'CA', |
| 118 | + }, |
| 119 | + }, |
| 120 | + } |
| 121 | + ); |
| 122 | + } |
| 123 | + ); |
| 124 | + }); |
| 125 | + }); |
| 126 | + |
| 127 | + describe('PUT /recoveryCodes', () => { |
| 128 | + it('should overwrite backup authentication codes in TOTP session', () => { |
| 129 | + requestOptions.credentials.authenticatorAssuranceLevel = 2; |
| 130 | + requestOptions.payload.recoveryCodes = ['123']; |
| 131 | + |
| 132 | + return runTest('/recoveryCodes', requestOptions, 'PUT').then( |
| 133 | + (res: any) => { |
| 134 | + expect(res.success).toBe(true); |
| 135 | + |
| 136 | + expect(db.updateRecoveryCodes.callCount).toBe(1); |
| 137 | + |
| 138 | + const args = db.updateRecoveryCodes.args[0]; |
| 139 | + expect(args[0]).toBe(UID); |
| 140 | + expect(args[1]).toEqual(['123']); |
| 141 | + sinon.assert.calledOnceWithExactly( |
| 142 | + mockAccountEventsManager.recordSecurityEvent, |
| 143 | + db, |
| 144 | + { |
| 145 | + name: 'account.recovery_codes_created', |
| 146 | + uid: 'uid', |
| 147 | + ipAddr: '63.245.221.32', |
| 148 | + tokenId: undefined, |
| 149 | + additionalInfo: { |
| 150 | + userAgent: 'test user-agent', |
| 151 | + location: { |
| 152 | + city: 'Mountain View', |
| 153 | + country: 'United States', |
| 154 | + countryCode: 'US', |
| 155 | + state: 'California', |
| 156 | + stateCode: 'CA', |
| 157 | + }, |
| 158 | + }, |
| 159 | + } |
| 160 | + ); |
| 161 | + } |
| 162 | + ); |
| 163 | + }); |
| 164 | + }); |
| 165 | + |
| 166 | + describe('GET /recoveryCodes/exists', () => { |
| 167 | + it('should return hasBackupCodes and count', async () => { |
| 168 | + mockBackupCodeManager.getCountForUserId = sandbox.fake.returns({ |
| 169 | + hasBackupCodes: true, |
| 170 | + count: 8, |
| 171 | + }); |
| 172 | + |
| 173 | + const res = await runTest('/recoveryCodes/exists', requestOptions, 'GET'); |
| 174 | + expect(res).toBeDefined(); |
| 175 | + expect(res.hasBackupCodes).toBe(true); |
| 176 | + expect(res.count).toBe(8); |
| 177 | + sinon.assert.calledOnce(mockBackupCodeManager.getCountForUserId); |
| 178 | + sinon.assert.calledWithExactly( |
| 179 | + mockBackupCodeManager.getCountForUserId, |
| 180 | + UID |
| 181 | + ); |
| 182 | + }); |
| 183 | + |
| 184 | + it('should handle empty response from backupCodeManager', async () => { |
| 185 | + mockBackupCodeManager.getCountForUserId = sandbox.fake.returns({}); |
| 186 | + |
| 187 | + const res = await runTest('/recoveryCodes/exists', requestOptions, 'GET'); |
| 188 | + expect(res.hasBackupCodes).toBeUndefined(); |
| 189 | + expect(res.count).toBeUndefined(); |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + describe('POST /session/verify/recoveryCode', () => { |
| 194 | + it('sends email if backup authentication codes are low', async () => { |
| 195 | + db.consumeRecoveryCode = sandbox.spy((_code: any) => { |
| 196 | + return Promise.resolve({ remaining: 1 }); |
| 197 | + }); |
| 198 | + await runTest('/session/verify/recoveryCode', requestOptions); |
| 199 | + expect(fxaMailer.sendLowRecoveryCodesEmail.callCount).toBe(1); |
| 200 | + const args = fxaMailer.sendLowRecoveryCodesEmail.args[0]; |
| 201 | + expect(args).toHaveLength(1); |
| 202 | + expect(args[0].numberRemaining).toBe(1); |
| 203 | + |
| 204 | + sinon.assert.calledOnceWithExactly( |
| 205 | + mockAccountEventsManager.recordSecurityEvent, |
| 206 | + db, |
| 207 | + { |
| 208 | + name: 'account.recovery_codes_signin_complete', |
| 209 | + uid: 'uid', |
| 210 | + ipAddr: '63.245.221.32', |
| 211 | + tokenId: undefined, |
| 212 | + additionalInfo: { |
| 213 | + userAgent: 'test user-agent', |
| 214 | + location: { |
| 215 | + city: 'Mountain View', |
| 216 | + country: 'United States', |
| 217 | + countryCode: 'US', |
| 218 | + state: 'California', |
| 219 | + stateCode: 'CA', |
| 220 | + }, |
| 221 | + }, |
| 222 | + } |
| 223 | + ); |
| 224 | + }); |
| 225 | + |
| 226 | + it('should rate-limit attempts to use a backup authentication code via customs', async () => { |
| 227 | + requestOptions.payload.code = '1234567890'; |
| 228 | + db.consumeRecoveryCode = sandbox.spy((_code: any) => { |
| 229 | + throw error.recoveryCodeNotFound(); |
| 230 | + }); |
| 231 | + try { |
| 232 | + await runTest('/session/verify/recoveryCode', requestOptions); |
| 233 | + throw new Error('should have thrown'); |
| 234 | + } catch (err: any) { |
| 235 | + expect(err.errno).toBe(error.ERRNO.RECOVERY_CODE_NOT_FOUND); |
| 236 | + sinon.assert.calledWithExactly( |
| 237 | + customs.checkAuthenticated, |
| 238 | + request, |
| 239 | + UID, |
| 240 | + TEST_EMAIL, |
| 241 | + 'verifyRecoveryCode' |
| 242 | + ); |
| 243 | + } |
| 244 | + }); |
| 245 | + |
| 246 | + it('should emit a glean event on successful verification', async () => { |
| 247 | + db.consumeRecoveryCode = sandbox.spy((_code: any) => { |
| 248 | + return Promise.resolve({ remaining: 4 }); |
| 249 | + }); |
| 250 | + await runTest('/session/verify/recoveryCode', requestOptions); |
| 251 | + sinon.assert.calledOnceWithExactly( |
| 252 | + glean.login.recoveryCodeSuccess, |
| 253 | + request, |
| 254 | + { uid: UID } |
| 255 | + ); |
| 256 | + }); |
| 257 | + |
| 258 | + it('should emit the flow complete event', async () => { |
| 259 | + db.consumeRecoveryCode = sandbox.spy((_code: any) => { |
| 260 | + return Promise.resolve({ remaining: 4 }); |
| 261 | + }); |
| 262 | + await runTest('/session/verify/recoveryCode', requestOptions); |
| 263 | + sinon.assert.calledTwice(request.emitMetricsEvent); |
| 264 | + sinon.assert.calledWith(request.emitMetricsEvent, 'account.confirmed', { |
| 265 | + uid: UID, |
| 266 | + }); |
| 267 | + }); |
| 268 | + }); |
| 269 | +}); |
0 commit comments