Skip to content

Commit ca7c9fe

Browse files
committed
wip
1 parent ec1df80 commit ca7c9fe

5 files changed

Lines changed: 225 additions & 0 deletions

File tree

packages/functional-tests/pages/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { RelierPage } from './relier';
2525
import { ResetPasswordPage } from './resetPassword';
2626
import { SecondaryEmailPage } from './settings/secondaryEmail';
2727
import { SettingsPage } from './settings';
28+
import { SettingsPasskeyAddPage } from './settings/passkey';
2829
import { SigninPage } from './signin';
2930
import { SigninRecoveryChoicePage } from './signinRecoveryChoice';
3031
import { SigninRecoveryPhonePage } from './signinRecoveryPhone';
@@ -69,6 +70,7 @@ export function create(page: Page, target: BaseTarget) {
6970
resetPassword: new ResetPasswordPage(page, target),
7071
secondaryEmail: new SecondaryEmailPage(page, target),
7172
settings: new SettingsPage(page, target),
73+
settingsPasskeyAdd: new SettingsPasskeyAddPage(page, target),
7274
signin: new SigninPage(page, target),
7375
signinRecoveryChoice: new SigninRecoveryChoicePage(page, target),
7476
signinRecoveryPhone: new SigninRecoveryPhonePage(page, target),

packages/functional-tests/pages/settings/components/unitRow.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ export class RecoveryKeyRow extends UnitRow {
8585
}
8686
}
8787

