From b826642853fce1ab24f2e8b645771599d578aa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:52:12 +0200 Subject: [PATCH 01/10] test(e2e): tag the unmocked release journeys @critical and hold them to the real API Backup and restore, the adherence journey, delegated writes and the offline page already run against the real server. They carry the @critical tag now, and a structural guard refuses any /api interception in a file that carries it, directly or through an imported helper, with a floor on the tagged count. Global setup seeds one account per critical journey and per project, so the journeys still to come can run at both widths without writing into each other or into the shared fixture account. pnpm e2e:critical runs the set. --- e2e/backup-restore-journey.spec.ts | 4 +- e2e/delegated-writes.spec.ts | 10 +- e2e/medication-compliance-journey.spec.ts | 2 +- e2e/pwa-offline.spec.ts | 2 +- e2e/setup/critical-fixture.ts | 166 +++++++++++ e2e/setup/global-setup.ts | 50 ++++ package.json | 1 + .../e2e-critical-no-api-stub-guard.test.ts | 279 ++++++++++++++++++ 8 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 e2e/setup/critical-fixture.ts create mode 100644 src/__tests__/e2e-critical-no-api-stub-guard.test.ts diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index 9104ec5f6..581fdb204 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -319,7 +319,7 @@ test.describe("Backup and restore, through the settings surfaces", () => { tamperedCopyId = null; }); - test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({ + test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({ page, }) => { await page.goto("/measurements", { waitUntil: "domcontentloaded" }); @@ -505,7 +505,7 @@ test.describe("Backup and restore, through the settings surfaces", () => { expect(await storedMeasurements()).toEqual(before); }); - test("the snapshot puts back what was deleted and drops what came after", async ({ + test("the snapshot puts back what was deleted and drops what came after @critical", async ({ page, }) => { const record = requireSeeded(); diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 5c7cfa565..f63b54f5c 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -136,7 +136,7 @@ test.describe("delegated writes", () => { ).toBeVisible(); } - test("the owner invites at a level that may add entries", async () => { + test("the owner invites at a level that may add entries @critical", async () => { await openInviteForm(); const identifier = ownerPage.locator( @@ -183,7 +183,9 @@ test.describe("delegated writes", () => { ).toBe(WRITE_LEVEL_VALUE); }); - test("the delegate accepts and opens the record", async ({ page }) => { + test("the delegate accepts and opens the record @critical", async ({ + page, + }) => { await page.goto("/settings/access"); await page.locator('[data-slot="grant-accept"]').first().click(); @@ -203,7 +205,9 @@ test.describe("delegated writes", () => { await expect(banner).not.toContainText("read it, not change it"); }); - test("the reading they add lands in the owner's record", async ({ page }) => { + test("the reading they add lands in the owner's record @critical", async ({ + page, + }) => { await page.goto("/measurements"); await expect( page.locator('[data-slot="shared-record-banner"]'), diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index 83f01b3dc..809f7120e 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => { }); }); - test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({ + test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({ page, }) => { // The wizard is a seven-step dialog with per-step validation gating Next; diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts index 05701ce0d..28b34c16a 100644 --- a/e2e/pwa-offline.spec.ts +++ b/e2e/pwa-offline.spec.ts @@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("offline, with the service worker", () => { test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); - test("an open page says it is offline, a new one opens the offline page, and both recover", async ({ + test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({ page, context, }) => { diff --git a/e2e/setup/critical-fixture.ts b/e2e/setup/critical-fixture.ts new file mode 100644 index 000000000..07c07b9b0 --- /dev/null +++ b/e2e/setup/critical-fixture.ts @@ -0,0 +1,166 @@ +/** + * Accounts for the `@critical` journeys: one per journey AND per project. + * + * The `@critical` journeys run against the real server with no `/api` stubs + * (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so + * every verdict they read is a statement about a real record. Two things would + * make those verdicts somebody else's: + * + * - a sibling spec writing into the same account. The shared fixture account + * is read and written by sixty files, so "the dashboard tile shows the + * weight I just entered" is only true of an account nobody else writes; + * - the same journey running in the other project. Every other journey that + * owns its account answers that by running in ONE project (the long list in + * `playwright.config.ts`). These journeys are the release gate and are + * meant to hold at both widths, so instead of dropping a project each + * project gets its own account: `chromium-desktop` and `chromium-mobile` + * run the same file in parallel and never meet. + * + * The accounts are seeded and signed in by `global-setup.ts`, like every + * other fixture account; this module only names them and resets what the + * journeys write. It talks to Postgres through `pg` for the reason + * `global-setup.ts` gives (Playwright's loader cannot load the generated + * Prisma client). + */ +import { resolve } from "node:path"; + +import type { TestInfo } from "@playwright/test"; +import pg from "pg"; + +/** The journeys that own an account pair. */ +export const CRITICAL_JOURNEYS = [ + "auth", + "measurement", + "mood", + "labs", + "wellbeing", + "settings", +] as const; + +export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number]; + +/** The projects the journeys run in; each gets its own account per journey. */ +export const CRITICAL_PROJECTS = { + "chromium-desktop": "d", + "chromium-mobile": "m", +} as const; + +type CriticalProject = keyof typeof CRITICAL_PROJECTS; + +export interface CriticalAccount { + username: string; + email: string; + password: string; + storageStatePath: string; +} + +function accountFor( + journey: CriticalJourney, + suffix: (typeof CRITICAL_PROJECTS)[CriticalProject], +): CriticalAccount { + const username = `e2e-critical-${journey}-${suffix}`; + return { + username, + email: `${username}@healthlog.test`, + // Fixture credential for a throwaway test database, like every other + // account in `global-setup.ts`. Long and mixed so it would also clear the + // registration strength gate. + password: `Cq7!${journey}-${suffix}-Wz4pLx`, + storageStatePath: resolve( + process.cwd(), + `e2e/setup/storageStateCritical-${journey}-${suffix}.json`, + ), + }; +} + +/** Every critical account, for `global-setup.ts` to seed and sign in. */ +export function allCriticalAccounts(): Array< + CriticalAccount & { journey: CriticalJourney } +> { + return CRITICAL_JOURNEYS.flatMap((journey) => + Object.values(CRITICAL_PROJECTS).map((suffix) => ({ + journey, + ...accountFor(journey, suffix), + })), + ); +} + +/** + * The account a journey uses in the project the test is running in. A project + * outside the map has no account on purpose: a critical journey that grows a + * third project has to say which account it reads. + */ +export function criticalAccount( + journey: CriticalJourney, + testInfo: TestInfo, +): CriticalAccount { + const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject]; + if (!suffix) { + throw new Error( + `[critical-fixture] no ${journey} account for project ${testInfo.project.name}`, + ); + } + return accountFor(journey, suffix); +} + +/** + * A `test.use({ storageState })` value that picks the jar of the running + * project's account. + */ +export function criticalStorageState(journey: CriticalJourney) { + return async ( + {}: object, + use: (path: string) => Promise, + testInfo: TestInfo, + ): Promise => { + await use(criticalAccount(journey, testInfo).storageStatePath); + }; +} + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + return await fn(pool); + } finally { + await pool.end(); + } +} + +/** + * Run statements against one critical account, with `$1` bound to its id. + * Fixture hygiene only: what a previous run or a retry left behind. Nothing a + * journey asserts is written here. + */ +export async function resetCriticalAccount( + account: CriticalAccount, + statements: readonly string[], +): Promise { + await withPool(async (pool) => { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [account.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + `[critical-fixture] ${account.username} not seeded — global-setup must run first`, + ); + } + for (const sql of statements) await pool.query(sql, [id]); + }); +} + +/** + * Clear the login buckets. The ceiling is five attempts per IP per quarter + * hour, the suite shares one IP, and global setup alone signs in more than + * twenty times; a journey that signs in through the form would otherwise meet + * the fixture's 429 rather than the product. Only the auth surfaces' buckets + * are touched, exactly as global setup does between its own logins. + */ +export async function clearLoginBuckets(): Promise { + await withPool(async (pool) => { + await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index faf10ceb1..0c38ef96c 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,6 +33,7 @@ import { } from "@playwright/test"; import pg from "pg"; +import { allCriticalAccounts } from "./critical-fixture"; import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; @@ -1539,6 +1540,49 @@ export default async function globalSetup(config: FullConfig): Promise { [cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username], ); + // ── the @critical journeys' accounts ───────────────────────────────── + // + // One per journey and per project; `critical-fixture.ts` says why. Every + // module a journey reaches is switched on (wellbeing and labs are opt-in), + // and the passkeys the sign-in journey registers go with the row's reset, + // so a re-run registers into an empty list rather than beside the last + // run's credential. + for (const account of allCriticalAccounts()) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json, + totp_confirmed_at = NULL`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + now, + JSON.stringify({ + mood: true, + labs: true, + mentalHealth: true, + medications: true, + }), + ], + ); + await pool.query( + `DELETE FROM passkeys + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [account.username], + ); + } + // Console (instead of structured logging) is intentional here — // global-setup runs outside the app's logging context, and the // line is useful when debugging a CI failure where the seed didn't @@ -1708,6 +1752,12 @@ export default async function globalSetup(config: FullConfig): Promise { `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, [E2E_SETUP_CHILD.username, now], ); + + // 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. + for (const account of allCriticalAccounts()) { + await capture(account, account.storageStatePath); + } } finally { await pool.end(); } diff --git a/package.json b/package.json index a8f6354bb..2094a014f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:watch": "vitest", "test:integration": "vitest run --config vitest.integration.config.mts", "e2e": "playwright test", + "e2e:critical": "playwright test --grep @critical", "e2e:ui": "playwright test --ui", "openapi:generate": "npx tsx scripts/generate-openapi.ts", "openapi:check": "npx tsx scripts/check-openapi.ts", diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts new file mode 100644 index 000000000..578effb68 --- /dev/null +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -0,0 +1,279 @@ +/** + * Structural guard: an `@critical` e2e journey talks to the real server. + * + * The `@critical` tag marks the journeys a release is gated on — sign-in, + * a reading reaching the dashboard, a dose moving compliance, a backup coming + * back. They are only worth that role if the server under them is the one + * that ships. A spec that stubs `/api/*` with `page.route(...)` proves that + * the UI renders a payload somebody typed into the test, which is how a broken + * write path stays green: the mock answers 200 whatever the route does. + * + * So a spec FILE that carries `@critical` anywhere may not intercept the app's + * API, and the rule is per file rather than per test because a route armed in + * a `beforeEach` or a `describe`-level hook covers every test beside it. + * What it catches: + * + * - `.route(` / `.routeFromHAR(` in the spec whose first argument mentions + * `api`, or is not a literal at all (a pattern held in a variable cannot be + * shown to miss `/api`, so it is refused rather than trusted); + * - a helper imported from a relative module whose exported function arms + * such a route (`stubDashboardAnalytics`, `mockMedicationsApi`, …). The + * helper is read, its exported functions are cut apart at each top-level + * `export`, and any that intercepts `/api` may not be imported by name. + * + * Stubbing a third-party host that is unreachable from CI and is not the + * journey's subject stays possible: a literal pattern without `api` passes. + * The spec is expected to say why next to the call. + * + * It is a tripwire with a scanner's limits, not a parser: a route armed + * through a helper that is not imported by name (a default export, a + * re-export chain) is not followed. The synthetic cases at the bottom prove + * each matcher fires; the floor proves the sweep found the tagged tests at + * all. + */ +import { existsSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + readSourceText, + requireFloor, + walkSourceFiles, +} from "./helpers/source-files"; + +const E2E = join(process.cwd(), "e2e"); + +/** + * The `@critical` tests found in `e2e/*.spec.ts`. Pinned at the real count: + * 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; + +/** A test whose title literal carries the tag, however the call is spelled. */ +const CRITICAL_TEST = + /\btest(?:\.(?:only|skip|fixme|fail|slow))?\(\s*(["'`])((?:(?!\1)[\s\S])*?@critical(?:(?!\1)[\s\S])*?)\1/g; + +/** A route interception call; the first argument is examined separately. */ +const ROUTE_CALL = /\.route(?:FromHAR)?\(\s*/g; + +/** + * The source without its comment LINES: a line whose first non-blank text is + * `//`, and a block that opens at the start of a line. Deliberately not the + * shared `stripComments`, which opens a block at any `/*` — and route globs + * (`"**\/api/day/**"`, `"https://host/**"`) are full of them, so it would + * swallow the code after the first glob, including any `.route(` there. Prose + * about this rule lives in comment lines, which is what has to go. + */ +function stripCommentLines(source: string): string { + return source + .replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, "") + .replace(/^[ \t]*\/\/.*$/gm, ""); +} + +interface StubFinding { + /** Where the interception lives (the spec, or the helper it imports). */ + where: string; + /** What was found. */ + detail: string; +} + +/** + * The first argument of a call whose open paren ends at `start`: a string, + * template or regex literal is returned whole; anything else is returned up to + * the first comma or closing paren and flagged as not a literal. + */ +function firstArgument( + source: string, + start: number, +): { text: string; literal: boolean } { + const opener = source[start]; + if (opener === '"' || opener === "'" || opener === "`") { + const end = source.indexOf(opener, start + 1); + return { text: source.slice(start, end + 1), literal: true }; + } + if (opener === "/") { + // A regex literal: up to the next unescaped slash. + let i = start + 1; + while (i < source.length && !(source[i] === "/" && source[i - 1] !== "\\")) + i += 1; + return { text: source.slice(start, i + 1), literal: true }; + } + const rest = source.slice(start); + const stop = rest.search(/[,)]/); + return { text: rest.slice(0, stop === -1 ? 80 : stop), literal: false }; +} + +/** Every `/api` (or unprovable) interception in one source text. */ +function apiStubsIn(source: string): string[] { + const code = stripCommentLines(source); + const hits: string[] = []; + for (const m of code.matchAll(ROUTE_CALL)) { + const arg = firstArgument(code, (m.index ?? 0) + m[0].length); + if (!arg.literal) { + hits.push(`route pattern is not a literal: ${arg.text.trim()}`); + } else if (/api/i.test(arg.text)) { + hits.push(`route intercepts the app's API: ${arg.text}`); + } + } + return hits; +} + +/** The names a spec imports from each relative module. */ +function relativeImports( + source: string, +): Array<{ names: string[]; from: string }> { + const code = stripCommentLines(source); + const out: Array<{ names: string[]; from: string }> = []; + for (const m of code.matchAll( + /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']+)["']/g, + )) { + const names = m[1] + .split(",") + .map((n) => + n + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0] + .trim(), + ) + .filter(Boolean); + out.push({ names, from: m[2] }); + } + return out; +} + +/** + * Exported functions of a helper that intercept `/api`, by name. The helper is + * cut at each top-level `export`, so each slice is one export's body. + */ +function stubbingExports(helperSource: string): string[] { + const code = stripCommentLines(helperSource); + const starts = [...code.matchAll(/^export\s+/gm)].map((m) => m.index ?? 0); + const names: string[] = []; + starts.forEach((start, i) => { + const slice = code.slice(start, starts[i + 1] ?? code.length); + const name = slice.match( + /^export\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+)([A-Za-z0-9_$]+)/, + )?.[1]; + if (name && apiStubsIn(slice).length > 0) names.push(name); + }); + return names; +} + +function resolveHelper(specRel: string, from: string): string | null { + const base = join(E2E, dirname(specRel), from); + for (const candidate of [`${base}.ts`, `${base}/index.ts`, base]) { + if (candidate.endsWith(".ts") && existsSync(candidate)) return candidate; + } + return null; +} + +/** Everything that stubs `/api` in one spec file, its imported helpers included. */ +function findApiStubs( + specRel: string, + source: string, + readHelper: (absolute: string) => string = readSourceText, +): StubFinding[] { + const findings: StubFinding[] = apiStubsIn(source).map((detail) => ({ + where: specRel, + detail, + })); + for (const { names, from } of relativeImports(source)) { + const helper = resolveHelper(specRel, from); + if (!helper) continue; + const stubbing = new Set(stubbingExports(readHelper(helper))); + for (const name of names) { + if (stubbing.has(name)) { + findings.push({ + where: relative(E2E, helper), + detail: `\`${name}\` (imported by ${specRel}) intercepts the app's API`, + }); + } + } + } + return findings; +} + +function specFiles(): string[] { + return walkSourceFiles(E2E, { floor: 100, extensions: [".ts"] }).filter( + (rel) => !rel.includes("/") && rel.endsWith(".spec.ts"), + ); +} + +function criticalTests(): Array<{ file: string; title: string }> { + const tests: Array<{ file: string; title: string }> = []; + for (const file of specFiles()) { + const code = stripCommentLines(readSourceText(join(E2E, file))); + for (const m of code.matchAll(CRITICAL_TEST)) { + tests.push({ file, title: m[2] }); + } + } + return tests; +} + +describe("@critical e2e journeys run against the real API", () => { + it("finds the tagged journeys", () => { + requireFloor("@critical e2e tests", criticalTests(), CRITICAL_TEST_FLOOR); + }); + + it("no file carrying @critical intercepts /api, directly or through a helper", () => { + const files = new Set(criticalTests().map((t) => t.file)); + // A file that mentions the tag outside a test title (a describe title, a + // `grep` option) is held to the same rule. + for (const file of specFiles()) { + if ( + stripCommentLines(readSourceText(join(E2E, file))).includes("@critical") + ) + files.add(file); + } + const offenders = [...files].flatMap((file) => + findApiStubs(file, readSourceText(join(E2E, file))).map( + (f) => `${f.where}: ${f.detail}`, + ), + ); + expect(offenders).toEqual([]); + }); + + describe("the matchers fire", () => { + it("on a glob, a regex and a non-literal pattern", () => { + expect( + apiStubsIn(`await page.route("**/api/measurements*", f);`), + ).toHaveLength(1); + expect( + apiStubsIn(`await page.route(/\\/api\\/analytics(\\?|$)/, f);`), + ).toHaveLength(1); + expect(apiStubsIn(`await context.route(PATTERN, f);`)).toHaveLength(1); + expect( + apiStubsIn(`await page.routeFromHAR("api.har", { update: false });`), + ).toHaveLength(1); + }); + + it("not on a third-party host, a comment, or an unrelated call", () => { + expect( + apiStubsIn(`await page.route("https://tiles.example.org/**", f);`), + ).toEqual([]); + expect(apiStubsIn(`// page.route("**/api/x", f)`)).toEqual([]); + expect(apiStubsIn(`router.push("/api/x");`)).toEqual([]); + }); + + it("on a stubbing helper imported by name, and not on its siblings", () => { + const helper = [ + `export function mockThings(page) {`, + ` void page.route("**/api/things**", (r) => r.fulfill({}));`, + `}`, + `export async function openThing(page) {`, + ` await page.goto("/things");`, + `}`, + ].join("\n"); + expect(stubbingExports(helper)).toEqual(["mockThings"]); + + const read = () => helper; + const uses = `import { mockThings, openThing } from "./medications-wizard-helpers";`; + expect(findApiStubs("x.spec.ts", uses, read)).toHaveLength(1); + const clean = `import { openThing } from "./medications-wizard-helpers";`; + expect(findApiStubs("x.spec.ts", clean, read)).toEqual([]); + }); + }); +}); From 518a381ff9f4ae0924b79ce7d7de4779f34f9d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:55:19 +0200 Subject: [PATCH 02/10] test(e2e): sign in with a password and a passkey, and sign out, against the real server The login spec stubbed the login route to prove the form shows an error. It now spends a real 401, and three journeys run on the sign-in account of their project: a password sign-in that lands on the dashboard as that account, a sign-out whose old cookie is refused afterwards (with a positive control before it), and a passkey registered in settings through Chromium's virtual authenticator that signs the account back in. --- e2e/login.spec.ts | 278 +++++++++++++++--- e2e/setup/critical-fixture.ts | 10 + e2e/setup/global-setup.ts | 5 +- .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 4 files changed, 245 insertions(+), 50 deletions(-) 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 = From 7445b698425bbacfc17dfd1cb38053c26f22783d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:58:36 +0200 Subject: [PATCH 03/10] test(e2e): add a weight through the dashboard against the real server The add-measurement flow answered its own POST and list read, so it proved that the list renders a row the test typed in. It now runs on the measurement journey's account, emptied first, and checks that the reading the server stored is the one entered and that the dashboard tile, the weight chart's data table and the day view all show it. --- e2e/measurement-flow.spec.ts | 259 ++++++++---------- .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 112 insertions(+), 149 deletions(-) diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 414dba6f6..7cc228024 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -1,140 +1,92 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; import { openMenu } from "./open-menu"; /** - * Add-measurement flow — exercises the dashboard's quick-entry dropdown, - * the dialog-mounted MeasurementForm, the POST to /api/measurements, and - * the resulting query invalidation that surfaces the row on - * `/measurements`. + * Add-measurement flow, against the real server: the dashboard's quick-entry + * menu, the dialog-mounted MeasurementForm, the real POST to + * `/api/measurements`, and then every surface that has to agree about the + * reading afterwards: the dashboard tile, the weight chart (through the data + * table that lists exactly the points it drew) and the day view. * - * Weight is the simplest type (single numeric input + datetime), so it - * keeps the spec focused on the round-trip rather than the BP three-field - * batched submit. + * Until v1.46 this spec answered its own POST and its own list read with a + * route stub, so it proved that the list renders a row the test typed in. + * Nothing here is stubbed now. The account is the measurement journey's own, + * one per project (`critical-fixture.ts`), and is emptied of readings before + * the test, so "the tile shows the weight" can only mean the weight this test + * entered. + * + * Weight is the simplest type (single numeric input + datetime), so it keeps + * the spec on the round trip rather than on the blood-pressure batch. */ + +/** Distinct from anything a fixture seeds, so a match is this reading. */ +const WEIGHT_KG = "81.7"; + +/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */ +function todayInBerlin(): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} + +/** The account's weight readings, as the list route answers them. */ +async function storedWeights( + page: Page, +): Promise> { + const res = await page.request.get("/api/measurements?type=WEIGHT"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { measurements: Array<{ value: number; unit: string }> }; + }; + return data.measurements.map(({ value, unit }) => ({ value, unit })); +} + test.describe("add measurement flow", () => { - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: criticalStorageState("measurement") }); // This journey lands on the dashboard, which is the most expensive page in - // the product to render cold: the route segment suspends behind the server - // render and shows `loading.tsx` until it resolves. On a shared runner with - // the whole suite in flight that has overrun the 30 s default, and the test - // then died on the quick-add trigger rather than on anything it is about. - // The subject here is the add-then-appears round trip, not the dashboard's - // cold-start latency, so the budget is the page's and the assertions stay - // exactly as tight as they were. + // the product to render cold, and then reads three more surfaces. The + // assertions stay as tight as they are; the budget is the pages'. test.setTimeout(90_000); - test("creating a weight reading surfaces it in the list", async ({ + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("measurement", testInfo), [ + "DELETE FROM measurement_rollups WHERE user_id = $1", + "DELETE FROM measurements WHERE user_id = $1", + ]); + }); + + test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({ page, }) => { - // Capture the POST payload so we can assert against it independently - // of any list-fetch response Playwright might cache. Match both - // `/api/measurements` (POST creates a reading) and the list query - // `/api/measurements?type=...&limit=...&offset=...` (GET). - let postedBody: unknown = null; - await page.route("**/api/measurements*", async (route) => { - const req = route.request(); - if (req.method() === "POST") { - postedBody = JSON.parse(req.postData() ?? "null"); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { id: "m_e2e_1", success: true }, - error: null, - }), - }); - return; - } - // GET — return the just-posted reading so the list view shows it. - // The list expects `data.measurements: Measurement[]` plus - // `data.meta.total`; chart fetches use the same envelope. - const body = postedBody as { - type?: string; - value?: number; - measuredAt?: string; - } | null; - // The list-page `Measurement` shape requires `unit` and `source` - // — without them the row crashes the render and the e2e poll - // never sees "78.4". Mock with the same fields a real reading - // carries. - const measurements = body - ? [ - { - id: "m_e2e_1", - type: body.type, - value: body.value, - unit: body.type === "WEIGHT" ? "kg" : "", - source: "MANUAL", - measuredAt: body.measuredAt, - notes: null, - }, - ] - : []; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { measurements, meta: { total: measurements.length } }, - error: null, - }), - }); - }); - - // Dashboard's analytics fetch — empty summaries are fine; we only - // need the page to render so we can click "Add". - // - // v1.4.39.3 — match `/api/analytics` AND any sliced variant - // (`?slice=summaries`). The v1.4.39.2 dashboard split fires both - // requests in parallel; the previous string glob missed the slim - // variant which then fell through to the real route and slowed - // the page paint enough that the post-mutation list query never - // surfaced inside the 10 s window. - await page.route(/\/api\/analytics(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, - error: null, - }), - }), - ); - await page.route("**/api/mood/analytics", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { entries: [], summary: { count: 0 } }, - error: null, - }), - }), - ); - await page.goto("/", { waitUntil: "domcontentloaded" }); - // Open the "Add" dropdown — the dashboard's quick-entry trigger sits - // at the top-right of `
`. Scope the locator there so we don't - // accidentally match an "Add" button on the sidebar. - const main = page.locator("main"); - const addTrigger = main + // The quick-entry trigger sits at the top of `
`. Waiting for it, + // rather than for the route skeleton to go, is waiting for the thing the + // next line clicks. + const addTrigger = page + .locator("main") .getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i }) .first(); - // Wait for the trigger itself rather than for the route-level skeleton to - // go. The skeleton is also absent while the protected shell holds the - // whole page behind its hydration gate, so its disappearance is not a - // statement about the suspended segment having resolved; the control the - // next line clicks is. `networkidle` says nothing about either. await expect(addTrigger).toBeVisible({ timeout: 60_000 }); - await page.waitForLoadState("networkidle"); + // The account holds no weight yet, so whatever the surfaces below show + // can only come from this reading. + expect(await storedWeights(page)).toEqual([]); await openMenu(page, addTrigger); - // v1.5: the menu items now have distinct labels — the - // measurement entry says "Measurement" / "Messung" instead of "Add", - // so we can target it directly. // The desktop rail opens a dropdown, the phone shell the capture sheet // the bottom nav's plus opens; the measurement entry is in either. await page @@ -143,52 +95,63 @@ test.describe("add measurement flow", () => { .first() .click(); - // Form should be visible inside the dialog. Switch type to WEIGHT - // (combo-box defaults to BLOOD_PRESSURE). const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); - // The Select trigger renders the current label — click to open and - // pick the weight option. + // The type select defaults to blood pressure. await dialog.getByRole("combobox").first().click(); await page .getByRole("option", { name: /weight|gewicht/i }) .first() .click(); + await dialog.locator("#value").fill(WEIGHT_KG); - // Weight is single-value mode — fill the value field with 78.4 kg. - const valueInput = dialog.locator("#value"); - await valueInput.fill("78.4"); - - // Save + const created = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/measurements" && + res.request().method() === "POST", + ); await dialog.getByRole("button", { name: /save|speichern/i }).click(); + const response = await created; + expect(response.status()).toBe(201); + await expect(dialog).toBeHidden(); - // Dialog closes when the mutation succeeds - await expect(dialog).toBeHidden({ timeout: 5_000 }); - - // Assert the POST went through with the expected shape - expect(postedBody).toMatchObject({ - type: "WEIGHT", - value: 78.4, + await test.step("the server stored exactly that reading", async () => { + expect(await storedWeights(page)).toEqual([ + { value: Number(WEIGHT_KG), unit: "kg" }, + ]); }); - // Navigate to the list and confirm the entry surfaces. The list - // page renders entries from /api/measurements which we've mocked - // to echo the just-posted row. - await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await test.step("the dashboard tile", async () => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + const tile = page.locator( + '[data-slot="dashboard-tile-link"][data-tile-id="weight"]', + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + WEIGHT_KG, + ); + }); - // The list renders a desktop table AND a mobile card grid (one is - // hidden via `md:hidden`/`hidden md:block`). At least one match - // means our row landed in the DOM regardless of viewport. - await expect - .poll( - async () => - await page - .locator("main") - .getByText(/78[.,]4/) - .count(), - { timeout: 10_000 }, - ) - .toBeGreaterThanOrEqual(1); + await test.step("the weight chart", async () => { + await page.goto("/insights/weight", { waitUntil: "domcontentloaded" }); + const table = page.locator('[data-slot="chart-data-table"]').first(); + await expect(table).toBeVisible({ timeout: 30_000 }); + await table.locator('[data-slot="chart-data-table-toggle"]').click(); + const rows = table.locator('[data-slot="chart-data-table-row"]'); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText(WEIGHT_KG); + }); + + await test.step("the day view", async () => { + await page.goto(`/measurements?day=${todayInBerlin()}`, { + waitUntil: "domcontentloaded", + }); + const value = page.locator( + '[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]', + ); + await expect(value).toBeVisible({ timeout: 30_000 }); + await expect(value).toContainText(WEIGHT_KG); + }); }); }); 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 418b18e58..856884d96 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 = 10; +const CRITICAL_TEST_FLOOR = 11; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From f185855d76dfb4e96d0970e0b1c1f93a7d0b6cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:58:39 +0200 Subject: [PATCH 04/10] test(e2e): log a mood at a chosen time against the real server The mood timestamp spec answered the POST and the list read itself. It now runs on the mood journey's account: the instant typed into the form is the instant stored, and the entry is in the list, also after a reload. --- e2e/mood-timestamp.spec.ts | 98 +++++++++++++------ .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index f157aa674..92f0a43f9 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -1,39 +1,53 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; + +/** + * Logging a mood through the form, against the real server: the time stays + * visible while the details are collapsed, the instant typed into it is the + * instant stored, and the entry is in the list afterwards. + * + * Until v1.46 this spec answered the POST and the list read itself, so the + * list was always empty and the POST always succeeded. The account is now the + * mood journey's own, one per project (`critical-fixture.ts`), emptied before + * the test; the empty state the form opens from is the account's real one. + */ + +/** The account's mood entries, as the list route answers them. */ +async function storedMoods( + page: Page, +): Promise> { + const res = await page.request.get("/api/mood-entries"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { entries: Array<{ mood: string; moodLoggedAt: string }> }; + }; + return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt })); +} test.describe("mood timestamp capture", () => { - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: criticalStorageState("mood") }); - test("keeps Time visible while details are collapsed and submits the selected instant", async ({ + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("mood", testInfo), [ + "DELETE FROM mood_entry_rollups WHERE user_id = $1", + "DELETE FROM mood_entries WHERE user_id = $1", + ]); + }); + + test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({ page, }) => { - let submitted: Record | null = null; - - await page.route("**/api/mood-entries*", async (route) => { - if (route.request().method() === "POST") { - submitted = route.request().postDataJSON() as Record; - await route.fulfill({ - status: 201, - contentType: "application/json", - body: JSON.stringify({ data: { id: "created-mood" }, error: null }), - }); - return; - } - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } }, - error: null, - }), - }); - }); - await page.goto("/mood", { waitUntil: "domcontentloaded" }); - // The list is mocked empty, so its empty state carries the add action - // (the header drops its own while there is nothing to list). + // An empty list carries the add action in its empty state (the header + // drops its own while there is nothing to list). await page.getByRole("button", { name: "Log your first mood" }).click(); await page.getByRole("radio", { name: "Amazing" }).click(); @@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => { await timeInput.fill("09:45"); await timeInput.blur(); + const created = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/mood-entries" && + res.request().method() === "POST", + ); await page.getByRole("button", { name: "Save", exact: true }).click(); + expect((await created).status()).toBe(201); - await expect.poll(() => submitted).not.toBeNull(); - expect(submitted).toMatchObject({ - mood: "SUPER_GUT", - moodLoggedAt: "2026-01-15T08:45:00.000Z", + // 09:45 in Berlin in January is 08:45 UTC. + expect(await storedMoods(page)).toEqual([ + { mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" }, + ]); + + // The list shows it, at either width (the table and the phone cards each + // carry the marker; one of them is on screen). + const rows = page.locator('[data-slot="mood-rows"]').filter({ + visible: true, }); + await expect(rows).toBeVisible(); + await expect(rows).toContainText("Amazing"); + await expect(rows).toContainText("09:45"); + + // And still after a reload, which reads the list from the server again + // rather than from the cache the mutation updated. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(rows).toContainText("Amazing"); + await expect(rows).toContainText("09:45"); }); }); 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 856884d96..c571d4bbd 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 = 11; +const CRITICAL_TEST_FLOOR = 12; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From 0bf6a54be060c9ed7ea3641014c79bcd5f1ea2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:00:00 +0200 Subject: [PATCH 05/10] test(e2e): import lab results from a CSV on an account of its own The import journey ran on the shared account in both projects, so one project's duplicate verdict could rest on the other's import. It now runs on the labs journey's account, emptied first, checks that the dry run writes nothing and the import writes exactly the accepted row, and finds the result on the labs page before the second run. --- e2e/record-csv-import.spec.ts | 101 ++++++++++++++---- .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/e2e/record-csv-import.spec.ts b/e2e/record-csv-import.spec.ts index 5ad83d964..d1168700a 100644 --- a/e2e/record-csv-import.spec.ts +++ b/e2e/record-csv-import.spec.ts @@ -1,29 +1,69 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; /** - * Lab results from a spreadsheet (#1215), at phone width, against the real - * server: the mapping is suggested from the headers, the check reports a - * refused row by line and writes nothing, the import writes the rest, and a - * second import of the same file only finds duplicates. + * Lab results from a spreadsheet (#1215), against the real server: the + * mapping is suggested from the headers, the check reports a refused row by + * line and writes nothing, the import writes the rest, the imported row is on + * the labs page, and a second import of the same file only finds duplicates. + * + * The account is the labs journey's own, one per project + * (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on + * the shared account in both projects, so the "only duplicates" verdict of + * one project could be the other project's first import. */ + +const ANALYTE = "E2E Marker CSV"; + function csv(): string { const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); return [ "Analyte;Value;Unit;Date", - `E2E Marker CSV;5,4;mmol/L;${day}`, - `E2E Marker CSV;1.234;mmol/L;${day}`, + // A decimal comma, as a German lab export writes it. + `${ANALYTE};5,4;mmol/L;${day}`, + // Either 1.234 or 1234: refused as ambiguous rather than guessed. + `${ANALYTE};1.234;mmol/L;${day}`, ].join("\n"); } +/** The account's lab results, as the list route answers them. */ +async function storedResults( + page: Page, +): Promise> { + const res = await page.request.get("/api/labs"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { + results: Array<{ analyte: string; value: number | null; unit: string }>; + }; + }; + return data.results.map(({ analyte, value, unit }) => ({ + analyte, + value, + unit, + })); +} + test.describe("record CSV import", () => { - test.use({ - storageState: STORAGE_STATE_PATH, - viewport: { width: 390, height: 844 }, + test.use({ storageState: criticalStorageState("labs") }); + + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("labs", testInfo), [ + "DELETE FROM lab_results WHERE user_id = $1", + ]); }); - test("checks, imports and then only finds duplicates", async ({ page }) => { + test("checks, imports, lists the result and then only finds duplicates @critical", async ({ + page, + }) => { await page.goto("/settings/export"); await page.locator('[data-slot="record-import-open-labs"]').click(); const sheet = page.locator('[data-slot="record-import-sheet"]'); @@ -48,19 +88,42 @@ test.describe("record CSV import", () => { await expect( page.locator('[data-slot="record-import-error-row"]'), ).toHaveCount(1); + // The check is a dry run. + expect(await storedResults(page)).toEqual([]); await page.locator('[data-slot="record-import-run"]').click(); await expect( page.locator('[data-slot="record-import-done"]'), ).toBeVisible(); + expect(await storedResults(page)).toEqual([ + { analyte: ANALYTE, value: 5.4, unit: "mmol/L" }, + ]); - // Second run of the same file. - await page.keyboard.press("Escape"); - await page.locator('[data-slot="record-import-open-labs"]').click(); - await page.locator('[data-slot="record-import-file"]').setInputFiles(file); - await page.locator('[data-slot="record-import-check"]').click(); - await expect( - page.locator('[data-slot="record-import-run"]'), - ).toBeDisabled(); + await test.step("the result is on the labs page", async () => { + await page.goto("/labs"); + const analyte = page + .locator('[data-slot="lab-list-analyte"]') + .filter({ hasText: ANALYTE }) + .filter({ visible: true }); + await expect(analyte).toHaveCount(1); + // The cell the name heads carries the latest reading under it. + await expect(analyte.locator("xpath=../..")).toContainText("5.4"); + }); + + await test.step("a second run of the same file", async () => { + await page.goto("/settings/export"); + await page.locator('[data-slot="record-import-open-labs"]').click(); + await page + .locator('[data-slot="record-import-file"]') + .setInputFiles(file); + await page.locator('[data-slot="record-import-check"]').click(); + await expect( + page.locator('[data-slot="record-import-checked"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="record-import-run"]'), + ).toBeDisabled(); + expect(await storedResults(page)).toHaveLength(1); + }); }); }); 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 c571d4bbd..5a51fb4a1 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 = 12; +const CRITICAL_TEST_FLOOR = 13; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From 0e9daa51f9a9fb0926bad6bf6da35143b04a8690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:02:01 +0200 Subject: [PATCH 06/10] test(e2e): read a WHO-5 check-in word for word in German and Brazilian Portuguese The check-in journeys anchor on data attributes so they read the same in every language, which leaves the words themselves unchecked. Two journeys on the wellbeing account now read the stem, the first and last item and the answer labels in German (the validated WHO translation, reproduced exactly) and in Brazilian Portuguese (the translation written for the app, with its note), submit, and check the score on screen against the assessment the server stored, with its language and kind of wording. --- e2e/mental-health-check-in.spec.ts | 179 ++++++++++++++++++ .../e2e-critical-no-api-stub-guard.test.ts | 5 +- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/e2e/mental-health-check-in.spec.ts b/e2e/mental-health-check-in.spec.ts index 85e4b77bb..85440382e 100644 --- a/e2e/mental-health-check-in.spec.ts +++ b/e2e/mental-health-check-in.spec.ts @@ -2,6 +2,11 @@ import AxeBuilder from "@axe-core/playwright"; import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** @@ -346,3 +351,177 @@ test.describe("mental-health check-in, in the reader's language", () => { ).toHaveCount(0); }); }); + +/** + * A WHO-5 check-in, word for word, in German and in Brazilian Portuguese, + * on the wellbeing journey's own account (one per project, + * `critical-fixture.ts`) against the real server. + * + * The journeys above anchor on data attributes so they read the same in every + * language; this one reads the words. The German text is the validated WHO + * translation, which the form has to reproduce exactly; the Brazilian one is + * HealthLog's own and has to say so. Either way the stored assessment records + * the language and the kind of wording it was answered in, and the score the + * server computed is the one on screen. + * + * The strings are written out here rather than imported from + * `src/lib/mental-health/wording/`: a test that read the expected text from + * the module it checks would pass on any edit to that module. + */ +const WHO5_WORDING = { + de: { + validated: true, + progress: "Frage 1 von 5", + stem: "In den letzten zwei Wochen …", + first: "… war ich froh und guter Laune", + last: "… war mein Alltag voller Dinge, die mich interessieren", + // In the order the form prints them, best first. + anchors: [ + "Die ganze Zeit", + "Meistens", + "Etwas mehr als die Hälfte der Zeit", + "Etwas weniger als die Hälfte der Zeit", + "Ab und zu", + "Zu keinem Zeitpunkt", + ], + }, + "pt-BR": { + validated: false, + progress: "Pergunta 1 de 5", + stem: "Nas últimas duas semanas …", + first: "Eu me senti alegre e de bom humor", + last: "Meu dia a dia foi preenchido com coisas que me interessam", + anchors: [ + "O tempo todo", + "A maior parte do tempo", + "Mais da metade do tempo", + "Menos da metade do tempo", + "Algumas vezes", + "Em nenhum momento", + ], + }, +} as const; + +/** The account's stored assessments, as the list route answers them. */ +async function storedAssessments(page: Page): Promise< + Array<{ + instrument: string; + locale: string; + wording: string | null; + totalScore: number; + }> +> { + const res = await page.request.get("/api/mental-health/assessments"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { + assessments: Array<{ + instrument: string; + locale: string; + wording: string | null; + totalScore: number; + }>; + }; + }; + return data.assessments.map( + ({ instrument, locale, wording, totalScore }) => ({ + instrument, + locale, + wording, + totalScore, + }), + ); +} + +test.describe("WHO-5 check-in, word for word", () => { + test.use({ storageState: criticalStorageState("wellbeing") }); + // Both locales write to the one account of the project, and each test + // empties it first; in order, so neither empties the other's mid-run. + test.describe.configure({ mode: "default" }); + + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [ + "DELETE FROM mental_health_assessments WHERE user_id = $1", + ]); + }); + + for (const [locale, words] of Object.entries(WHO5_WORDING)) { + test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({ + page, + }) => { + test.setTimeout(90_000); + await openInLocale(page, locale); + await expect(page.locator("html")).toHaveAttribute("lang", locale); + await page + .locator('[data-slot="instrument-card"][data-instrument="WHO5"]') + .locator('[data-slot="instrument-card-start"]') + .click(); + + const wizard = page.locator( + '[data-slot="mental-health-check-in"][data-instrument="WHO5"]', + ); + const step = (n: number) => + wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`); + + await expect(step(1)).toBeVisible(); + await expect( + wizard.locator('[data-slot="check-in-progress"]'), + ).toContainText(words.progress); + await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText( + words.stem, + ); + await expect( + step(1).locator('[data-slot="check-in-question"]'), + ).toHaveText(words.first); + await expect(step(1).locator('[role="radio"]')).toHaveText([ + ...words.anchors, + ]); + await expect( + wizard.locator('[data-slot="check-in-unvalidated-note"]'), + ).toHaveCount(words.validated ? 0 : 1); + + // "Meistens" / "A maior parte do tempo" (value 4) on every item. + for (let n = 1; n <= 5; n++) { + await expect(step(n)).toBeVisible(); + if (n === 5) { + await expect( + step(n).locator('[data-slot="check-in-question"]'), + ).toHaveText(words.last); + } + await step(n).getByRole("radio", { name: words.anchors[1] }).click(); + } + + await expect( + wizard.locator('[data-slot="check-in-review"]'), + ).toBeVisible(); + const created = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/mental-health/assessments" && + res.request().method() === "POST", + ); + await wizard.locator('[data-slot="check-in-submit"]').click(); + expect((await created).status()).toBe(201); + + // Five items at 4, times WHO-5's multiplier of 4. + const result = page.locator( + '[data-slot="mental-health-result"][data-instrument="WHO5"]', + ); + await expect(result.locator('[data-slot="result-score"]')).toContainText( + "80", + ); + await expect( + result.locator('[data-slot="result-wording-note"]'), + ).toHaveCount(words.validated ? 0 : 1); + + expect(await storedAssessments(page)).toEqual([ + { + instrument: "WHO5", + locale, + wording: words.validated ? "validated" : "unvalidated_translation", + totalScore: 80, + }, + ]); + }); + } +}); 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 5a51fb4a1..6e85af0a0 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -47,9 +47,10 @@ const E2E = join(process.cwd(), "e2e"); /** * The `@critical` tests found in `e2e/*.spec.ts`. Pinned at the real count: * the set is the release gate, so a journey that loses its tag is a change - * somebody should see here. + * somebody should see here. It counts title literals in the source, so a + * title written once inside a loop counts once however many tests it names. */ -const CRITICAL_TEST_FLOOR = 13; +const CRITICAL_TEST_FLOOR = 14; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From 34a93b883ff4caa17799ce7b2c5f385c525f903e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:04:04 +0200 Subject: [PATCH 07/10] test(e2e): a settings change persists on the server The time-format choice is changed through the account settings and read back after a reload, in a second browser context that shares only the session cookie, and on /api/auth/me, on the settings journey's own account. --- e2e/settings-persistence.spec.ts | 82 +++++++++++++++++++ .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 e2e/settings-persistence.spec.ts diff --git a/e2e/settings-persistence.spec.ts b/e2e/settings-persistence.spec.ts new file mode 100644 index 000000000..cdf7399de --- /dev/null +++ b/e2e/settings-persistence.spec.ts @@ -0,0 +1,82 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; + +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; + +/** + * A settings change is the server's, not the tab's. + * + * The time-format choice is changed through the account settings, then read + * back three ways that a client-side cache could not fake: after a reload, in + * a second browser context that shares the session but none of the first + * one's storage, and on `/api/auth/me`, which every client renders from. + * + * The account is the settings journey's own, one per project + * (`critical-fixture.ts`), and is put back to the default first. + */ + +async function openTimeFormat(page: Page) { + await page.goto("/settings/account"); + const select = page.getByTestId("settings-time-format-select"); + await expect(select).toBeVisible({ timeout: 30_000 }); + // The control is disabled until the account payload has landed. + await expect(select).toBeEnabled(); + return select; +} + +async function publishedTimeFormat(page: Page): Promise { + const res = await page.request.get("/api/auth/me"); + expect(res.status()).toBe(200); + return ((await res.json()) as { data: { timeFormat: string } }).data + .timeFormat; +} + +test.describe("settings persistence", () => { + test.use({ storageState: criticalStorageState("settings") }); + + test.beforeEach(async ({}, testInfo) => { + await resetCriticalAccount(criticalAccount("settings", testInfo), [ + "UPDATE users SET time_format = 'AUTO' WHERE id = $1", + ]); + }); + + test("a time-format change survives a reload and reaches a second browser @critical", async ({ + page, + browser, + }, testInfo) => { + const select = await openTimeFormat(page); + await expect(select).toHaveValue("AUTO"); + + const saved = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/user/profile" && + res.request().method() === "PATCH", + ); + await select.selectOption("H12"); + expect((await saved).status()).toBe(200); + expect(await publishedTimeFormat(page)).toBe("H12"); + + await page.reload(); + await expect(page.getByTestId("settings-time-format-select")).toHaveValue( + "H12", + ); + + // Same session cookie, nothing else: no local storage, no IndexedDB + // query snapshot, no in-memory cache. + const other = await browser.newContext({ + storageState: criticalAccount("settings", testInfo).storageStatePath, + baseURL: testInfo.project.use.baseURL, + }); + try { + const second = await other.newPage(); + await expect(await openTimeFormat(second)).toHaveValue("H12"); + } finally { + await other.close(); + } + }); +}); 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 6e85af0a0..6486deaf0 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -50,7 +50,7 @@ const E2E = join(process.cwd(), "e2e"); * somebody should see here. It counts title literals in the source, so a * title written once inside a loop counts once however many tests it names. */ -const CRITICAL_TEST_FLOOR = 14; +const CRITICAL_TEST_FLOOR = 15; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From de970ac07928d1b53f45a6dacc185417fccae733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:12:45 +0200 Subject: [PATCH 08/10] test(e2e): restore a person's full backup into their recreated account The backup journey restored a snapshot over the account it came from. A fifth act now follows the runbook's new-host path: a second account writes a reading and a mood entry and downloads its full backup from the export card, the account is recreated empty under the same id, the admin uploads the file in the backups console and restores it, and the person signs in to the same rows under the same ids. The restore wait is shared by both restores. --- e2e/backup-restore-journey.spec.ts | 264 +++++++++++++++--- e2e/setup/critical-fixture.ts | 77 +++++ .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 3 files changed, 302 insertions(+), 41 deletions(-) diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index 581fdb204..a5193ca58 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -18,7 +18,11 @@ * 5. what was deleted is back under its ORIGINAL id, and what arrived after * the snapshot is gone — which is what "the restore replaces the * account's data tables" in `docs/ops/backup-restore.md` means when it is - * spelled out. + * spelled out; + * 6. a second person downloads their own full backup, their account is + * recreated empty under the same id (the runbook's new host), the admin + * uploads the file and restores it, and the person signs in to the same + * rows under the same ids. * * Refusing controls, because a restore path that cannot say no is worse than * none: a stored copy with one flipped ciphertext byte is refused and changes @@ -36,19 +40,20 @@ * * Everything the journey does, it does through the app: the readings and the * document ride the page's own `fetch`, the archive and the snapshot ride the - * real buttons, the restore rides the typed-confirmation dialog. The only two + * real buttons, the restore rides the typed-confirmation dialog. The only * things reaching Postgres directly are the ones a browser cannot express — - * resetting the account between repetitions, and corrupting one stored copy so - * the authenticated envelope has something to refuse. Both live in - * `setup/backup-journey-fixture.ts`. + * resetting the accounts between repetitions, corrupting one stored copy so + * the authenticated envelope has something to refuse (both in + * `setup/backup-journey-fixture.ts`), and recreating the second account under + * its own id (`setup/critical-fixture.ts`). * - * Serial and single-project: the four tests are four acts of one journey, they - * mutate the account destructively, and the last one moves a session's record + * Serial and single-project: the five tests are five acts of one journey, they + * mutate the accounts destructively, and the last one moves a session's record * selector. Its jar is its own for the reason `global-setup.ts` spells out. */ import { readFile } from "node:fs/promises"; -import type { Page } from "@playwright/test"; +import type { Page, Response } from "@playwright/test"; import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive"; @@ -65,6 +70,13 @@ import { storedMeasurements, storeTamperedCopy, } from "./setup/backup-journey-fixture"; +import { + accountRowCounts, + clearLoginBuckets, + criticalAccount, + recreateCriticalAccount, + resetCriticalAccount, +} from "./setup/critical-fixture"; import { actWithReproof, completeReproofWithPassword, @@ -91,6 +103,8 @@ const RESTORED_WEIGHT_KG = 81.4; const UNTOUCHED_PULSE_BPM = 58; /** The reading that arrives AFTER the snapshot and must not survive it. */ const POST_SNAPSHOT_PULSE_BPM = 66; +/** The second account's reading, carried through its own backup. */ +const FRESH_ACCOUNT_WEIGHT_KG = 67.3; const MEDICATION_NAME = "E2E Backup Tablet"; @@ -291,6 +305,45 @@ async function restoreThroughDialog(page: Page, id: string) { return answered; } +/** + * Wait for a queued restore to finish, and fail with its code if it failed. + * + * The restore runs on the `backup-restore` queue: the request answers 202 + * with the job and its status route, and the worker inside the server under + * test does the work. Nothing after this is read until the job says it is + * done, so every assertion is about the finished restore rather than a moment + * in the middle of it. + */ +async function awaitRestore(page: Page, response: Response): Promise { + expect(response.status()).toBe(202); + const queued = (await response.json()) as { + data: { jobId: string; status: string; statusUrl: string }; + }; + expect(queued.data.status).toBe("queued"); + expect(queued.data.statusUrl).toBe( + `/api/admin/backups/restores/${queued.data.jobId}`, + ); + await expect + .poll( + async () => { + const res = await page.request.get(queued.data.statusUrl); + const body = (await res.json()) as { + data: { + status: string; + failure: { code: string } | null; + } | null; + }; + // A failed job ends the wait with its code in the message instead of + // running the clock out on a state that will never change. + return body.data?.status === "failed" + ? `failed: ${body.data.failure?.code ?? "unknown"}` + : body.data?.status; + }, + { timeout: 60_000, intervals: [250, 500, 1_000] }, + ) + .toBe("succeeded"); +} + test.describe("Backup and restore, through the settings surfaces", () => { test.use({ storageState: BACKUP_STORAGE_STATE_PATH }); @@ -563,39 +616,10 @@ test.describe("Backup and restore, through the settings surfaces", () => { expect(beforeRestore.map((m) => m.id)).toContain(laterId); await openBackupsConsole(page); - const response = await restoreThroughDialog(page, requireSnapshot()); - // The restore runs on the `backup-restore` queue: the request answers 202 - // with the job and its status route, and the worker inside the server - // under test does the work. Nothing below is read until the job says it - // is done, so every assertion is about the finished restore rather than - // a moment in the middle of it. - expect(response.status()).toBe(202); - const queued = (await response.json()) as { - data: { jobId: string; status: string; statusUrl: string }; - }; - expect(queued.data.status).toBe("queued"); - expect(queued.data.statusUrl).toBe( - `/api/admin/backups/restores/${queued.data.jobId}`, + await awaitRestore( + page, + await restoreThroughDialog(page, requireSnapshot()), ); - await expect - .poll( - async () => { - const res = await page.request.get(queued.data.statusUrl); - const body = (await res.json()) as { - data: { - status: string; - failure: { code: string } | null; - } | null; - }; - // A failed job ends the wait with its code in the message instead - // of running the clock out on a state that will never change. - return body.data?.status === "failed" - ? `failed: ${body.data.failure?.code ?? "unknown"}` - : body.data?.status; - }, - { timeout: 60_000, intervals: [250, 500, 1_000] }, - ) - .toBe("succeeded"); const afterRestore = await storedMeasurements(); @@ -650,6 +674,166 @@ test.describe("Backup and restore, through the settings surfaces", () => { ), ).toHaveCount(0); }); + + test("a full backup the operator restores brings the record back into a fresh account @critical", async ({ + page, + browser, + }, testInfo) => { + // The second account: a person with a record of their own, who downloads + // the file the export card offers. Its jar is its own; the act below + // gives it a session that needs a re-proof, like the admin's. + const person = criticalAccount("restore", testInfo); + await resetCriticalAccount(person, [ + "DELETE FROM measurement_rollups WHERE user_id = $1", + "DELETE FROM measurements WHERE user_id = $1", + "DELETE FROM mood_entry_rollups WHERE user_id = $1", + "DELETE FROM mood_entries WHERE user_id = $1", + "DELETE FROM rate_limits WHERE key = 'export:' || $1", + ]); + + const context = await browser.newContext({ + storageState: person.storageStatePath, + baseURL: testInfo.project.use.baseURL, + }); + let backupPath = ""; + let written: { weightId: string; moodId: string }; + try { + const own = await context.newPage(); + const endOwnSession = await useStaleSession(own, person.username); + try { + await own.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); + written = await own.evaluate(async (weightKg: number) => { + const post = async (path: string, body: unknown) => { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { data: { id: string } | null }; + if (!res.ok || !json.data) { + throw new Error(`${path} answered ${res.status}`); + } + return json.data.id; + }; + const at = new Date(Date.now() - 90 * 60_000).toISOString(); + return { + weightId: await post("/api/measurements", { + type: "WEIGHT", + value: weightKg, + measuredAt: at, + }), + moodId: await post("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: at, + }), + }; + }, FRESH_ACCOUNT_WEIGHT_KG); + + await own.goto("/settings/export", { waitUntil: "domcontentloaded" }); + await expect(own.getByTestId("export-card-full-backup")).toBeVisible({ + timeout: 30_000, + }); + const download = own.waitForEvent("download"); + await actWithReproof(own, person.password, () => + own.getByTestId("export-action-full-backup").click(), + ); + // Kept past the context: a download's own file goes with it. + backupPath = testInfo.outputPath("full-backup.json"); + await (await download).saveAs(backupPath); + } finally { + await endOwnSession(); + } + } finally { + await context.close(); + } + + // ── the account is gone, and comes back empty under the same id ────── + // What a new host is to the runbook (`docs/ops/backup-restore.md`): the + // backup names its owner by id, so the account is recreated under it + // before the upload. A browser cannot express this; it is the one act + // here that goes to Postgres. + await recreateCriticalAccount(person); + expect(await accountRowCounts(person)).toEqual({ + measurements: 0, + moodEntries: 0, + }); + + // ── the operator uploads the file and restores it ─────────────────── + await openBackupsConsole(page); + const uploaded = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/admin/backups/upload" && + res.request().method() === "POST" && + res.status() !== 401, + ); + await actWithReproof(page, E2E_BACKUP_ADMIN.password, () => + page + .locator('input[type="file"][accept*="application/gzip"]') + .setInputFiles(backupPath), + ); + const upload = await uploaded; + expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201); + const uploadId = ((await upload.json()) as { data: { id: string } }).data + .id; + await expect(snapshotRow(page, uploadId)).toHaveCount(1, { + timeout: 30_000, + }); + await awaitRestore(page, await restoreThroughDialog(page, uploadId)); + + // ── and the person signs in to their record ────────────────────────── + const back = await browser.newContext({ + baseURL: testInfo.project.use.baseURL, + }); + try { + await clearLoginBuckets(); + const login = await back.request.post("/api/auth/login", { + data: { email: person.username, password: person.password }, + }); + expect(login.status()).toBe(200); + const signedIn = await back.newPage(); + + // The same rows, under their own ids: a restore, not a re-entry. + const measurements = ( + (await (await signedIn.request.get("/api/measurements")).json()) as { + data: { measurements: Array<{ id: string; value: number }> }; + } + ).data.measurements; + expect(measurements).toEqual([ + expect.objectContaining({ + id: written.weightId, + value: FRESH_ACCOUNT_WEIGHT_KG, + }), + ]); + const moods = ( + (await (await signedIn.request.get("/api/mood-entries")).json()) as { + data: { entries: Array<{ id: string; mood: string }> }; + } + ).data.entries; + expect(moods).toEqual([ + expect.objectContaining({ id: written.moodId, mood: "OKAY" }), + ]); + + await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect( + signedIn + .locator( + '[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' + + `[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`, + ) + .first(), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await back.close(); + } + + // The admin's own record is untouched by a restore aimed at somebody else. + expect( + (await storedMeasurements()).filter((m) => m.deletedAt === null), + ).toHaveLength(2); + }); }); test.describe("A read-level delegate inside the record", () => { diff --git a/e2e/setup/critical-fixture.ts b/e2e/setup/critical-fixture.ts index 8601a6e1d..e7ec87b76 100644 --- a/e2e/setup/critical-fixture.ts +++ b/e2e/setup/critical-fixture.ts @@ -35,6 +35,10 @@ export const CRITICAL_JOURNEYS = [ "labs", "wellbeing", "settings", + // The fresh account a full backup is imported into. The backup journey + // runs in the desktop project only (`playwright.config.ts` says why), so + // only the desktop account of this pair is ever written. + "restore", ] as const; export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number]; @@ -174,3 +178,76 @@ export async function clearLoginBuckets(): Promise { await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); }); } + +/** + * Delete a critical account and create it again, empty, under the same id, + * username and password. The backup journey restores a person's file into + * the account the way the runbook brings one back on a new host, where the + * file names its owner by id; a browser cannot delete and recreate a row + * under a chosen id, so this is the fixture's act, not the journey's. + */ +export async function recreateCriticalAccount( + account: CriticalAccount, +): Promise { + await withPool(async (pool) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const res = await client.query<{ + id: string; + password_hash: string; + module_preferences_json: unknown; + }>( + `SELECT id, password_hash, module_preferences_json + FROM users WHERE username = $1`, + [account.username], + ); + const row = res.rows[0]; + if (!row) { + throw new Error(`[critical-fixture] ${account.username} not seeded`); + } + await client.query("DELETE FROM users WHERE id = $1", [row.id]); + await client.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`, + [ + row.id, + account.username, + account.email, + row.password_hash, + JSON.stringify(row.module_preferences_json), + ], + ); + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + }); +} + +/** How many measurements and mood entries a critical account holds. */ +export async function accountRowCounts( + account: CriticalAccount, +): Promise<{ measurements: number; moodEntries: number }> { + return withPool(async (pool) => { + const res = await pool.query<{ measurements: string; mood: string }>( + `SELECT + (SELECT count(*) FROM measurements m + JOIN users u ON u.id = m.user_id WHERE u.username = $1) + AS measurements, + (SELECT count(*) FROM mood_entries e + JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`, + [account.username], + ); + return { + measurements: Number(res.rows[0]?.measurements ?? 0), + moodEntries: Number(res.rows[0]?.mood ?? 0), + }; + }); +} 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 6486deaf0..983d71d8b 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -50,7 +50,7 @@ const E2E = join(process.cwd(), "e2e"); * somebody should see here. It counts title literals in the source, so a * title written once inside a loop counts once however many tests it names. */ -const CRITICAL_TEST_FLOOR = 15; +const CRITICAL_TEST_FLOOR = 16; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST = From 041c33ec55ef7a4aa6be867cb0b6032e5195bcdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:13:48 +0200 Subject: [PATCH 09/10] test(e2e): wait for hover transitions to settle before the check-in axe scans The scan waited a fixed 300 ms after moving the pointer away. It now waits until no CSS transition is running, which is the state the wait stood for. --- e2e/mental-health-check-in.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/e2e/mental-health-check-in.spec.ts b/e2e/mental-health-check-in.spec.ts index 85440382e..399bf49f2 100644 --- a/e2e/mental-health-check-in.spec.ts +++ b/e2e/mental-health-check-in.spec.ts @@ -42,9 +42,16 @@ const CASES: readonly Case[] = [ async function expectNoSeriousViolations(page: Page, label: string) { // The pointer rests where the last tap landed; a hover tint under it is - // not what the screen shows at rest. + // not what the screen shows at rest. Moving it away starts the tint's + // transition back, so the scan waits for every transition to settle rather + // than for a guessed number of milliseconds. Transitions only: a spinner's + // or a skeleton's endless animation would never settle. await page.mouse.move(0, 0); - await page.waitForTimeout(300); + await page.waitForFunction(() => + document + .getAnimations() + .every((a) => !(a instanceof CSSTransition) || a.playState !== "running"), + ); const results = await new AxeBuilder({ page }) .include( '[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]', From 591824044bc43c2555fbdec02413e787c8b30ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:19:47 +0200 Subject: [PATCH 10/10] docs(testing): describe the critical journeys and their accounts --- docs/contributing/testing.md | 22 ++++++++++++++++++++++ playwright.config.ts | 8 ++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fd0a7d434..6821cba0d 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -27,6 +27,28 @@ ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP (`safeFetch`), APNs, SMTP, the model. +## Critical journeys + +A few e2e journeys carry `@critical` in their test titles: signing in with a +password and a passkey and signing out, a reading reaching the dashboard, a +dose moving compliance, a mood entry, a lab CSV import, a questionnaire in +German and Brazilian Portuguese, a setting that persists, a backup coming +back, sharing a record, and the offline page. `pnpm e2e:critical` runs them. + +They run against the real server and a seeded Postgres. A spec file that +carries the tag may not intercept anything under `/api`, directly or through +a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` +refuses it and keeps a floor on the number of tagged tests. Stubbing an +unreachable third-party host that is not the journey's subject is allowed, +with the reason next to the call. + +A journey that writes gets an account nobody else writes. The accounts in +`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey +and per Chromium project, so a journey runs at both widths without the two +projects meeting. Reset what a previous run left with `resetCriticalAccount` +in a `beforeEach`, and read results through the app's own routes rather than +through the database. + ## The route test kit `tests/kit/` holds two modules for tests under `tests/integration/`. diff --git a/playwright.config.ts b/playwright.config.ts index 9a53b27c1..cd8a69347 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,14 @@ import { * * To run locally: `pnpm dlx playwright install --with-deps chromium` * once, then `pnpm e2e`. + * + * The journeys tagged `@critical` (`pnpm e2e:critical`) are the exception to + * the route interception above: they intercept nothing under `/api`, which + * `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds. Most of them + * run in both Chromium projects on an account per project + * (`e2e/setup/critical-fixture.ts`), so they need no entry in the + * `chromium-mobile` ignore list below; the ones that are on that list are + * there for the reason written beside them. */ /** * Drawn once here, before any server or worker exists, and published through