Files
HealthLog/e2e/doctor-report-delegate.spec.ts
T
Marc-André Bombeck f17819f1c2 fix(settings): English addresses for the health record and checkups pages
Two Settings pages had German words in their address in every
language: /settings/gesundheitsakte and /settings/layout/vorsorge.
They are /settings/health-record and /settings/layout/checkups now,
and the old addresses (and the older /settings/sharing and
/settings/vorsorge) redirect permanently, so bookmarks and links in
old reports keep working; the report range a since-last-visit link
carries in its query passes through. The in-app links, the tour, the
command palette, the anchor forwards and the e2e specs use the new
addresses, and the two message groups follow the new slugs.
2026-11-11 22:47:40 +01: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/health-record` 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/health-record", {
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);
});
});