Skip to content

Commit 6e60d18

Browse files
committed
test(e2e): Add passkey/WebAuthn e2e tests
1 parent 6b411b1 commit 6e60d18

3 files changed

Lines changed: 200 additions & 0 deletions

File tree

integration/presets/envs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ const withNeedsClientTrust = base
214214
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-needs-client-trust').sk)
215215
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-needs-client-trust').pk);
216216

217+
const withPasskeys = base
218+
.clone()
219+
.setId('withPasskeys')
220+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-passkeys').sk)
221+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-passkeys').pk);
222+
217223
export const envs = {
218224
base,
219225
sessionsProd1,
@@ -233,6 +239,7 @@ export const envs = {
233239
withKeyless,
234240
withLegalConsent,
235241
withNeedsClientTrust,
242+
withPasskeys,
236243
withRestrictedMode,
237244
withReverification,
238245
withSessionTasks,

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const createLongRunningApps = () => {
7474
{ id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes },
7575
{ id: 'tanstack.react-start.withCustomRoles', config: tanstack.reactStart, env: envs.withCustomRoles },
7676
{ id: 'tanstack.react-start.withEmailCodesProxy', config: tanstack.reactStart, env: envs.withEmailCodesProxy },
77+
{ id: 'tanstack.react-start.withPasskeys', config: tanstack.reactStart, env: envs.withPasskeys },
7778

7879
/**
7980
* Various apps - basic flows
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { CDPSession } from '@playwright/test';
2+
import { expect, test } from '@playwright/test';
3+
4+
import { appConfigs } from '../../presets';
5+
import type { FakeUser } from '../../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
7+
8+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withPasskeys] })('passkeys @tanstack-react-start', ({ app }) => {
9+
test.describe.configure({ mode: 'serial' });
10+
11+
let fakeUser: FakeUser;
12+
let savedCredentials: any[] = [];
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser();
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
const setupVirtualAuthenticator = async (page: any): Promise<{ cdpSession: CDPSession; authenticatorId: string }> => {
26+
// Clerk's isValidBrowser() checks !navigator.webdriver, which is true in Playwright.
27+
// Override it so Clerk detects WebAuthn as supported.
28+
await page.addInitScript(() => {
29+
Object.defineProperty(navigator, 'webdriver', { get: () => false });
30+
});
31+
32+
const cdpSession = await page.context().newCDPSession(page);
33+
await cdpSession.send('WebAuthn.enable');
34+
const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
35+
options: {
36+
protocol: 'ctap2',
37+
transport: 'internal',
38+
hasResidentKey: true,
39+
hasUserVerification: true,
40+
isUserVerified: true,
41+
},
42+
});
43+
return { cdpSession, authenticatorId };
44+
};
45+
46+
const teardownVirtualAuthenticator = async (cdpSession: CDPSession, authenticatorId: string) => {
47+
await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
48+
await cdpSession.send('WebAuthn.disable');
49+
await cdpSession.detach();
50+
};
51+
52+
const dismissOrgDialog = async (page: any) => {
53+
await page.getByRole('button', { name: /I'll remove it myself/i }).click();
54+
};
55+
56+
const openSecurityTabViaUserButton = async (u: ReturnType<typeof createTestUtils>) => {
57+
await u.po.userButton.waitForMounted();
58+
await u.po.userButton.toggleTrigger();
59+
await u.po.userButton.waitForPopover();
60+
await u.po.userButton.triggerManageAccount();
61+
await u.po.userProfile.waitForUserProfileModal();
62+
await u.po.userProfile.switchToSecurityTab();
63+
};
64+
65+
test('register a passkey from UserProfile', async ({ page, context }) => {
66+
const u = createTestUtils({ app, page, context });
67+
68+
const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page);
69+
70+
await u.po.signIn.goTo();
71+
await u.po.signIn.signInWithEmailAndInstantPassword({
72+
email: fakeUser.email,
73+
password: fakeUser.password,
74+
});
75+
await u.page.goToAppHome();
76+
await dismissOrgDialog(page);
77+
await openSecurityTabViaUserButton(u);
78+
79+
// Click "Add a passkey"
80+
await page.getByRole('button', { name: /add a passkey/i }).click();
81+
82+
// The virtual authenticator auto-responds to navigator.credentials.create()
83+
await expect(page.locator('.cl-profileSectionItem__passkeys')).toBeVisible({ timeout: 10000 });
84+
85+
// Save credentials so the sign-in test can import them into its own virtual authenticator
86+
const { credentials } = await cdpSession.send('WebAuthn.getCredentials', { authenticatorId });
87+
savedCredentials = credentials;
88+
89+
await teardownVirtualAuthenticator(cdpSession, authenticatorId);
90+
});
91+
92+
test('sign in with passkey', async ({ page, context }) => {
93+
const u = createTestUtils({ app, page, context });
94+
95+
const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page);
96+
97+
// Import credentials from the register test
98+
for (const credential of savedCredentials) {
99+
await cdpSession.send('WebAuthn.addCredential', { authenticatorId, credential });
100+
}
101+
102+
await u.po.signIn.goTo();
103+
await page.getByRole('link', { name: /use passkey/i }).click();
104+
105+
// The virtual authenticator auto-responds to navigator.credentials.get()
106+
await u.po.expect.toBeSignedIn();
107+
108+
await teardownVirtualAuthenticator(cdpSession, authenticatorId);
109+
});
110+
111+
test('rename a passkey', async ({ page, context }) => {
112+
const u = createTestUtils({ app, page, context });
113+
114+
const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page);
115+
116+
await u.po.signIn.goTo();
117+
await u.po.signIn.signInWithEmailAndInstantPassword({
118+
email: fakeUser.email,
119+
password: fakeUser.password,
120+
});
121+
await u.page.goToAppHome();
122+
await dismissOrgDialog(page);
123+
await openSecurityTabViaUserButton(u);
124+
125+
// Register a passkey
126+
const passkeysBefore = await page.locator('.cl-profileSectionItem__passkeys').count();
127+
await page.getByRole('button', { name: /add a passkey/i }).click();
128+
await expect(page.locator('.cl-profileSectionItem__passkeys')).toHaveCount(passkeysBefore + 1, { timeout: 10000 });
129+
130+
// Click three-dots menu on the newly added passkey (last one)
131+
await page
132+
.locator('.cl-profileSectionItem__passkeys')
133+
.last()
134+
.getByRole('button', { name: /open menu/i })
135+
.click();
136+
137+
// Click "Rename"
138+
await page.getByRole('menuitem', { name: /rename/i }).click();
139+
140+
// Enter new name
141+
const newName = 'My Renamed Passkey';
142+
await page.locator('input[name="passkeyName"]').fill(newName);
143+
await page.getByRole('button', { name: /save/i }).click();
144+
145+
// Verify the updated name appears
146+
await expect(page.locator('.cl-profileSectionItem__passkeys').filter({ hasText: newName })).toBeVisible();
147+
148+
// Clean up
149+
await teardownVirtualAuthenticator(cdpSession, authenticatorId);
150+
});
151+
152+
test('remove a passkey', async ({ page, context }) => {
153+
const u = createTestUtils({ app, page, context });
154+
155+
const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page);
156+
157+
await u.po.signIn.goTo();
158+
await u.po.signIn.signInWithEmailAndInstantPassword({
159+
email: fakeUser.email,
160+
password: fakeUser.password,
161+
});
162+
await u.page.goToAppHome();
163+
await dismissOrgDialog(page);
164+
await openSecurityTabViaUserButton(u);
165+
166+
// Count existing passkeys before registering a new one
167+
const passkeyItems = page.locator('.cl-profileSectionItem__passkeys');
168+
const countBefore = await passkeyItems.count();
169+
170+
// Register a passkey
171+
await page.getByRole('button', { name: /add a passkey/i }).click();
172+
await expect(passkeyItems).toHaveCount(countBefore + 1, { timeout: 10000 });
173+
174+
// Click three-dots menu on the newly added passkey (last one)
175+
await passkeyItems
176+
.last()
177+
.getByRole('button', { name: /open menu/i })
178+
.click();
179+
180+
// Click "Remove"
181+
await page.getByRole('menuitem', { name: /remove/i }).click();
182+
183+
// Confirm removal
184+
await page.getByRole('button', { name: /remove/i }).click();
185+
186+
// Verify passkey count decreased
187+
await expect(passkeyItems).toHaveCount(countBefore, { timeout: 10000 });
188+
189+
// Clean up
190+
await teardownVirtualAuthenticator(cdpSession, authenticatorId);
191+
});
192+
});

0 commit comments

Comments
 (0)