diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 4a0b4ea49..07761eecc 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,24 +1,89 @@ +import type { BrowserContext, Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; +import { + clearLoginBuckets, + criticalAccount, + resetCriticalAccount, + type CriticalAccount, +} from "./setup/critical-fixture"; + /** - * Smoke checks on the login surface — must render, must wire the - * password-manager autofill hints (autoComplete="username" / - * "current-password"), must let the user submit. + * Signing in and out, against the real server. * - * These do NOT log in (no DB seed required); the password-flow - * roundtrip is exercised by the integration test in - * tests/integration/auth-flow.test.ts. + * Every test starts without a session and signs in through the form, so what + * it reads is what the login route, the session table and the shell did, not + * a payload typed into the test. The account is the sign-in journey's own + * (one per project, see `critical-fixture.ts`): it gets no jar from global + * setup, because the tests here end the sessions they open and a jar captured + * once would be dead for a retry. + * + * The passkey half runs through Chromium's virtual authenticator, attached + * over CDP. It is a real authenticator as far as the page and the server are + * concerned: the registration and the assertion are signed, and the server + * verifies both against the origin and RP id the suite runs under. */ -async function openPasswordForm(page: import("@playwright/test").Page) { +test.use({ storageState: { cookies: [], origins: [] } }); + +const SESSION_COOKIE = "healthlog_session"; + +async function openPasswordForm(page: Page): Promise { await page.goto("/auth/login"); - // The login page defaults to passkey mode; the password form is - // mounted only after clicking the "Login with password" / "Mit - // Passwort anmelden" outline button. - await page - .getByRole("button", { - name: /sign in with password|login with password|mit passwort/i, - }) - .click(); + // The login page opens in passkey mode; the password form mounts after + // this button. The button is server-rendered, so the click is retried + // until the form it mounts is there. + const usePassword = page.getByTestId("login-use-password"); + const email = page.getByTestId("login-email"); + await expect(async () => { + await usePassword.click({ timeout: 2_000 }); + await expect(email).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); +} + +async function signInWithPassword( + page: Page, + account: CriticalAccount, +): Promise { + await openPasswordForm(page); + await page.getByTestId("login-email").fill(account.username); + await page.getByTestId("login-password").fill(account.password); + // The login route allows five attempts per IP per quarter hour and the + // whole suite is one IP; see `clearLoginBuckets`. + await clearLoginBuckets(); + await page.getByTestId("login-password-submit").click(); + await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 }); +} + +async function sessionCookie(context: BrowserContext): Promise { + const cookie = (await context.cookies()).find( + (c) => c.name === SESSION_COOKIE, + ); + expect(cookie, "the login answered without a session cookie").toBeTruthy(); + return cookie!.value; +} + +/** The username `/api/auth/me` answers for this context's session. */ +async function signedInAs(page: Page): Promise { + const res = await page.request.get("/api/auth/me"); + if (res.status() !== 200) return null; + return ((await res.json()) as { data: { username: string } }).data.username; +} + +async function signOutThroughTheMenu(page: Page): Promise { + // The desktop sidebar and the phone top bar each carry a user menu; only + // one of them is on screen at a given width. + const trigger = page + .getByRole("button", { name: "User menu" }) + .filter({ visible: true }) + .first(); + const signOut = page.getByRole("menuitem", { name: "Sign out" }); + await expect(async () => { + await trigger.click({ timeout: 2_000 }); + await expect(signOut).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); + await signOut.click(); + await page.waitForURL((url) => url.pathname === "/auth/login"); } test.describe("login page", () => { @@ -27,47 +92,166 @@ test.describe("login page", () => { }) => { await openPasswordForm(page); - const username = page - .getByLabel(/username|benutzername|email|e-mail/i) - .first(); + const username = page.getByTestId("login-email"); await expect(username).toBeVisible(); await expect(username).toHaveAttribute("autoComplete", /username|email/); - const password = page.locator('input[type="password"]').first(); + const password = page.getByTestId("login-password"); await expect(password).toBeVisible(); await expect(password).toHaveAttribute("autoComplete", "current-password"); }); - test("rejects an obviously-wrong credential pair", async ({ page }) => { + test("refuses a wrong password and opens no session", async ({ + page, + context, + }, testInfo) => { + const account = criticalAccount("auth", testInfo); await openPasswordForm(page); - - await page - .getByLabel(/username|benutzername|email|e-mail/i) - .first() - .fill("nobody-here"); - await page.locator('input[type="password"]').first().fill("not-the-pw"); - - // Intercept the API call so the test does not depend on a real - // backend rejecting the credentials. The login form's error - // surfacing is what we actually want to prove here. - await page.route("**/api/auth/login", (route) => - route.fulfill({ - status: 401, - contentType: "application/json", - body: JSON.stringify({ error: "Invalid credentials" }), - }), + await page.getByTestId("login-email").fill(account.username); + await page.getByTestId("login-password").fill("not-this-account-password"); + await clearLoginBuckets(); + const answer = page.waitForResponse( + (res) => + res.url().endsWith("/api/auth/login") && + res.request().method() === "POST", ); + await page.getByTestId("login-password-submit").click(); - // The form has multiple buttons matching /sign in/ — both the - // passkey CTA and the password submit. Filter to the submit button - // so the click lands on the right one. - await page - .locator('button[type="submit"]') - .filter({ hasText: /login|anmelden|sign in/i }) - .click(); - - await expect( - page.getByText(/invalid credentials|ungültig|falsch/i), - ).toBeVisible({ timeout: 5_000 }); + expect((await answer).status()).toBe(401); + await expect(page.getByText("Invalid credentials")).toBeVisible(); + await expect(page).toHaveURL(/\/auth\/login/); + expect( + (await context.cookies()).some((c) => c.name === SESSION_COOKIE), + ).toBe(false); + }); +}); + +test.describe("signing in and out", () => { + test("a password sign-in lands on the dashboard as that account @critical", async ({ + page, + }, testInfo) => { + const account = criticalAccount("auth", testInfo); + await signInWithPassword(page, account); + + await expect(page.locator("main")).toBeVisible(); + expect(await signedInAs(page)).toBe(account.username); + }); + + test("signing out ends the session on the server, not only in the browser @critical", async ({ + page, + context, + playwright, + baseURL, + }, testInfo) => { + const account = criticalAccount("auth", testInfo); + await signInWithPassword(page, account); + const cookie = await sessionCookie(context); + // A second client holding the same cookie, the way a copied jar or a + // stolen cookie would. + const replay = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` }, + }); + try { + // Positive control: the replayed cookie is a working session before + // the sign-out, so the 401 below is about the sign-out. + expect((await replay.get("/api/auth/me")).status()).toBe(200); + + await signOutThroughTheMenu(page); + + // The browser dropped its cookie; that alone would pass with a server + // that kept the row. The proof is the old cookie being refused. + expect(await signedInAs(page)).toBeNull(); + expect((await replay.get("/api/auth/me")).status()).toBe(401); + expect((await replay.get("/api/measurements?limit=1")).status()).toBe( + 401, + ); + } finally { + await replay.dispose(); + } + + // And the protected shell sends the browser back to the form. + await page.goto("/"); + await page.waitForURL((url) => url.pathname === "/auth/login"); + }); + + test("a passkey registered in settings signs the account back in @critical", async ({ + page, + context, + }, testInfo) => { + const account = criticalAccount("auth", testInfo); + // A retry, or the last run, may have left a credential on the account; + // the list below has to count only the one this test registers. + await resetCriticalAccount(account, [ + "DELETE FROM passkeys WHERE user_id = $1", + ]); + + const cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send( + "WebAuthn.addVirtualAuthenticator", + { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }, + ); + + try { + await signInWithPassword(page, account); + + await test.step("register a passkey under the current password", async () => { + await page.goto("/settings/security"); + await page.getByRole("button", { name: "Add passkey" }).click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await dialog.locator('input[type="password"]').fill(account.password); + await dialog.getByRole("button", { name: "Verify and add" }).click(); + + await expect(dialog).toBeHidden(); + await expect( + page.getByTestId("passkeys-list").locator("li"), + ).toHaveCount(1); + const stored = await cdp.send("WebAuthn.getCredentials", { + authenticatorId, + }); + expect(stored.credentials).toHaveLength(1); + expect(stored.credentials[0].isResidentCredential).toBe(true); + }); + + await signOutThroughTheMenu(page); + expect(await signedInAs(page)).toBeNull(); + + await test.step("sign in with it", async () => { + await clearLoginBuckets(); + await page + .getByRole("button", { name: "Sign in with Passkey" }) + .click(); + await page.waitForURL((url) => url.pathname === "/", { + timeout: 30_000, + }); + expect(await signedInAs(page)).toBe(account.username); + + // The server recorded the use against the stored credential. + const res = await page.request.get("/api/auth/passkeys"); + expect(res.status()).toBe(200); + const passkeys = ( + (await res.json()) as { + data: Array<{ lastUsedAt: string | null }>; + } + ).data; + expect(passkeys).toHaveLength(1); + expect(passkeys[0].lastUsedAt).not.toBeNull(); + }); + } finally { + await cdp + .send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }) + .catch(() => undefined); + } }); }); diff --git a/e2e/setup/critical-fixture.ts b/e2e/setup/critical-fixture.ts index 07c07b9b0..8601a6e1d 100644 --- a/e2e/setup/critical-fixture.ts +++ b/e2e/setup/critical-fixture.ts @@ -39,6 +39,16 @@ export const CRITICAL_JOURNEYS = [ export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number]; +/** + * Journeys that sign in through the form in every test and so get no jar. + * The sign-in journey ends the sessions it opens; a jar captured once in + * global setup would be dead for a retry, and a retry has to start from the + * same place as the first attempt. + */ +export const FORM_SIGN_IN_JOURNEYS: ReadonlySet = new Set([ + "auth", +]); + /** The projects the journeys run in; each gets its own account per journey. */ export const CRITICAL_PROJECTS = { "chromium-desktop": "d", diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 0c38ef96c..493b36f80 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,7 +33,7 @@ import { } from "@playwright/test"; import pg from "pg"; -import { allCriticalAccounts } from "./critical-fixture"; +import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture"; import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; @@ -1754,8 +1754,9 @@ export default async function globalSetup(config: FullConfig): Promise { ); // The @critical journeys' jars. The sign-in journey signs in through the - // form, so its account gets a jar only for the steps that start signed in. + // form in every test, so its account gets none. for (const account of allCriticalAccounts()) { + if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue; await capture(account, account.storageStatePath); } } finally { diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts index 578effb68..418b18e58 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -49,7 +49,7 @@ const E2E = join(process.cwd(), "e2e"); * the set is the release gate, so a journey that loses its tag is a change * somebody should see here. */ -const CRITICAL_TEST_FLOOR = 7; +const CRITICAL_TEST_FLOOR = 10; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST =