Skip to content

Commit 1a59f7b

Browse files
authored
Merge pull request #19453 from mozilla/FXA-12228
feat(mfa): Wrap delete secondary email in MfaGuard
2 parents bae1e9c + 096db6e commit 1a59f7b

7 files changed

Lines changed: 266 additions & 51 deletions

File tree

packages/fxa-auth-client/lib/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,14 @@ export default class AuthClient {
17051705
);
17061706
}
17071707

1708+
async recoveryEmailDestroyWithJwt(
1709+
jwt: string,
1710+
email: string,
1711+
headers?: Headers
1712+
) {
1713+
return this.jwtPost('/mfa/recovery_email/destroy', jwt, { email }, headers);
1714+
}
1715+
17081716
async recoveryEmailSetPrimaryEmail(
17091717
sessionToken: hexstring,
17101718
email: string,

packages/fxa-auth-server/docs/swagger/emails-api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,36 @@ const RECOVERY_EMAIL_DESTROY_POST = {
156156
},
157157
};
158158

159+
const MFA_RECOVERY_EMAIL_DESTROY_POST = {
160+
...TAGS_EMAILS,
161+
description: '/mfa/recovery_email/destroy',
162+
notes: [
163+
dedent`
164+
🔒 Authenticated with session MFA JWT (scope: mfa:email)
165+
166+
Delete an email address associated with the logged-in user.
167+
`,
168+
],
169+
plugins: {
170+
'hapi-swagger': {
171+
responses: {
172+
400: {
173+
description: dedent`
174+
Failing requests may be caused by the following errors (this is not an exhaustive list):
175+
- \`errno: 138\` - Unverified session
176+
`,
177+
},
178+
401: {
179+
description: dedent`
180+
Failing requests may be caused by the following errors (this is not an exhaustive list):
181+
- \`errno: 110\` - Invalid authentication token in request signature
182+
`,
183+
},
184+
},
185+
},
186+
},
187+
};
188+
159189
const RECOVERY_EMAIL_SET_PRIMARY_POST = {
160190
...TAGS_EMAILS,
161191
description: '/recovery_email/set_primary',
@@ -235,6 +265,7 @@ const RECOVERY_EMAIL_SECONDARY_VERIFY_CODE_POST = {
235265
const API_DOCS = {
236266
EMAILS_REMINDERS_CAD_POST,
237267
RECOVERY_EMAIL_DESTROY_POST,
268+
MFA_RECOVERY_EMAIL_DESTROY_POST,
238269
RECOVERY_EMAIL_POST,
239270
RECOVERY_EMAIL_RESEND_CODE_POST,
240271
RECOVERY_EMAIL_SECONDARY_RESEND_CODE_POST,

packages/fxa-auth-server/lib/routes/emails.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ module.exports = (
299299
},
300300
};
301301

302-
return [
302+
const routes = [
303303
{
304304
method: 'GET',
305305
path: '/recovery_email/status',
@@ -860,6 +860,35 @@ module.exports = (
860860
return {};
861861
},
862862
},
863+
{
864+
method: 'POST',
865+
path: '/mfa/recovery_email/destroy',
866+
options: {
867+
...EMAILS_DOCS.MFA_RECOVERY_EMAIL_DESTROY_POST,
868+
auth: {
869+
strategy: 'mfa',
870+
scope: ['mfa:email'],
871+
payload: false,
872+
},
873+
validate: {
874+
payload: isA.object({
875+
email: validators
876+
.email()
877+
.required()
878+
.description(DESCRIPTION.emailDelete),
879+
}),
880+
},
881+
response: {},
882+
},
883+
handler: async function (request) {
884+
return routes
885+
.find(
886+
(r) =>
887+
r.path === '/v1/recovery_email/destroy' && r.method === 'POST'
888+
)
889+
.handler(request);
890+
},
891+
},
863892
{
864893
method: 'POST',
865894
path: '/recovery_email/set_primary',
@@ -1152,6 +1181,8 @@ module.exports = (
11521181
},
11531182
},
11541183
];
1184+
1185+
return routes;
11551186
};
11561187

11571188
// Exported for testing purposes.

packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.test.tsx

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,29 @@
44

55
import 'mutationobserver-shim';
66
import React from 'react';
7-
import { screen, fireEvent, act } from '@testing-library/react';
7+
import { screen, fireEvent, act, waitFor } from '@testing-library/react';
88
import {
99
renderWithRouter,
1010
mockEmail,
1111
mockAppContext,
1212
mockSettingsContext,
1313
} from '../../../models/mocks';
14+
import { createMockAccount } from './mocks';
1415
import { UnitRowSecondaryEmail } from '.';
1516
import { Account, AppContext } from '../../../models';
1617
import { SettingsContext } from '../../../models/contexts/SettingsContext';
18+
import { JwtTokenCache } from '../../../lib/cache';
19+
import AuthClient from 'fxa-auth-client/lib/client';
20+
import userEvent, { UserEvent } from '@testing-library/user-event';
21+
22+
const mockSessionToken = 'you-get-a-session-token';
23+
const mockJwt = 'and-you-get-a-jwt';
24+
25+
jest.mock('../../../lib/cache', () => ({
26+
__esModule: true,
27+
...jest.requireActual('../../../lib/cache'),
28+
sessionToken: () => mockSessionToken,
29+
}));
1730

1831
const account = {
1932
emails: [mockEmail(), mockEmail('[email protected]', false, false)],
@@ -308,46 +321,81 @@ describe('UnitRowSecondaryEmail', () => {
308321
});
309322

310323
describe('deleteSecondaryEmail', () => {
324+
let user: UserEvent;
325+
const mockAuthClient = new AuthClient('http://localhost:9000');
326+
327+
const setupMockAuthClient = () => {
328+
mockAuthClient.mfaRequestOtp = jest
329+
.fn()
330+
.mockResolvedValue({ code: 200, errno: 0 });
331+
mockAuthClient.mfaOtpVerify = jest
332+
.fn()
333+
.mockResolvedValue({ accessToken: mockJwt });
334+
};
335+
336+
const resetJwtCache = () => {
337+
if (JwtTokenCache.hasToken(mockSessionToken, 'email')) {
338+
JwtTokenCache.removeToken(mockSessionToken, 'email');
339+
}
340+
};
341+
342+
beforeEach(() => {
343+
jest.clearAllMocks();
344+
resetJwtCache();
345+
setupMockAuthClient();
346+
user = userEvent.setup();
347+
});
348+
/**
349+
* This test case also implicitly covers that the MfaGuard is not displayed
350+
* on success, so no need for a separate test for that specific case.
351+
*/
311352
it('displays a success message in the AlertBar', async () => {
312-
const primaryEmail = mockEmail('[email protected]');
353+
JwtTokenCache.setToken(mockSessionToken, 'email', mockJwt);
354+
313355
const emails = [
314-
{ ...primaryEmail },
356+
mockEmail('[email protected]'),
315357
mockEmail('[email protected]', false, false),
316358
];
317-
const account = {
359+
const account = createMockAccount({
360+
deleteSecondaryEmailWithJwt: jest.fn().mockResolvedValue(true),
318361
emails,
319-
deleteSecondaryEmail: jest.fn().mockResolvedValue(true),
320-
} as unknown as Account;
321-
const context = mockAppContext({ account });
362+
});
363+
364+
const appCtx = mockAppContext({
365+
authClient: mockAuthClient as any,
366+
account,
367+
});
322368
const settingsContext = mockSettingsContext();
323369
renderWithRouter(
324-
<AppContext.Provider value={context}>
370+
<AppContext.Provider value={appCtx}>
325371
<SettingsContext.Provider value={settingsContext}>
326372
<UnitRowSecondaryEmail />
327373
</SettingsContext.Provider>
328374
</AppContext.Provider>
329375
);
330376

331-
await act(async () => {
332-
fireEvent.click(screen.getByTestId('secondary-email-delete'));
333-
});
377+
await user.click(screen.getByTestId('secondary-email-delete'));
334378

335-
expect(settingsContext.alertBarInfo?.success).toHaveBeenCalledTimes(1);
336-
expect(settingsContext.alertBarInfo?.success).toHaveBeenCalledWith(
337-
'[email protected] successfully deleted'
379+
await waitFor(() =>
380+
expect(settingsContext.alertBarInfo?.success).toHaveBeenCalledTimes(1)
381+
);
382+
await waitFor(() =>
383+
expect(settingsContext.alertBarInfo?.success).toHaveBeenCalledWith(
384+
'[email protected] successfully deleted'
385+
)
338386
);
339387
});
340388

341389
it('displays an error message in the AlertBar', async () => {
342-
const emails = [
343-
mockEmail(),
344-
mockEmail('[email protected]', false, false),
345-
];
346-
const account = {
347-
emails,
348-
deleteSecondaryEmail: jest.fn().mockRejectedValue(new Error()),
349-
} as unknown as Account;
350-
const context = mockAppContext({ account });
390+
JwtTokenCache.setToken(mockSessionToken, 'email', mockJwt);
391+
392+
const account = createMockAccount({
393+
deleteSecondaryEmailWithJwt: jest.fn().mockRejectedValue(new Error()),
394+
});
395+
const context = mockAppContext({
396+
authClient: mockAuthClient as any,
397+
account,
398+
});
351399
const settingsContext = mockSettingsContext();
352400
renderWithRouter(
353401
<AppContext.Provider value={context}>
@@ -362,5 +410,36 @@ describe('UnitRowSecondaryEmail', () => {
362410
});
363411
expect(settingsContext.alertBarInfo?.error).toHaveBeenCalledTimes(1);
364412
});
413+
414+
it('displays the MFA guard if the JWT is invalid', async () => {
415+
JwtTokenCache.setToken(mockSessionToken, 'email', 'invalid-jwt');
416+
417+
const account = createMockAccount({
418+
deleteSecondaryEmailWithJwt: jest
419+
.fn()
420+
.mockRejectedValue({ code: 401, errno: 110 }),
421+
});
422+
423+
const context = mockAppContext({
424+
authClient: mockAuthClient as any,
425+
account,
426+
});
427+
const settingsContext = mockSettingsContext();
428+
renderWithRouter(
429+
<AppContext.Provider value={context}>
430+
<SettingsContext.Provider value={settingsContext}>
431+
<UnitRowSecondaryEmail />
432+
</SettingsContext.Provider>
433+
</AppContext.Provider>
434+
);
435+
436+
await act(async () => {
437+
fireEvent.click(screen.getByTestId('secondary-email-delete'));
438+
});
439+
440+
expect(
441+
await screen.findByText('Enter confirmation code')
442+
).toBeInTheDocument();
443+
});
365444
});
366445
});

0 commit comments

Comments
 (0)