Files
HealthLog/e2e/doctor-report-delegate.spec.ts
Marc-André Bombeck 881a8d9061 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.
2026-09-27 03:19:39 +02:00

306 lines
12 KiB
TypeScript

/**
* The doctor report is not something a READ-level delegate can generate.
*
* `POST /api/export/health-record` resolves `requireRecordAuth("manage",
* "record")`: a prepared artefact from a declared selection is the one export an
* invited MANAGER reaches, and READ is below that line. That is the statement
* this spec is about, and it is the ROUTE half that carries it: 403 inside the
* record the delegate may only read, 200 inside the one it manages.
*
* The UI half proves something narrower, and the file says so rather than
* implying the route's boundary twice. `/settings/gesundheitsakte` is a
* PERSONAL destination: inside SOMEBODY ELSE's record the shell answers it with
* the "not part of shared access" panel at every adult grant level — READ,
* WRITE and MANAGE alike — so the export card is never rendered for a delegate
* to press. That is a property of the destination, not of the level, and the
* spec asserts it at BOTH levels for exactly that reason: the same two
* absences inside the MANAGE record, where the route says 200, are what stops
* "the control is not offered" from being a check that cannot fail.
*
* Why both halves at all. A missing button proves nothing about the server, and
* a 403 proves nothing about what the person is shown; the failure mode this
* release family keeps rediscovering is a control that renders and then 403s.
*
* The positive control is the same delegate, the same payload and the same
* switched-record plumbing against the record it holds a MANAGE grant on. That
* isolates the one variable the refusal is supposed to be about: if the payload
* were malformed, the session unfenced or the switch broken, the MANAGE arm
* would refuse too and the file would go red instead of passing for the wrong
* reason.
*
* Both requests carry the record-context headers the shipped client sends. A
* raw `fetch` without them is refused by the record-session fence with the SAME
* status and the SAME error code as an insufficient grant — so an unfenced
* request would make the refusal below pass for the wrong reason. The context
* is read from `/api/auth/me`, which is where a browser gets it too.
*
* Its own cookie jar, like every spec that moves a session's record selector:
* the switch is stamped on the session row, so sharing a jar would switch the
* record under whichever spec happened to be mid-navigation.
*/
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";
/** A selection the route accepts, so a refusal is about the grant and nothing
* else. One leaf is enough: an empty list is its own 422. */
const SELECTION = { v: 2, leaves: ["WEIGHT"] } as const;
interface ReportAttempt {
status: number;
contentType: string;
errorCode: string | null;
magic: string;
}
async function openSwitcher(page: Page): Promise<void> {
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
}
/**
* Run `act` and wait for the document it replaces to be gone — the switch is a
* hard navigation, and a banner assertion alone settles nothing. Same helper
* shape the v1.37/v1.38 sharing journeys use, for the reason written there.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
/** Land on the delegate's own record, with the shell live. */
async function openOwnRecord(page: Page): Promise<void> {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "User menu" }).first(),
).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
async function enterRecord(page: Page, username: string): Promise<void> {
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
);
await expect(entry).toHaveCount(1);
await withDocumentReplacement(page, () => entry.click());
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
timeout: 30_000,
});
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page): Promise<void> {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
/**
* Open the health-record settings page and wait for the shell to decide.
*
* Inside somebody else's record the shell answers this destination with the
* "not part of shared access" panel rather than the section, so the panel is
* the settled state to gate on — and the thing whose presence says the export
* card was never offered.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible({ timeout: 30_000 });
}
/**
* Ask the route for a PDF from inside the page, carrying the record context the
* shipped client carries.
*/
async function attemptReport(
page: Page,
selection: { v: number; leaves: readonly string[] },
): Promise<ReportAttempt> {
return page.evaluate(async (chosen) => {
const me = (await (await fetch("/api/auth/me")).json()) as {
data: { recordSession: { epoch: number; scope: string | null } | null };
};
const context = me.data.recordSession;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (context) {
headers["x-healthlog-record-epoch"] = String(context.epoch);
headers["x-healthlog-record-scope"] = context.scope ?? "self";
}
const res = await fetch("/api/export/health-record", {
method: "POST",
headers,
body: JSON.stringify({
format: "pdf",
locale: "en",
range: { days: 30 },
includeCharts: false,
selection: chosen,
}),
});
const contentType = res.headers.get("content-type") ?? "";
const buffer = await res.arrayBuffer();
const head = String.fromCharCode(...new Uint8Array(buffer.slice(0, 5)));
let errorCode: string | null = null;
if (contentType.includes("json")) {
try {
const payload = JSON.parse(new TextDecoder().decode(buffer)) as {
meta?: { errorCode?: string };
};
errorCode = payload.meta?.errorCode ?? null;
} catch {
errorCode = null;
}
}
return { status: res.status, contentType, errorCode, magic: head };
}, selection);
}
test.describe.serial("a read-level delegate and the doctor report", () => {
test.use({ storageState: REPORT_DELEGATE_STORAGE_STATE_PATH });
const readRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "READ" && record.recordKind === "shared",
);
if (!readRecord) throw new Error("the READ-level record fixture is missing");
const manageRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "MANAGE" && record.recordKind === "shared",
);
if (!manageRecord) {
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
// make its own next run start somewhere it did not choose.
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 ({
page,
}) => {
test.setTimeout(120_000);
await openOwnRecord(page);
// Positive control: a record the same delegate MANAGES. Everything except
// the access level is identical to the refusal below.
await enterRecord(page, manageRecord.username);
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");
expect(managed.magic).toBe("%PDF-");
// The destination is shut here too, and this is the arm that proves it is
// the DESTINATION doing it: the route answered 200 for this very record a
// line ago, so the two absences below cannot be the grant level talking.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0);
await expect(page.getByTestId("health-record-generate")).toHaveCount(0);
await leaveRecord(page);
await enterRecord(page, readRecord.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "shared");
// The banner carries the PRESENTATION of the grant, and a READ grant
// presents as "view" — one level below the "view-and-add" a WRITE grant
// shows and two below the "manage" this route requires.
await expect(banner).toHaveAttribute("data-access-level", "view");
// The same closed door, one level down. Paired with the MANAGE arm above it
// says the export card is offered inside NOBODY's record but your own; on
// its own it would say nothing about READ.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0);
await expect(page.getByTestId("health-record-generate")).toHaveCount(0);
// The level-sensitive statement: the same request, the same headers and the
// same payload that answered 200 inside the managed record are refused
// here, and nothing but the grant changed.
const shared = await attemptReport(page, SELECTION);
expect(shared.status).toBe(403);
expect(shared.errorCode).toBe("sharing.access.denied");
expect(shared.contentType).toContain("json");
await leaveRecord(page);
});
});