diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index f21d6137c..a990b7319 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -18,6 +18,43 @@ const DOMAIN_READS = { documents: "/api/documents/inbound", } as const; +/** + * v1.37.0 — the record-session assertion the app is currently sending. + * + * The two API probes in this file are deliberate: they check a status code the + * UI does not surface (a MANAGE-only generated read) and a payload the UI + * paginates. But a bare `fetch` inside `page.evaluate` carries no + * record-session headers, and the fence refuses an unasserted request on any + * session that has been inside a shared record — so the probes started failing + * with 403 for a reason that had nothing to do with what they test. + * + * They are NOT an old-bundle simulation and must not become one: that case is + * covered deliberately in `src/__tests__/record-session-fence-compat.test.ts` + * and `tests/integration/record-session-fence.test.ts`, where the 403 and the + * recovery are the subject. Here the subject is the grant level, so the probe + * asserts exactly what the app beside it asserts — copied off a real app + * request rather than computed, so it cannot drift from what the client + * actually believes. + */ +function trackFenceAssertion(page: Page): () => { + "x-healthlog-record-epoch": string; + "x-healthlog-record-scope": string; +} { + let latest = { epoch: "bootstrap", scope: "bootstrap" }; + page.on("request", (request) => { + const headers = request.headers(); + const epoch = headers["x-healthlog-record-epoch"]; + const scope = headers["x-healthlog-record-scope"]; + if (epoch === undefined || scope === undefined) return; + if (epoch === "bootstrap") return; + latest = { epoch, scope }; + }); + return () => ({ + "x-healthlog-record-epoch": latest.epoch, + "x-healthlog-record-scope": latest.scope, + }); +} + async function openSwitcher(page: Page) { await page.getByRole("button", { name: "User menu" }).first().click(); await page.locator('[data-slot="account-switcher-trigger"]').click(); @@ -294,6 +331,7 @@ test.describe.serial("scoped sharing browser journeys", () => { test("lets an adult WRITE grant add only to the selected record", async ({ page, }) => { + const fenceHeaders = trackFenceAssertion(page); const record = E2E_LEVEL_RECORDS.find( (candidate) => candidate.access === "WRITE", ); @@ -324,10 +362,10 @@ test.describe.serial("scoped sharing browser journeys", () => { expect((await post).status()).toBeLessThan(300); await page.reload(); - const payload = await page.evaluate(async () => { - const response = await fetch("/api/measurements"); + const payload = await page.evaluate(async (headers) => { + const response = await fetch("/api/measurements", { headers }); return { status: response.status, body: await response.json() }; - }); + }, fenceHeaders()); expect(payload.status).toBeLessThan(300); expect(JSON.stringify(payload.body)).toContain("124"); await expect(banner).toHaveAttribute("data-account-id", accountId); @@ -342,6 +380,7 @@ test.describe.serial("scoped sharing browser journeys", () => { test("opens MANAGE-only generated reads without opening adult Settings", async ({ page, }) => { + const fenceHeaders = trackFenceAssertion(page); const record = E2E_LEVEL_RECORDS.find( (candidate) => candidate.access === "MANAGE" && candidate.recordKind === "shared", @@ -357,10 +396,10 @@ test.describe.serial("scoped sharing browser journeys", () => { await page.goto("/"); await expect(banner).toHaveAttribute("data-access-level", "manage"); - const generatedRead = await page.evaluate(async () => { - const response = await fetch("/api/dashboard/summary"); + const generatedRead = await page.evaluate(async (headers) => { + const response = await fetch("/api/dashboard/summary", { headers }); return response.status; - }); + }, fenceHeaders()); expect(generatedRead).toBeLessThan(300); await expect( page.locator('[data-slot="shared-record-unavailable"]'), diff --git a/src/__tests__/record-fence-headerless-transport-guard.test.ts b/src/__tests__/record-fence-headerless-transport-guard.test.ts new file mode 100644 index 000000000..2eef9d1f2 --- /dev/null +++ b/src/__tests__/record-fence-headerless-transport-guard.test.ts @@ -0,0 +1,279 @@ +/** + * A fenced route may not be reached by a transport that cannot carry a header. + * + * The record-session fence is asserted on request HEADERS, attached inside + * `src/lib/api/api-fetch.ts`. Three browser transports never go through that + * wrapper and cannot be made to: + * + * * `` — the browser issues the subresource itself; + * * `