Add passkey authentication E2E tests

Uses Playwright's virtual WebAuthn authenticator (CDP) to test:
- Register with passkey + sign in (full round-trip)
- Login fails with no credential (friendly error message)
- Duplicate email rejection
- Duplicate username rejection

Would have caught the credential encoding bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-27 21:33:34 +01:00
parent 24fc6cc089
commit 39b57b73b7
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
2 changed files with 120 additions and 0 deletions

112
e2e/auth.test.ts Normal file
View file

@ -0,0 +1,112 @@
import { test, expect, type CDPSession, type Page } from "@playwright/test";
// Virtual authenticator helpers
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) {
await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
await cdp.send("WebAuthn.disable");
}
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await page.getByLabel("Email").click();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").click();
await page.getByLabel("Username").fill(username);
// Verify both fields retained values before submitting
await expect(page.getByLabel("Email")).toHaveValue(email);
await expect(page.getByLabel("Username")).toHaveValue(username);
await page.getByRole("button", { name: /Register with Passkey/ }).click();
}
async function logout(page: Page) {
await page.getByRole("navigation").getByRole("button", { name: "Log Out" }).click();
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
}
test.describe("Passkey Authentication", () => {
test("register with passkey and sign in", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const email = `test-${Date.now()}@example.com`;
const username = `testuser${Date.now()}`;
// Register
await registerUser(page, email, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
// Log out
await logout(page);
// Sign in with passkey
await page.goto("/auth/login");
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("passkey login fails with no registered credential", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await page.goto("/auth/login");
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
await expect(page.getByText(/No passkey found/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("register rejects duplicate email", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const email = `dup-${Date.now()}@example.com`;
// Register first user
await registerUser(page, email, `first${Date.now()}`);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
// Try to register with same email
await registerUser(page, email, `second${Date.now()}`);
await expect(page.getByText(/already in use/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("register rejects duplicate username", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const username = `uniq${Date.now()}`;
// Register first user
await registerUser(page, `first-${Date.now()}@example.com`, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
// Try to register with same username
await registerUser(page, `second-${Date.now()}@example.com`, username);
await expect(page.getByText(/already taken/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
});

View file

@ -31,6 +31,14 @@ export default defineConfig({
baseURL: "http://localhost:3001",
},
},
{
name: "auth",
testMatch: "auth.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "integration",
testMatch: "integration.test.ts",