Skip to content

Commit 3e3129f

Browse files
Merge pull request #19429 from mozilla/FXA-12355
feat(mfa): handle unexpected error states and resend code success
2 parents 5d37523 + 4c5edac commit 3e3129f

2 files changed

Lines changed: 185 additions & 75 deletions

File tree

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

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import React from 'react';
6-
import { screen } from '@testing-library/react';
6+
import { screen, waitFor } from '@testing-library/react';
77
import userEvent from '@testing-library/user-event';
8-
import { renderWithRouter } from '../../../models/mocks';
8+
import { mockAppContext, renderWithRouter } from '../../../models/mocks';
99
import { MfaGuard } from './index';
1010
import { JwtTokenCache } from '../../../lib/cache';
1111
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
12+
import { AppContext } from '../../../models';
1213

1314
const mockSessionToken = 'session-xyz';
1415
const mockOtp = '123456';
@@ -22,9 +23,8 @@ const mockAuthClient = {
2223
return Promise.reject(AuthUiErrors.INVALID_EXPIRED_OTP_CODE);
2324
}),
2425
};
25-
const mockFtlMsgResolver = {
26-
getMsg: (id: string, fallback: string) => fallback,
27-
};
26+
const mockAlertBar = { error: jest.fn() };
27+
const mockNavigate = jest.fn();
2828