88+
export class PasskeyRow extends UnitRow {
89+
get createButton() {
90+
return this.page.getByTestId('passkey-unit-row-route');
91+
}
92+
93+
get subRow() {
94+
return this.page.getByTestId('passkey-sub-row');
95+
}
96+
97+
get deleteButton() {
98+
return this.page.getByRole('button', { name: 'Delete passkey' });
99+
}
100+
101+
get deleteModalHeading() {
102+
return this.page.getByRole('heading', { name: 'Delete your passkey?' });
103+
}
104+
105+
get confirmDeleteButton() {
106+
return this.page.getByTestId('confirm-delete-passkey-button');
107+
}
108+
}
109+
88110
export class TotpRow extends UnitRow {
89111
get addButton() {
90112
return this.page.getByTestId('two-step-unit-row-modal-button');

packages/functional-tests/pages/settings/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ConnectedServicesRow,
99
DataCollectionRow,
1010
DisplayNameRow,
11+
PasskeyRow,
1112
PasswordRow,
1213
PrimaryEmailRow,
1314
RecoveryKeyRow,
@@ -59,6 +60,10 @@ export class SettingsPage extends SettingsLayout {
5960
return this.lazyRow('two-step', TotpRow);
6061
}
6162

63+
get passkey() {
64+
return this.lazyRow('passkey', PasskeyRow);
65+
}
66+
6267
get connectedServiceName() {
6368
return this.page.getByTestId('service-name');
6469
}
@@ -152,4 +157,14 @@ export class SettingsPage extends SettingsLayout {
152157
.fill(code);
153158
await this.page.getByRole('button', { name: 'Confirm' }).click();
154159
}
160+
161+
/**
162+
* Confirms the MFA guard only if the modal is currently displayed. Safe to
163+
* call when the cached JWT already satisfies the guard and no modal appears.
164+
*/
165+
async confirmMfaGuardIfVisible(email: string) {
166+
if (await this.isMfaGuardVisible()) {
167+
await this.confirmMfaGuard(email);
168+
}
169+
}
155170
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 { PasskeyPage } from '../passkey';
6+
7+
/**
8+
* Page object for the `/settings/passkeys/add` page, which auto-starts a
9+
* WebAuthn registration ceremony on mount. Extends PasskeyPage to pick up the
10+
* virtual-authenticator lifecycle (init/cleanup) and `passkeyAuth` getter.
11+
*/
12+
export class SettingsPasskeyAddPage extends PasskeyPage {
13+
readonly path = 'settings/passkeys/add';
14+
15+
get pageContainer() {
16+
return this.page.getByTestId('page-passkey-add');
17+
}
18+
19+
get creatingHeading() {
20+
return this.page.getByRole('heading', { name: 'Creating passkey…' });
21+
}
22+
23+
get cancelButton() {
24+
return this.page.getByTestId('passkey-add-cancel');
25+
}
26+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
/*
6+
* These tests require the passkey feature flags to be enabled on the content
7+
* server (FEATURE_FLAGS_PASSKEYS_ENABLED=true and
8+
* FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED=true) and passkeys.enabled=true
9+
* on the auth-server. The suite is skipped at runtime if the
10+
* fxa-settings config reports either flag as disabled.
11+
*/
12+
13+
import { Page, expect, test } from '../../lib/fixtures/standard';
14+
import { BaseTarget, Credentials } from '../../lib/targets/base';
15+
import { TestAccountTracker } from '../../lib/testAccountTracker';
16+
import { SettingsPage } from '../../pages/settings';
17+
import { SigninPage } from '../../pages/signin';
18+
19+
test.describe('severity-1 #smoke', () => {
20+
test.describe('passkey registration', () => {
21+
test.beforeEach(async ({ pages: { configPage } }) => {
22+
const config = await configPage.getConfig();
23+
test.skip(
24+
!config.featureFlags?.passkeysEnabled ||
25+
!config.featureFlags?.passkeyRegistrationEnabled,
26+
'Passkey feature flags are not enabled'
27+
);
28+
});
29+
30+
test('registers a new passkey', async ({
31+
target,
32+
pages: { page, settings, settingsPasskeyAdd, signin },
33+
testAccountTracker,
34+
}) => {
35+
const { email } = await signInAccount(
36+
target,
37+
page,
38+
settings,
39+
signin,
40+
testAccountTracker
41+
);
42+
43+
await settings.goto();
44+
45+
await expect(settings.settingsHeading).toBeVisible();
46+
await expect(settings.passkey.status).toHaveText('Not set');
47+
48+
await settingsPasskeyAdd.initPasskeys(page);
49+
50+
await settingsPasskeyAdd.passkeyAuth.success(async () => {
51+
await settings.passkey.createButton.click();
52+
await settings.confirmMfaGuard(email);
53+
});
54+
55+
await expect(settings.settingsHeading).toBeVisible();
56+
await expect(settings.alertBar).toHaveText('Passkey created');
57+
await expect(settings.passkey.status).toHaveText('Enabled');
58+
await expect(settings.passkey.subRow).toBeVisible();
59+
60+
const credentials = await settingsPasskeyAdd.passkeyAuth.getCredentials();
61+
expect(credentials).toHaveLength(1);
62+
});
63+
64+
test('cancels passkey registration', async ({
65+
target,
66+
pages: { page, settings, settingsPasskeyAdd, signin },
67+
testAccountTracker,
68+
}) => {
69+
const { email } = await signInAccount(
70+
target,
71+
page,
72+
settings,
73+
signin,
74+
testAccountTracker
75+
);
76+
77+
await settings.goto();
78+
await expect(settings.settingsHeading).toBeVisible();
79+
80+
// Initialize the virtual authenticator but do NOT enable presence
81+
// simulation, so the WebAuthn ceremony hangs waiting for user input.
82+
// This lets us exercise the Cancel button while the ceremony is in
83+
// flight.
84+
await settingsPasskeyAdd.initPasskeys(page);
85+
86+
await settings.passkey.createButton.click();
87+
await settings.confirmMfaGuard(email);
88+
89+
await expect(settingsPasskeyAdd.pageContainer).toBeVisible();
90+
await expect(settingsPasskeyAdd.creatingHeading).toBeVisible();
91+
92+
await settingsPasskeyAdd.cancelButton.click();
93+
94+
await expect(settings.settingsHeading).toBeVisible();
95+
await expect(settings.passkey.status).toHaveText('Not set');
96+
await expect(settings.passkey.subRow).toHaveCount(0);
97+
98+
const credentials = await settingsPasskeyAdd.passkeyAuth.getCredentials();
99+
expect(credentials).toHaveLength(0);
100+
});
101+
102+
test('deletes a registered passkey', async ({
103+
target,
104+
pages: { page, settings, settingsPasskeyAdd, signin },
105+
testAccountTracker,
106+
}) => {
107+
const { email } = await signInAccount(
108+
target,
109+
page,
110+
settings,
111+
signin,
112+
testAccountTracker
113+
);
114+
115+
await settings.goto();
116+
await expect(settings.settingsHeading).toBeVisible();
117+
await expect(settings.passkey.status).toHaveText('Not set');
118+
119+
await settingsPasskeyAdd.initPasskeys(page);
120+
121+
await settingsPasskeyAdd.passkeyAuth.success(async () => {
122+
await settings.passkey.createButton.click();
123+
await settings.confirmMfaGuard(email);
124+
});
125+
126+
await expect(settings.passkey.status).toHaveText('Enabled');
127+
128+
await settings.passkey.deleteButton.click();
129+
await expect(settings.passkey.deleteModalHeading).toBeVisible();
130+
await settings.passkey.confirmDeleteButton.click();
131+
132+
// The delete action is guarded by MfaGuard (scope: passkey); the
133+
// cached JWT from registration usually satisfies it, but re-confirm
134+
// if the modal appears.
135+
await settings.confirmMfaGuardIfVisible(email);
136+
137+
await expect(settings.settingsHeading).toBeVisible();
138+
await expect(settings.alertBar).toHaveText('Passkey deleted');
139+
await expect(settings.passkey.status).toHaveText('Not set');
140+
await expect(settings.passkey.subRow).toHaveCount(0);
141+
});
142+
});
143+
});
144+
145+
async function signInAccount(
146+
target: BaseTarget,
147+
page: Page,
148+
settings: SettingsPage,
149+
signin: SigninPage,
150+
testAccountTracker: TestAccountTracker
151+
): Promise<Credentials> {
152+
const credentials = await testAccountTracker.signUp();
153+
await page.goto(target.contentServerUrl);
154+
await signin.fillOutEmailFirstForm(credentials.email);
155+
await signin.fillOutPasswordForm(credentials.password);
156+
await page.waitForURL(/settings/);
157+
await expect(settings.settingsHeading).toBeVisible();
158+
159+
return credentials;
160+
}

0 commit comments

Comments
 (0)