From 6e60d18ce28657de0b31db5d89143029d28bb5dc Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 16 Mar 2026 17:03:07 -0400 Subject: [PATCH 1/2] test(e2e): Add passkey/WebAuthn e2e tests --- integration/presets/envs.ts | 7 + integration/presets/longRunningApps.ts | 1 + .../tests/tanstack-start/passkeys.test.ts | 192 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 integration/tests/tanstack-start/passkeys.test.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index eac5f2c938a..daff7abfa5d 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -214,6 +214,12 @@ const withNeedsClientTrust = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-needs-client-trust').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-needs-client-trust').pk); +const withPasskeys = base + .clone() + .setId('withPasskeys') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-passkeys').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-passkeys').pk); + export const envs = { base, sessionsProd1, @@ -233,6 +239,7 @@ export const envs = { withKeyless, withLegalConsent, withNeedsClientTrust, + withPasskeys, withRestrictedMode, withReverification, withSessionTasks, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 0d2352b3e11..0325e4c31ce 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -74,6 +74,7 @@ export const createLongRunningApps = () => { { id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes }, { id: 'tanstack.react-start.withCustomRoles', config: tanstack.reactStart, env: envs.withCustomRoles }, { id: 'tanstack.react-start.withEmailCodesProxy', config: tanstack.reactStart, env: envs.withEmailCodesProxy }, + { id: 'tanstack.react-start.withPasskeys', config: tanstack.reactStart, env: envs.withPasskeys }, /** * Various apps - basic flows diff --git a/integration/tests/tanstack-start/passkeys.test.ts b/integration/tests/tanstack-start/passkeys.test.ts new file mode 100644 index 00000000000..4a153b2add9 --- /dev/null +++ b/integration/tests/tanstack-start/passkeys.test.ts @@ -0,0 +1,192 @@ +import type { CDPSession } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withPasskeys] })('passkeys @tanstack-react-start', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let savedCredentials: any[] = []; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + const setupVirtualAuthenticator = async (page: any): Promise<{ cdpSession: CDPSession; authenticatorId: string }> => { + // Clerk's isValidBrowser() checks !navigator.webdriver, which is true in Playwright. + // Override it so Clerk detects WebAuthn as supported. + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + }); + + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send('WebAuthn.enable'); + const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return { cdpSession, authenticatorId }; + }; + + const teardownVirtualAuthenticator = async (cdpSession: CDPSession, authenticatorId: string) => { + await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await cdpSession.send('WebAuthn.disable'); + await cdpSession.detach(); + }; + + const dismissOrgDialog = async (page: any) => { + await page.getByRole('button', { name: /I'll remove it myself/i }).click(); + }; + + const openSecurityTabViaUserButton = async (u: ReturnType) => { + await u.po.userButton.waitForMounted(); + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + await u.po.userButton.triggerManageAccount(); + await u.po.userProfile.waitForUserProfileModal(); + await u.po.userProfile.switchToSecurityTab(); + }; + + test('register a passkey from UserProfile', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.page.goToAppHome(); + await dismissOrgDialog(page); + await openSecurityTabViaUserButton(u); + + // Click "Add a passkey" + await page.getByRole('button', { name: /add a passkey/i }).click(); + + // The virtual authenticator auto-responds to navigator.credentials.create() + await expect(page.locator('.cl-profileSectionItem__passkeys')).toBeVisible({ timeout: 10000 }); + + // Save credentials so the sign-in test can import them into its own virtual authenticator + const { credentials } = await cdpSession.send('WebAuthn.getCredentials', { authenticatorId }); + savedCredentials = credentials; + + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + }); + + test('sign in with passkey', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + + // Import credentials from the register test + for (const credential of savedCredentials) { + await cdpSession.send('WebAuthn.addCredential', { authenticatorId, credential }); + } + + await u.po.signIn.goTo(); + await page.getByRole('link', { name: /use passkey/i }).click(); + + // The virtual authenticator auto-responds to navigator.credentials.get() + await u.po.expect.toBeSignedIn(); + + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + }); + + test('rename a passkey', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.page.goToAppHome(); + await dismissOrgDialog(page); + await openSecurityTabViaUserButton(u); + + // Register a passkey + const passkeysBefore = await page.locator('.cl-profileSectionItem__passkeys').count(); + await page.getByRole('button', { name: /add a passkey/i }).click(); + await expect(page.locator('.cl-profileSectionItem__passkeys')).toHaveCount(passkeysBefore + 1, { timeout: 10000 }); + + // Click three-dots menu on the newly added passkey (last one) + await page + .locator('.cl-profileSectionItem__passkeys') + .last() + .getByRole('button', { name: /open menu/i }) + .click(); + + // Click "Rename" + await page.getByRole('menuitem', { name: /rename/i }).click(); + + // Enter new name + const newName = 'My Renamed Passkey'; + await page.locator('input[name="passkeyName"]').fill(newName); + await page.getByRole('button', { name: /save/i }).click(); + + // Verify the updated name appears + await expect(page.locator('.cl-profileSectionItem__passkeys').filter({ hasText: newName })).toBeVisible(); + + // Clean up + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + }); + + test('remove a passkey', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.page.goToAppHome(); + await dismissOrgDialog(page); + await openSecurityTabViaUserButton(u); + + // Count existing passkeys before registering a new one + const passkeyItems = page.locator('.cl-profileSectionItem__passkeys'); + const countBefore = await passkeyItems.count(); + + // Register a passkey + await page.getByRole('button', { name: /add a passkey/i }).click(); + await expect(passkeyItems).toHaveCount(countBefore + 1, { timeout: 10000 }); + + // Click three-dots menu on the newly added passkey (last one) + await passkeyItems + .last() + .getByRole('button', { name: /open menu/i }) + .click(); + + // Click "Remove" + await page.getByRole('menuitem', { name: /remove/i }).click(); + + // Confirm removal + await page.getByRole('button', { name: /remove/i }).click(); + + // Verify passkey count decreased + await expect(passkeyItems).toHaveCount(countBefore, { timeout: 10000 }); + + // Clean up + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + }); +}); From 98381a9350a81e9c2c323bb422c66ed31a0ec7ad Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 16 Mar 2026 17:05:10 -0400 Subject: [PATCH 2/2] chore: Add empty changeset --- .changeset/three-clowns-travel.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/three-clowns-travel.md diff --git a/.changeset/three-clowns-travel.md b/.changeset/three-clowns-travel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/three-clowns-travel.md @@ -0,0 +1,2 @@ +--- +---