// WebAuthn passkey flow (QA-B1-01) — driven end-to-end with Playwright's CDP // virtual authenticator, which retires Cycle 1's "needs a human with a key" // assumption: enroll on /profile → sign out → password + key sign-in → remove // the key (restoring the seeded user to password-only for the other specs). const { test, expect } = require('@playwright/test'); const { E2E_USER, E2E_PASS } = require('./constants'); // Fresh context: this spec exercises the real login flow, not saved state. test.use({ storageState: { cookies: [], origins: [] } }); test('enroll passkey → sign in with key → remove key', async ({ page }) => { // Virtual authenticator: an "internal" (platform) CTAP2 key that auto-approves // presence/verification prompts, so the browser dialog never blocks CI. const cdp = await page.context().newCDPSession(page); await cdp.send('WebAuthn.enable'); await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }, }); // 1. Password sign-in. await page.goto('/login'); await page.locator('#username').fill(E2E_USER); await page.locator('#password').fill(E2E_PASS); await page.getByRole('button', { name: /sign in/i }).click(); await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 }); await page .getByRole('button', { name: 'Got it' }) .click({ timeout: 5000 }) .catch(() => {}); // 2. Enroll a key on /profile. await page.goto('/profile'); const passkeyCard = page.locator('section, div').filter({ has: page.getByRole('heading', { name: /passkeys & security keys/i }), }); await page.getByRole('button', { name: 'Add key' }).click(); await page.locator('#passkey-name').fill('Virtual E2E Key'); await page.getByRole('button', { name: 'Register key' }).click(); await expect(page.getByText('Security key added.')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Virtual E2E Key')).toBeVisible(); // 3. Sign out (API logout keeps the flow deterministic), then key sign-in. const csrf = await (await page.request.get('/api/auth/csrf-token')).json(); await page.request.post('/api/auth/logout', { headers: { 'x-csrf-token': csrf.token } }); await page.goto('/login'); await page.locator('#username').fill(E2E_USER); await page.locator('#password').fill(E2E_PASS); await page.getByRole('button', { name: /sign in/i }).click(); // The security-key step auto-fires startAuthentication; the virtual // authenticator approves it and we land authenticated. await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 }); await page .getByRole('button', { name: 'Got it' }) .click({ timeout: 5000 }) .catch(() => {}); await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible({ timeout: 10_000 }); // 4. Remove the key (password-gated) so the seeded user is password-only // again for every other spec in the run. await page.goto('/profile'); await passkeyCard.getByRole('button', { name: 'Remove', exact: true }).first().click(); await page.locator('#passkey-remove-password').fill(E2E_PASS); await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); await expect(page.getByText('Security key removed.')).toBeVisible({ timeout: 10_000 }); // Both the TOTP and the passkey card now read "Not configured" (the seeded // user has neither) — count is the unambiguous assertion. await expect(page.getByText('Not configured')).toHaveCount(2); });