Skip to content

Commit 8ce913e

Browse files
authored
Merge pull request #20385 from mozilla/FXA-13072
task(settings): passkey registration UI flow
2 parents 474a645 + 2944360 commit 8ce913e

15 files changed

Lines changed: 977 additions & 139 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## PagePasskeyAdd - Loading page shown during passkey creation
2+
3+
page-passkey-add-creating-heading = Creating passkey…
4+
page-passkey-add-follow-prompts = Follow the prompts on your device.
5+
page-passkey-add-cancel = Cancel
6+
7+
## Success / Error messages (shown in alert bar after returning to settings)
8+
9+
page-passkey-add-success = Passkey created
10+
page-passkey-add-error-system = System not available. Try again later.
11+
12+
##
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
import React from 'react';
6+
import { Meta } from '@storybook/react';
7+
import { withLocalization } from 'fxa-react/lib/storybooks';
8+
import { LocationProvider } from '@reach/router';
9+
import { PagePasskeyAdd } from '.';
10+
import { AppContext } from 'fxa-settings/src/models';
11+
import {
12+
mockAppContext,
13+
mockSettingsContext,
14+
} from 'fxa-settings/src/models/mocks';
15+
import { SettingsContext } from 'fxa-settings/src/models/contexts/SettingsContext';
16+
import { MfaContext } from '../MfaGuard';
17+
import { getDefault } from '../../../lib/config';
18+
19+
export default {
20+
title: 'Pages/Settings/PasskeyAdd',
21+
component: PagePasskeyAdd,
22+
decorators: [withLocalization],
23+
} as Meta;
24+
25+
const mockAccount = {
26+
getCachedJwtByScope: () => 'mock-jwt',
27+
} as any;
28+
29+
// Auth client that never resolves — keeps the loading page visible.
30+
const hangingAuthClient = {
31+
beginPasskeyRegistration: () => new Promise(() => {}),
32+
completePasskeyRegistration: () => new Promise(() => {}),
33+
};
34+
35+
function initLocalAccount() {
36+
const NS = '__fxa_storage';
37+
const uid = 'abc123';
38+
const accounts = {
39+
[uid]: {
40+
uid,
41+
sessionToken: 'mock-session-token',
42+
43+
verified: true,
44+
lastLogin: Date.now(),
45+
},
46+
};
47+
window.localStorage.setItem(`${NS}.accounts`, JSON.stringify(accounts));
48+
window.localStorage.setItem(`${NS}.currentAccountUid`, JSON.stringify(uid));
49+
}
50+
51+
const configWithPasskeys = {
52+
...getDefault(),
53+
featureFlags: {
54+
...getDefault().featureFlags,
55+
passkeyRegistrationEnabled: true,
56+
},
57+
};
58+
59+
export const CeremonyInProgress = () => {
60+
initLocalAccount();
61+
return (
62+
<LocationProvider>
63+
<AppContext.Provider
64+
value={mockAppContext({
65+
account: mockAccount,
66+
authClient: hangingAuthClient as any,
67+
config: configWithPasskeys,
68+
})}
69+
>
70+
<SettingsContext.Provider value={mockSettingsContext()}>
71+
<MfaContext.Provider value="passkey">
72+
<PagePasskeyAdd />
73+
</MfaContext.Provider>
74+
</SettingsContext.Provider>
75+
</AppContext.Provider>
76+
</LocationProvider>
77+
);
78+
};
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
import React from 'react';
6+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
7+
import '@testing-library/jest-dom';
8+
9+
import { PagePasskeyAdd } from '.';
10+
import { Account, AppContext } from '../../../models';
11+
import { mockAppContext, mockSettingsContext } from '../../../models/mocks';
12+
import { SettingsContext } from '../../../models/contexts/SettingsContext';
13+
import { MfaContext } from '../MfaGuard';
14+
import {
15+
WebAuthnErrorCategory,
16+
WebAuthnErrorType,
17+
} from '../../../lib/passkeys/webauthn-errors';
18+
19+
// Mock navigate
20+
const mockNavigateWithQuery = jest.fn();
21+
jest.mock('../../../lib/hooks/useNavigateWithQuery', () => ({
22+
useNavigateWithQuery: () => mockNavigateWithQuery,
23+
}));
24+
25+
// Mock LoadingSpinner
26+
jest.mock('fxa-react/components/LoadingSpinner', () => () => (
27+
<div data-testid="loading-spinner">Loading…</div>
28+
));
29+
30+
// Mock Sentry
31+
const mockCaptureException = jest.fn();
32+
jest.mock('@sentry/browser', () => ({
33+
captureException: (...args: unknown[]) => mockCaptureException(...args),
34+
}));
35+
36+
// Mock WebAuthn utilities
37+
const mockCreateCredential = jest.fn();
38+
jest.mock('../../../lib/passkeys/webauthn', () => ({
39+
createCredential: (...args: unknown[]) => mockCreateCredential(...args),
40+
}));
41+
42+
const mockHandleWebAuthnError = jest.fn();
43+
jest.mock('../../../lib/passkeys/webauthn-errors', () => ({
44+
...jest.requireActual('../../../lib/passkeys/webauthn-errors'),
45+
handleWebAuthnError: (...args: unknown[]) => mockHandleWebAuthnError(...args),
46+
}));
47+
48+
// Mock cache
49+
jest.mock('../../../lib/cache', () => ({
50+
...jest.requireActual('../../../lib/cache'),
51+
JwtTokenCache: {
52+
hasToken: jest.fn(() => true),
53+
getToken: jest.fn(() => 'mock-jwt'),
54+
subscribe: jest.fn(() => () => {}),
55+
getSnapshot: jest.fn(() => ({})),
56+
getKey: jest.fn(() => 'key'),
57+
},
58+
sessionToken: jest.fn(() => 'session-123'),
59+
}));
60+
61+
// Mock auth client methods
62+
const mockBeginPasskeyRegistration = jest.fn();
63+
const mockCompletePasskeyRegistration = jest.fn();
64+
65+
const mockAlertSuccess = jest.fn();
66+
const mockAlertError = jest.fn();
67+
68+
const mockCreationOptions = {
69+
rp: { name: 'Mozilla', id: 'accounts.firefox.com' },
70+
user: { id: 'dXNlcg', name: '[email protected]', displayName: 'Test' },
71+
challenge: 'Y2hhbGxlbmdl',
72+
pubKeyCredParams: [{ alg: -7, type: 'public-key' as const }],
73+
};
74+
75+
const mockCredential = {
76+
id: 'Y3JlZA',
77+
rawId: 'Y3JlZA',
78+
type: 'public-key' as const,
79+
response: {
80+
clientDataJSON: 'ZXlK',
81+
attestationObject: 'bzJO',
82+
},
83+
};
84+
85+
function renderPage() {
86+
const account = {
87+
getCachedJwtByScope: jest.fn(() => 'mock-jwt'),
88+
} as unknown as Account;
89+
90+
const authClient = {
91+
beginPasskeyRegistration: mockBeginPasskeyRegistration,
92+
completePasskeyRegistration: mockCompletePasskeyRegistration,
93+
};
94+
95+
const alertBarInfo = {
96+
success: mockAlertSuccess,
97+
error: mockAlertError,
98+
info: jest.fn(),
99+
show: jest.fn(),
100+
hide: jest.fn(),
101+
setType: jest.fn(),
102+
setContent: jest.fn(),
103+
visible: false,
104+
type: 'success' as const,
105+
content: null,
106+
};
107+
108+
return render(
109+
<AppContext.Provider
110+
value={mockAppContext({
111+
account,
112+
authClient: authClient as any,
113+
})}
114+
>
115+
<SettingsContext.Provider
116+
value={mockSettingsContext({
117+
alertBarInfo: alertBarInfo as any,
118+
})}
119+
>
120+
<MfaContext.Provider value="passkey">
121+
<PagePasskeyAdd />
122+
</MfaContext.Provider>
123+
</SettingsContext.Provider>
124+
</AppContext.Provider>
125+
);
126+
}
127+
128+
describe('PagePasskeyAdd', () => {
129+
beforeEach(() => {
130+
jest.clearAllMocks();
131+
mockBeginPasskeyRegistration.mockResolvedValue(mockCreationOptions);
132+
mockCreateCredential.mockResolvedValue(mockCredential);
133+
mockCompletePasskeyRegistration.mockResolvedValue({
134+
credentialId: 'cred-1',
135+
name: 'Test Passkey',
136+
createdAt: Date.now(),
137+
lastUsedAt: null,
138+
transports: ['internal'],
139+
aaguid: 'aaguid-1',
140+
backupEligible: true,
141+
backupState: false,
142+
prfEnabled: false,
143+
});
144+
});
145+
146+
it('shows loading modal on mount', () => {
147+
// Make the ceremony hang so we can see the modal
148+
mockBeginPasskeyRegistration.mockReturnValue(new Promise(() => {}));
149+
renderPage();
150+
expect(screen.getByTestId('page-passkey-add')).toBeInTheDocument();
151+
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
152+
expect(screen.getByText('Creating passkey…')).toBeInTheDocument();
153+
expect(
154+
screen.getByText('Follow the prompts on your device.')
155+
).toBeInTheDocument();
156+
expect(screen.getByTestId('passkey-add-cancel')).toBeInTheDocument();
157+
});
158+
159+
it('completes ceremony and shows success alert', async () => {
160+
renderPage();
161+
await waitFor(() => {
162+
expect(mockAlertSuccess).toHaveBeenCalledWith('Passkey created');
163+
});
164+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/settings#security', {
165+
replace: true,
166+
});
167+
expect(mockBeginPasskeyRegistration).toHaveBeenCalledWith('mock-jwt');
168+
expect(mockCreateCredential).toHaveBeenCalledWith(mockCreationOptions);
169+
expect(mockCompletePasskeyRegistration).toHaveBeenCalledWith(
170+
'mock-jwt',
171+
mockCredential,
172+
'Y2hhbGxlbmdl'
173+
);
174+
});
175+
176+
it('handles user cancel (NotAllowedError)', async () => {
177+
const cancelError = new DOMException('User cancelled', 'NotAllowedError');
178+
mockCreateCredential.mockRejectedValue(cancelError);
179+
mockHandleWebAuthnError.mockReturnValue({
180+
category: WebAuthnErrorCategory.UserAction,
181+
errorType: WebAuthnErrorType.NotAllowed,
182+
userMessageKey: 'passkey-registration-error-not-allowed',
183+
logToSentry: false,
184+
});
185+
186+
renderPage();
187+
await waitFor(() => {
188+
expect(mockAlertError).toHaveBeenCalled();
189+
});
190+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/settings#security', {
191+
replace: true,
192+
});
193+
});
194+
195+
it('handles timeout error', async () => {
196+
const timeoutError = new DOMException('Timeout', 'TimeoutError');
197+
mockCreateCredential.mockRejectedValue(timeoutError);
198+
mockHandleWebAuthnError.mockReturnValue({
199+
category: WebAuthnErrorCategory.UserAction,
200+
errorType: WebAuthnErrorType.Timeout,
201+
userMessageKey: 'passkey-registration-error-timeout',
202+
logToSentry: false,
203+
});
204+
205+
renderPage();
206+
await waitFor(() => {
207+
expect(mockAlertError).toHaveBeenCalled();
208+
});
209+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/settings#security', {
210+
replace: true,
211+
});
212+
});
213+
214+
it('handles server error on beginPasskeyRegistration', async () => {
215+
const serverError = new Error('Server error');
216+
mockBeginPasskeyRegistration.mockRejectedValue(serverError);
217+
218+
renderPage();
219+
await waitFor(() => {
220+
expect(mockAlertError).toHaveBeenCalledWith(
221+
'System not available. Try again later.'
222+
);
223+
});
224+
expect(mockCaptureException).toHaveBeenCalledWith(serverError);
225+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/settings#security', {
226+
replace: true,
227+
});
228+
});
229+
230+
it('handles server error on completePasskeyRegistration', async () => {
231+
const serverError = new Error('Completion failed');
232+
mockCompletePasskeyRegistration.mockRejectedValue(serverError);
233+
234+
renderPage();
235+
await waitFor(() => {
236+
expect(mockAlertError).toHaveBeenCalledWith(
237+
'System not available. Try again later.'
238+
);
239+
});
240+
expect(mockCaptureException).toHaveBeenCalledWith(serverError);
241+
});
242+
243+
it('cancel button navigates back to settings', () => {
244+
mockBeginPasskeyRegistration.mockReturnValue(new Promise(() => {}));
245+
renderPage();
246+
fireEvent.click(screen.getByTestId('passkey-add-cancel'));
247+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/settings#security', {
248+
replace: true,
249+
});
250+
});
251+
});

0 commit comments

Comments
 (0)