From 881a8d90616c5ef9eaa7da8dbe8fbc69f7a1b995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 03:19:39 +0200 Subject: [PATCH] test(e2e): meet every recent-proof gate from a stale session The invitation journeys, the backup journey (archive, snapshot download and restores) and the delegated doctor report acted on jars signed in once in global setup, so they passed or met the re-proof gate depending on how far into the run they started. Each now acts from a session of its own, made stale before every gated press, and answers the dialog with the password; the delegated report asserts the 401 before re-proving over the wire. The helper gains expireRecentProof, reproveWithPassword and actWithReproof. --- e2e/account-sharing.spec.ts | 9 +++- e2e/backup-restore-journey.spec.ts | 40 ++++++++++++++-- e2e/delegated-writes.spec.ts | 8 +++- e2e/doctor-report-delegate.spec.ts | 18 ++++++++ e2e/setup/recent-proof.ts | 74 ++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 6 deletions(-) diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index a15a9e0ee..2238bc4d2 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -65,6 +65,7 @@ import type { BrowserContext, Page } from "@playwright/test"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { actWithReproof, useStaleSession } from "./setup/recent-proof"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, @@ -162,15 +163,21 @@ test.describe("account sharing", () => { let ownerContext: BrowserContext; let ownerPage: Page; + let endOwnerSession: (() => Promise) | null = null; test.beforeAll(async ({ browser }) => { ownerContext = await browser.newContext({ storageState: OWNER_STORAGE_STATE_PATH, }); ownerPage = await ownerContext.newPage(); + // An invitation asks for a recent proof. The owner acts from a session of + // this journey's own, made stale before the press, so the re-proof dialog + // is met on every run rather than only once the run is five minutes old. + endOwnerSession = await useStaleSession(ownerPage, E2E_OWNER.username); }); test.afterAll(async () => { + await endOwnerSession?.(); await ownerContext.close(); }); @@ -248,7 +255,7 @@ test.describe("account sharing", () => { (req) => req.method() === "POST" && req.url().endsWith("/api/account/grants"), ); - await submit.click(); + await actWithReproof(ownerPage, E2E_OWNER.password, () => submit.click()); const posted = JSON.parse((await invitePost).postData() ?? "{}") as { expiresAt?: string | null; access?: string; diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index 9db3722d2..9104ec5f6 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -65,6 +65,12 @@ import { storedMeasurements, storeTamperedCopy, } from "./setup/backup-journey-fixture"; +import { + actWithReproof, + completeReproofWithPassword, + expireRecentProof, + useStaleSession, +} from "./setup/recent-proof"; import { expect, test } from "./setup/test"; test.describe.configure({ mode: "serial" }); @@ -256,12 +262,20 @@ function snapshotRow(page: Page, id: string) { return page.locator(`[data-backup-id="${id}"]:visible`); } -/** Drive the typed-confirmation dialog on one row and hand back the answer. */ +/** + * Drive the typed-confirmation dialog on one row and hand back the answer. + * + * A restore asks for a recent proof, and the session is made stale first, so + * the first POST is always refused and the answer waited for is the one to + * the retry the page sends after the re-proof dialog. + */ async function restoreThroughDialog(page: Page, id: string) { + await expireRecentProof(page); const answered = page.waitForResponse( (response) => response.url().includes(`/api/admin/backups/${id}/restore`) && - response.request().method() === "POST", + response.request().method() === "POST" && + response.status() !== 401, ); await snapshotRow(page, id).getByTestId("backup-restore-trigger").click(); // The instance-wide opt-in, left exactly where the dialog puts it. Asserted @@ -273,12 +287,26 @@ async function restoreThroughDialog(page: Page, id: string) { ).not.toBeChecked(); await page.getByTestId("backup-restore-prompt").fill("RESTORE"); await page.getByTestId("backup-restore-confirm").click(); + await completeReproofWithPassword(page, E2E_BACKUP_ADMIN.password); return answered; } test.describe("Backup and restore, through the settings surfaces", () => { test.use({ storageState: BACKUP_STORAGE_STATE_PATH }); + // The archive, the snapshot download and every restore ask for a recent + // proof. Each test acts from a session of its own that is made stale before + // each of those presses, so the re-proof dialog is met every time, whatever + // minute of the run the journey lands in. + let endStaleSession: (() => Promise) | null = null; + test.beforeEach(async ({ page }) => { + endStaleSession = await useStaleSession(page, E2E_BACKUP_ADMIN.username); + }); + test.afterEach(async () => { + await endStaleSession?.(); + endStaleSession = null; + }); + test.beforeAll(async () => { seeded = null; snapshotId = null; @@ -327,7 +355,9 @@ test.describe("Backup and restore, through the settings surfaces", () => { .getByTestId("export-full-backup-passphrase") .fill(ARCHIVE_PASSPHRASE); const archiveDownload = page.waitForEvent("download"); - await page.getByTestId("export-action-full-backup").click(); + await actWithReproof(page, E2E_BACKUP_ADMIN.password, () => + page.getByTestId("export-action-full-backup").click(), + ); const bytes = await readFile(await (await archiveDownload).path()); // The header `passphrase-archive.ts` documents, field by field: magic, @@ -420,7 +450,9 @@ test.describe("Backup and restore, through the settings surfaces", () => { // ── what the stored copy actually carries ──────────────────────────── const snapshotDownload = page.waitForEvent("download"); - await snapshotRow(page, row.id).getByTestId("backup-download").click(); + await actWithReproof(page, E2E_BACKUP_ADMIN.password, () => + snapshotRow(page, row.id).getByTestId("backup-download").click(), + ); const payload = JSON.parse( await readFile(await (await snapshotDownload).path(), "utf8"), ) as { diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 1074b44ed..de3bffcd4 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -36,9 +36,11 @@ */ import type { BrowserContext, Page } from "@playwright/test"; +import { actWithReproof, useStaleSession } from "./setup/recent-proof"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, + E2E_OWNER, E2E_OWNER_FULL_NAME, E2E_USER, OWNER_STORAGE_STATE_PATH, @@ -76,12 +78,15 @@ test.describe("delegated writes", () => { let ownerContext: BrowserContext; let ownerPage: Page; let levelControlPresent = false; + let endOwnerSession: (() => Promise) | null = null; test.beforeAll(async ({ browser }) => { ownerContext = await browser.newContext({ storageState: OWNER_STORAGE_STATE_PATH, }); ownerPage = await ownerContext.newPage(); + // The invitation asks for a recent proof; see `useStaleSession`. + endOwnerSession = await useStaleSession(ownerPage, E2E_OWNER.username); await ownerPage.goto("/settings/access"); levelControlPresent = (await ownerPage.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`).count()) > @@ -89,6 +94,7 @@ test.describe("delegated writes", () => { }); test.afterAll(async () => { + await endOwnerSession?.(); await ownerContext.close(); }); @@ -134,7 +140,7 @@ test.describe("delegated writes", () => { (req) => req.method() === "POST" && req.url().endsWith("/api/account/grants"), ); - await submit.click(); + await actWithReproof(ownerPage, E2E_OWNER.password, () => submit.click()); const posted = JSON.parse((await invitePost).postData() ?? "{}") as { access?: string; }; diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts index c17512663..6cf5db593 100644 --- a/e2e/doctor-report-delegate.spec.ts +++ b/e2e/doctor-report-delegate.spec.ts @@ -40,9 +40,11 @@ */ import type { Page } from "@playwright/test"; +import { reproveWithPassword, useStaleSession } from "./setup/recent-proof"; import { expect, test } from "./setup/test"; import { E2E_LEVEL_RECORDS, + E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -223,6 +225,15 @@ test.describe.serial("a read-level delegate and the doctor report", () => { throw new Error("the MANAGE-level record fixture is missing"); } + // Generating the report exports a whole record, which asks the actor for a + // recent proof. The test runs on a session of its own, signed in ten minutes + // ago, so the gate is met on every run and proved explicitly below rather + // than passed or failed by where in the run this file lands. + let endStaleSession: (() => Promise) | null = null; + test.beforeEach(async ({ page }) => { + endStaleSession = await useStaleSession(page, E2E_SCOPE_DELEGATE.username); + }); + test.afterEach(async ({ page }) => { // Hand the session back to its own record even when an assertion above // failed: this jar is this spec's, but a switched row left behind would @@ -230,6 +241,8 @@ test.describe.serial("a read-level delegate and the doctor report", () => { await page.request .post("/api/account/switch", { data: { accountId: null } }) .catch(() => {}); + await endStaleSession?.(); + endStaleSession = null; }); test("cannot generate on a record it may only read, and is not offered the control", async ({ @@ -245,6 +258,11 @@ test.describe.serial("a read-level delegate and the doctor report", () => { await expect( page.locator('[data-slot="shared-record-banner"]'), ).toHaveAttribute("data-access-level", "manage"); + // Without a recent proof the manager is asked for one, not refused. + const unproven = await attemptReport(page, SELECTION); + expect(unproven.status).toBe(401); + expect(unproven.errorCode).toBe("auth.reproof.required"); + await reproveWithPassword(page, E2E_SCOPE_DELEGATE.password); const managed = await attemptReport(page, SELECTION); expect(managed.status).toBe(200); expect(managed.contentType).toContain("application/pdf"); diff --git a/e2e/setup/recent-proof.ts b/e2e/setup/recent-proof.ts index 27925f854..f8a7a88e0 100644 --- a/e2e/setup/recent-proof.ts +++ b/e2e/setup/recent-proof.ts @@ -86,6 +86,80 @@ export async function useStaleSession( }; } +/** The page's current session secret, hashed the way the server stores it. */ +async function pageSessionHash(page: Page): Promise { + const cookie = (await page.context().cookies()).find( + (c) => c.name === SESSION_COOKIE, + ); + if (!cookie) { + throw new Error("[recent-proof] the page carries no session cookie"); + } + return createHmac("sha256", env("API_TOKEN_HMAC_KEY")) + .update(cookie.value) + .digest("hex"); +} + +/** + * Make the page's session stale again after a re-proof, so the next gated + * action meets the dialog whatever the clock says. Only a row that + * `useStaleSession` inserted is touched: a journey that re-proves more than + * five minutes apart must not depend on which side of the window it lands. + */ +export async function expireRecentProof(page: Page): Promise { + const tokenHash = await pageSessionHash(page); + const pool = new pg.Pool({ connectionString: env("DATABASE_URL") }); + try { + const updated = await pool.query( + `UPDATE sessions + SET created_at = LEAST(created_at, NOW() - make_interval(mins => $2)), + reproof_at = NULL, + mfa_verified_at = NULL + WHERE token_hash = $1 AND id LIKE 'e2estale%'`, + [tokenHash, STALE_SIGN_IN_MINUTES], + ); + if ((updated.rowCount ?? 0) !== 1) { + throw new Error( + "[recent-proof] the page is not on a session from useStaleSession", + ); + } + } finally { + await pool.end(); + } +} + +/** + * Re-prove with the password over the wire the dialog uses, for a spec that + * calls a gated route from the page rather than through a control. + */ +export async function reproveWithPassword( + page: Page, + password: string, +): Promise { + const status = await page.evaluate(async (secret: string) => { + const res = await fetch("/api/auth/reproof", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "password", password: secret }), + }); + return res.status; + }, password); + expect(status, "the password re-proof was accepted").toBe(200); +} + +/** + * Stale the session, run the action that meets the gate, and answer the + * dialog it opens. The page retries the action once the proof is in. + */ +export async function actWithReproof( + page: Page, + password: string, + act: () => Promise, +): Promise { + await expireRecentProof(page); + await act(); + await completeReproofWithPassword(page, password); +} + /** * Answer the re-proof dialog with the account's password, the way a person * does, and wait for it to close. The action behind it is retried by the page.