Skip to content

Commit 00bea49

Browse files
committed
test(fxa-auth-server): migrate MFA/recovery Mocha tests to co-located Jest specs (FXA-12612)
1 parent 48c9dc7 commit 00bea49

5 files changed

Lines changed: 2967 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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

Comments
 (0)