Skip to content

Commit de91a74

Browse files
committed
progress on passkey tests
1 parent 7fa5b06 commit de91a74

File tree

3 files changed

+174
-12
lines changed

3 files changed

+174
-12
lines changed

app/routes/_auth+/login.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ function PasskeyLogin({
267267
setPasskeyMessage("You're logged in! Navigating...")
268268
await navigate(verification.location ?? '/')
269269
} catch (e) {
270+
console.error('ahhhhhhhhhhhhh error', e)
270271
setError(
271272
e instanceof Error ? e.message : 'Failed to authenticate with passkey',
272273
)

app/routes/settings+/profile.passkeys.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ export default function Passkeys({ loaderData }: Route.ComponentProps) {
130130
</div>
131131
) : null}
132132

133-
<div className="flex flex-col gap-4">
134-
{loaderData.passkeys.length ? (
135-
loaderData.passkeys.map((passkey) => (
136-
<div
133+
{loaderData.passkeys.length ? (
134+
<ul className="flex flex-col gap-4" title="passkeys">
135+
{loaderData.passkeys.map((passkey) => (
136+
<li
137137
key={passkey.id}
138138
className="flex items-center justify-between gap-4 rounded-lg border border-muted-foreground p-4"
139139
>
@@ -164,14 +164,14 @@ export default function Passkeys({ loaderData }: Route.ComponentProps) {
164164
<Icon name="trash">Delete</Icon>
165165
</Button>
166166
</Form>
167-
</div>
168-
))
169-
) : (
170-
<div className="text-center text-muted-foreground">
171-
No passkeys registered yet
172-
</div>
173-
)}
174-
</div>
167+
</li>
168+
))}
169+
</ul>
170+
) : (
171+
<div className="text-center text-muted-foreground">
172+
No passkeys registered yet
173+
</div>
174+
)}
175175
</div>
176176
)
177177
}

