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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 03:19:39 +02:00
1 parent bf7bb0925d
commit 881a8d9061
5 files changed
+143 -6

No files matched your search

+8 -1
View File
@@ -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<void>) | 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;
+36 -4
View File
@@ -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<void>) | 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 {
+7 -1
View File
@@ -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<void>) | 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;
};
+18
View File
@@ -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<void>) | 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");
+74
View File
@@ -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<string> {
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<void> {
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<void> {
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<void>,
): Promise<void> {
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.