77 lines
3.6 KiB
JavaScript
77 lines
3.6 KiB
JavaScript
// 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);
|
|
});
|