tests/e2e/passkey.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { faker } from '@faker-js/faker'
2+
import { type CDPSession } from '@playwright/test'
3+
import { expect, test } from '#tests/playwright-utils.ts'
4+
5+
async function setupWebAuthn(page: any) {
6+
const client = await page.context().newCDPSession(page)
7+
// https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/
8+
await client.send('WebAuthn.enable', { options: { enableUI: true } })
9+
const result = await client.send('WebAuthn.addVirtualAuthenticator', {
10+
options: {
11+
protocol: 'ctap2',
12+
transport: 'internal',
13+
hasUserVerification: true,
14+
isUserVerified: true,
15+
},
16+
})
17+
return { client, authenticatorId: result.authenticatorId }
18+
}
19+
20+
async function simulateSuccessfulPasskeyInput(
21+
client: CDPSession,
22+
authenticatorId: string,
23+
operationTrigger: () => Promise<void>,
24+
) {
25+
// initialize event listeners to wait for a successful passkey input event
26+
const operationCompleted = new Promise<void>((resolve) => {
27+
client.on('WebAuthn.credentialAdded', () => resolve())
28+
client.on('WebAuthn.credentialAsserted', () => resolve())
29+
})
30+
31+
// perform a user action that triggers passkey prompt
32+
await operationTrigger()
33+
34+
// wait to receive the event that the passkey was successfully registered or verified
35+
await operationCompleted
36+
}
37+
38+
test('Users can register and use passkeys', async ({ page, login }) => {
39+
const password = faker.internet.password()
40+
const user = await login({ password })
41+
42+
const { client, authenticatorId } = await setupWebAuthn(page)
43+
44+
const initialCredentials = await client.send('WebAuthn.getCredentials', {
45+
authenticatorId,
46+
})
47+
expect(
48+
initialCredentials.credentials,
49+
'No credentials should exist initially',
50+
).toHaveLength(0)
51+
52+
await page.goto('/settings/profile/passkeys')
53+
54+
await simulateSuccessfulPasskeyInput(client, authenticatorId, async () => {
55+
await page.getByRole('button', { name: /register new passkey/i }).click()
56+
})
57+
58+
// Verify the passkey appears in the UI
59+
await expect(page.getByRole('list', { name: /passkeys/i })).toBeVisible()
60+
await expect(page.getByText(/registered .* ago/i)).toBeVisible()
61+
62+
const afterRegistrationCredentials = await client.send(
63+
'WebAuthn.getCredentials',
64+
{ authenticatorId },
65+
)
66+
expect(
67+
afterRegistrationCredentials.credentials,
68+
'One credential should exist after registration',
69+
).toHaveLength(1)
70+
71+
// Logout
72+
await page.getByRole('link', { name: user.name ?? user.username }).click()
73+
await page.getByRole('menuitem', { name: /logout/i }).click()
74+
await expect(page).toHaveURL(`/`)
75+
76+
// Try logging in with passkey
77+
await page.goto('/login')
78+
const signCount1 = afterRegistrationCredentials.credentials[0].signCount
79+
80+
await simulateSuccessfulPasskeyInput(client, authenticatorId, async () => {
81+
await page.getByRole('button', { name: /login with a passkey/i }).click()
82+
})
83+
84+
// Verify successful login
85+
await expect(
86+
page.getByRole('link', { name: user.name ?? user.username }),
87+
).toBeVisible()
88+
89+
// Verify the sign count increased
90+
const afterLoginCredentials = await client.send('WebAuthn.getCredentials', {
91+
authenticatorId,
92+
})
93+
expect(afterLoginCredentials.credentials).toHaveLength(1)
94+
expect(afterLoginCredentials.credentials[0].signCount).toBeGreaterThan(
95+
signCount1,
96+
)
97+
98+
// Go to passkeys page and delete the passkey
99+
await page.goto('/settings/profile/passkeys')
100+
await page.getByRole('button', { name: /delete/i }).click()
101+
102+
// Verify the passkey is no longer listed on the page
103+
await expect(page.getByText(/no passkeys registered/i)).toBeVisible()
104+
105+
// But verify it still exists in the authenticator
106+
const afterDeletionCredentials = await client.send(
107+
'WebAuthn.getCredentials',
108+
{ authenticatorId },
109+
)
110+
expect(afterDeletionCredentials.credentials).toHaveLength(1)
111+
112+
// Logout again to test deleted passkey
113+
await page.getByRole('link', { name: user.name ?? user.username }).click()
114+
await page.getByRole('menuitem', { name: /logout/i }).click()
115+
await expect(page).toHaveURL(`/`)
116+
117+
// Try logging in with the deleted passkey
118+
await page.goto('/login')
119+
await simulateSuccessfulPasskeyInput(client, authenticatorId, async () => {
120+
await page.getByRole('button', { name: /login with a passkey/i }).click()
121+
})
122+
123+
// Verify error message appears
124+
await expect(page.getByText(/passkey not found/i)).toBeVisible()
125+
126+
// Verify we're still on the login page
127+
await expect(page).toHaveURL(`/login`)
128+
})
129+
130+
test('Failed passkey verification shows error', async ({ page, login }) => {
131+
const password = faker.internet.password()
132+
await login({ password })
133+
134+
// Set up WebAuthn
135+
const { client, authenticatorId } = await setupWebAuthn(page)
136+
137+
// Navigate to passkeys page
138+
await page.goto('/settings/profile/passkeys')
139+
140+
// Try to register with failed verification
141+
await client.send('WebAuthn.setUserVerified', {
142+
authenticatorId,
143+
isUserVerified: false,
144+
})
145+
146+
await client.send('WebAuthn.setAutomaticPresenceSimulation', {
147+
authenticatorId,
148+
enabled: true,
149+
})
150+
151+
await page.getByRole('button', { name: /register new passkey/i }).click()
152+
153+
// Wait for error message
154+
await expect(page.getByText(/failed to create passkey/i)).toBeVisible()
155+
156+
// Verify no passkey was registered
157+
const credentials = await client.send('WebAuthn.getCredentials', {
158+
authenticatorId,
159+
})
160+
expect(credentials.credentials).toHaveLength(0)
161+
})

0 commit comments

Comments
 (0)