2929
jest.mock('../../../lib/cache', () => {
3030
const actual = jest.requireActual('../../../lib/cache');
@@ -36,11 +36,24 @@ jest.mock('../../../lib/cache', () => {
3636
});
3737

3838
jest.mock('../../../models', () => ({
39-
useAccount: () => ({ email: '[email protected]' }),
39+
...jest.requireActual('../../../models'),
4040
useAuthClient: () => mockAuthClient,
41-
useFtlMsgResolver: () => mockFtlMsgResolver,
41+
useAlertBar: () => mockAlertBar,
42+
}));
43+
44+
jest.mock('@reach/router', () => ({
45+
...jest.requireActual('@reach/router'),
46+
useNavigate: () => mockNavigate,
4247
}));
4348

49+
async function submitCode(otp: string = mockOtp) {
50+
await userEvent.type(
51+
screen.getByRole('textbox', { name: 'Enter 6-digit code' }),
52+
otp
53+
);
54+
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
55+
}
56+
4457
describe('MfaGuard', () => {
4558
beforeEach(() => {
4659
if (JwtTokenCache.hasToken(mockSessionToken, mockScope)) {
@@ -51,9 +64,11 @@ describe('MfaGuard', () => {
5164

5265
it('requests OTP and shows modal when JWT missing', async () => {
5366
renderWithRouter(
54-
<MfaGuard requiredScope={mockScope}>
55-
<div>secured</div>
56-
</MfaGuard>
67+
<AppContext.Provider value={mockAppContext()}>
68+
<MfaGuard requiredScope={mockScope}>
69+
<div>secured</div>
70+
</MfaGuard>
71+
</AppContext.Provider>
5772
);
5873

5974
expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalledWith(
@@ -65,12 +80,7 @@ describe('MfaGuard', () => {
6580
await screen.findByText('Enter confirmation code')
6681
).toBeInTheDocument();
6782

68-
// Submit a code to verify integration with onSubmit
69-
await userEvent.type(
70-
screen.getByRole('textbox', { name: 'Enter 6-digit code' }),
71-
mockOtp
72-
);
73-
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
83+
await submitCode();
7484

7585
expect(mockAuthClient.mfaOtpVerify).toHaveBeenCalledWith(
7686
mockSessionToken,
@@ -83,9 +93,11 @@ describe('MfaGuard', () => {
8393
JwtTokenCache.setToken(mockSessionToken, mockScope, 'jwt-present');
8494

8595
renderWithRouter(
86-
<MfaGuard requiredScope={mockScope}>
87-
<div>secured</div>
88-
</MfaGuard>
96+
<AppContext.Provider value={mockAppContext()}>
97+
<MfaGuard requiredScope={mockScope}>
98+
<div>secured</div>
99+
</MfaGuard>
100+
</AppContext.Provider>
89101
);
90102

91103
expect(screen.getByText('secured')).toBeInTheDocument();
@@ -97,17 +109,15 @@ describe('MfaGuard', () => {
97109

98110
it('shows error banner on invalid OTP', async () => {
99111
renderWithRouter(
100-
<MfaGuard requiredScope={mockScope}>
101-
<div>secured</div>
102-
</MfaGuard>
112+
<AppContext.Provider value={mockAppContext()}>
113+
<MfaGuard requiredScope={mockScope}>
114+
<div>secured</div>
115+
</MfaGuard>
116+
</AppContext.Provider>
103117
);
104118

105119
expect(screen.queryByText('Enter confirmation code')).toBeInTheDocument();
106-
await userEvent.type(
107-
screen.getByRole('textbox', { name: 'Enter 6-digit code' }),
108-
'654321'
109-
);
110-
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
120+
await submitCode('654321');
111121

112122
expect(
113123
await screen.findByText('Invalid or expired confirmation code')
@@ -116,26 +126,98 @@ describe('MfaGuard', () => {
116126

117127
it('clears error banner on input change', async () => {
118128
renderWithRouter(
119-
<MfaGuard requiredScope={mockScope}>
120-
<div>secured</div>
121-
</MfaGuard>
129+
<AppContext.Provider value={mockAppContext()}>
130+
<MfaGuard requiredScope={mockScope}>
131+
<div>secured</div>
132+
</MfaGuard>
133+
</AppContext.Provider>
122134
);
123135

124136
expect(screen.getByText('Enter confirmation code')).toBeInTheDocument();
137+
await submitCode('654321');
138+
expect(
139+
await screen.findByText('Invalid or expired confirmation code')
140+
).toBeInTheDocument();
141+
142+
await userEvent.clear(screen.getByRole('textbox'));
143+
144+
expect(
145+
screen.queryByText('Invalid or expired confirmation code')
146+
).not.toBeInTheDocument();
147+
});
148+
149+
it('shows resend success banner and hides error banner on resend success', async () => {
150+
renderWithRouter(
151+
<AppContext.Provider value={mockAppContext()}>
152+
<MfaGuard requiredScope={mockScope}>
153+
<div>secured</div>
154+
</MfaGuard>
155+
</AppContext.Provider>
156+
);
157+
158+
// Trigger an error first
125159
await userEvent.type(
126160
screen.getByRole('textbox', { name: 'Enter 6-digit code' }),
127161
'654321'
128162
);
129163
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
130-
131164
expect(
132165
await screen.findByText('Invalid or expired confirmation code')
133166
).toBeInTheDocument();
134167

135-
await userEvent.clear(screen.getByRole('textbox'));
136-
168+
await userEvent.click(
169+
screen.getByRole('button', { name: 'Email new code.' })
170+
);
171+
expect(
172+
await screen.findByText('A new code was sent to your email.')
173+
).toBeInTheDocument();
137174
expect(
138175
screen.queryByText('Invalid or expired confirmation code')
139176
).not.toBeInTheDocument();
140177
});
178+
179+
it('shows error banner and hide success banner on resend error', async () => {
180+
renderWithRouter(
181+
<AppContext.Provider value={mockAppContext()}>
182+
<MfaGuard requiredScope={mockScope}>
183+
<div>secured</div>
184+
</MfaGuard>
185+
</AppContext.Provider>
186+
);
187+
188+
await userEvent.click(
189+
screen.getByRole('button', { name: 'Email new code.' })
190+
);
191+
expect(
192+
await screen.findByText('A new code was sent to your email.')
193+
).toBeInTheDocument();
194+
195+
mockAuthClient.mfaRequestOtp.mockRejectedValueOnce(
196+
AuthUiErrors.UNEXPECTED_ERROR
197+
);
198+
199+
await userEvent.click(
200+
screen.getByRole('button', { name: 'Email new code.' })
201+
);
202+
expect(await screen.findByText('Unexpected error')).toBeInTheDocument();
203+
});
204+
205+
it('goes home and shows error alert bar if request for OTP fails', async () => {
206+
mockAuthClient.mfaRequestOtp.mockRejectedValueOnce(
207+
AuthUiErrors.UNEXPECTED_ERROR
208+
);
209+
210+
renderWithRouter(
211+
<AppContext.Provider value={mockAppContext()}>
212+
<MfaGuard requiredScope={mockScope}>
213+
<div>secured</div>
214+
</MfaGuard>
215+
</AppContext.Provider>
216+
);
217+
218+
await waitFor(() => {
219+
expect(mockNavigate).toHaveBeenCalledWith('/settings');
220+
expect(mockAlertBar.error).toHaveBeenCalledWith('Unexpected error');
221+
});
222+
});
141223
});

0 commit comments

Comments
 (0)