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.