fix(fhir): stop advertising a credential no client can obtain

The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.

End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent f12290532f
commit ffe99caccd
5 files changed
+72 -12

No files matched your search

+5 -4
View File
@@ -15737,9 +15737,10 @@ components:
restBaseUrl:
type: string
description: Base path of the read-only FHIR R4 REST face.
readScope:
type: string
description: Bearer scope a narrow token needs to read the FHIR face.
scopeMintable:
type: boolean
description: "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the
scope, so the face is reachable by the owner's cookie session or a wildcard device token only."
resourceTypes:
type: array
items:
@@ -15760,7 +15761,7 @@ components:
- snomedRoute
- germanAtcDefaultLocales
- restBaseUrl
- readScope
- scopeMintable
- resourceTypes
- operations
- searchParams
+50
View File
@@ -53,6 +53,56 @@ test.describe("Settings → Export & Import", () => {
});
});
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
page,
}) => {
// The three panel states are the release's whole point, and the first one
// is the only place a template is applied. It has to be VISIBLE — a
// template behind a collapsed disclosure is opt-out wearing a hat — so the
// picker, its twelve group rows and the fenced tier all render without a
// click.
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("report-scope-picker")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("report-group-row-vitals")).toBeVisible();
await expect(page.getByTestId("report-group-row-sensitive")).toHaveCount(0);
await expect(page.getByTestId("report-sensitive-tier")).toBeVisible();
// The fenced tier has no group control — there is no single click that
// turns on more than one sensitive leaf.
await expect(page.getByTestId("report-group-check-sensitive")).toHaveCount(
0,
);
await expect(page.getByTestId("report-scope-summary")).toBeVisible();
});
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
const row = page.getByTestId("report-group-row-vitals");
await expect(row).toBeVisible({ timeout: 10_000 });
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test("opens a group's leaf grid from its chevron", async ({ page }) => {
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
const row = page.getByTestId("report-group-row-vitals");
await expect(row).toBeVisible({ timeout: 10_000 });
// Collapsed: the leaves are behind the disclosure, which is what makes a
// repeat run two interactions.
await expect(page.getByTestId("report-leaf-PULSE")).toHaveCount(0);
await row.getByRole("button", { expanded: false }).click();
await expect(page.getByTestId("report-leaf-PULSE")).toBeVisible();
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
@@ -53,7 +53,6 @@ import {
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
FHIR_READ_SCOPE,
FHIR_REST_RESOURCE_TYPES,
FHIR_EVERYTHING_OPERATION,
FHIR_SEARCH_PARAMS,
@@ -87,7 +86,7 @@ type CapabilitiesBody = {
snomedRoute: string;
germanAtcDefaultLocales: string[];
restBaseUrl: string;
readScope: string;
scopeMintable: boolean;
resourceTypes: string[];
operations: string[];
searchParams: string[];
@@ -169,7 +168,10 @@ describe("GET /api/meta/capabilities — drift guards", () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.fhir.restBaseUrl).toBe("/api/fhir");
expect(body.data.fhir.readScope).toBe(FHIR_READ_SCOPE);
// The face checks `fhir:read`, but nothing mints a token carrying it, so
// advertising it as an obtainable credential would be a claim the code
// cannot support.
expect(body.data.fhir.scopeMintable).toBe(false);
expect(body.data.fhir.resourceTypes).toEqual([...FHIR_REST_RESOURCE_TYPES]);
expect(body.data.fhir.operations).toEqual([FHIR_EVERYTHING_OPERATION]);
expect(body.data.fhir.searchParams).toEqual([...FHIR_SEARCH_PARAMS]);
+7 -2
View File
@@ -41,7 +41,6 @@ import {
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
FHIR_READ_SCOPE,
FHIR_REST_RESOURCE_TYPES,
FHIR_EVERYTHING_OPERATION,
FHIR_SEARCH_PARAMS,
@@ -111,7 +110,13 @@ export const GET = apiHandler(async () => {
// whole-record operation, the honoured search params and the narrow
// Bearer scope. All sourced from the canonical `rest.ts` constants.
restBaseUrl: "/api/fhir",
readScope: FHIR_READ_SCOPE,
// The face checks a narrow `fhir:read` scope, and NO code path mints a
// token carrying it — every mint grants a wildcard, the medication
// ingest scope, or an MCP scope. So the face is reachable by the owner's
// cookie session or a wildcard device token, and by nothing else. It is
// reported that way rather than advertising a credential a client cannot
// obtain.
scopeMintable: false,
resourceTypes: FHIR_REST_RESOURCE_TYPES,
operations: [FHIR_EVERYTHING_OPERATION],
searchParams: FHIR_SEARCH_PARAMS,
+5 -3
View File
@@ -70,9 +70,11 @@ const capabilitiesResponse = z
restBaseUrl: z
.string()
.describe("Base path of the read-only FHIR R4 REST face."),
readScope: z
.string()
.describe("Bearer scope a narrow token needs to read the FHIR face."),
scopeMintable: z
.boolean()
.describe(
"Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the scope, so the face is reachable by the owner's cookie session or a wildcard device token only.",
),
resourceTypes: z
.array(z.string())
.describe(