From f90219784b2a16cb0cea69dbe583cff75426e2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 17:22:48 +0200 Subject: [PATCH] test(export): pin the selection controls against going inert again Two layers, because the defect had two halves and each needs its own kind of proof. The structural guard walks the panel's control shape, folds every control through the wire schema and the resolver the route uses, and fails when flipping one changes nothing in the resolved selection. It also checks the panel renders a row for every control it declares, and that every resolved flag is read downstream, either by gating measurement types or by name in the aggregator. That is the link nobody had written: the dead switches were dead for several releases and no test connected "the panel renders this" to "the export honours this". The behavioural half runs the real export route and reads what comes out. The previously-dead measurement switches are asserted against the FHIR bundle and the PDF table, with a positive control beside each exclusion so nothing passes on an empty fixture. The screening totals are asserted absent for a caller that names other sections and for a caller that sends no sections at all, then present when asked for by name. Allergies and family history are asserted absent from the bundle and never read from the database, and the package case opens the zip and checks the bundle inside it agrees with the PDF beside it. Mutation-checked: reverting the allergy gate, the medication-list gate, the screener entries or the measurement entries turns the matching cases red and leaves the rest green; removing a control's fold entry or its aggregator entry turns the guard red with the control named. The remaining changes update fixtures the new shapes touch: prefs objects now resolve the added flags from the defaults, and the share-link fixtures drop the retired FHIR scope, with a case pinning that the create route now refuses those keys instead of ignoring them. --- ...doctor-report-control-gating-guard.test.ts | 187 +++++++++++ .../__tests__/route.test.ts | 16 +- .../health-record/__tests__/route.test.ts | 294 +++++++++++++++++- .../api/share-links/__tests__/route.test.ts | 42 ++- src/app/c/[token]/__tests__/page.test.tsx | 2 - .../c/[token]/d/[id]/__tests__/route.test.ts | 2 - .../__tests__/share-link-create-form.test.tsx | 22 +- .../__tests__/sharing-section.test.tsx | 9 +- .../__tests__/resolve-share-token.test.ts | 6 +- .../__tests__/share-view-data.test.ts | 2 - .../__tests__/doctor-report-prefs.test.ts | 9 +- 11 files changed, 533 insertions(+), 58 deletions(-) create mode 100644 src/__tests__/doctor-report-control-gating-guard.test.ts diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts new file mode 100644 index 000000000..b7c476dff --- /dev/null +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -0,0 +1,187 @@ +/** + * Structural guard: a rendered export control with no gating path fails the + * build. + * + * The health-record export panel shipped twenty data-selection switches, of + * which eight changed nothing at all. `toDoctorReportPrefs` folded the grouped + * wire shape down to twelve flat flags and dropped the rest on the floor, so a + * user who switched off SpO₂, body fat, body composition, resting heart rate, + * HRV, VO₂max, steps or distance exported them anyway — in the PDF, in the FHIR + * bundle, in the package, in a clinician share link and over MCP. Nothing + * failed; the control simply lied, and it lied for several releases because no + * test connected "the panel renders this" to "the export honours this". + * + * This file is that connection, in three links that each break independently: + * + * 1. Every control the panel holds state for survives the wire schema. A key + * the schema strips can never be honoured, however carefully the fold is + * written. + * 2. Flipping exactly one control changes the resolved `DoctorReportPrefs` + * the aggregator consumes. This is the assertion that would have caught + * all eight dead switches on the day they were added. + * 3. Every flag in the resolved shape is read downstream — either as a + * per-measurement-type entry in the selection gates or by name in the + * aggregator body. A flag nothing reads is the same defect one layer down. + * + * It also checks the panel actually renders every control it declares, so the + * split between the control shape and its rendering cannot quietly leave a + * declared control with no row (or a row with no gate). + * + * What it does NOT prove: that the excluded data is actually absent from a + * rendered artefact. That is a behavioural property and it is covered where the + * artefacts are built — `src/app/api/export/health-record/__tests__/route.test.ts` + * asserts the PDF, the FHIR bundle and the zip for the same selections. This + * guard is the cheap structural half that runs on every commit and cannot be + * satisfied by writing a control that looks plausible. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + exportSectionsSchema, + toDoctorReportPrefs, +} from "@/lib/validations/health-record-export"; +import { + DEFAULT_DOCTOR_REPORT_PREFS, + type DoctorReportPrefs, +} from "@/lib/validations/doctor-report-prefs"; +import { + DEFAULT_SECTIONS, + buildSelectionSections, + type SectionState, +} from "@/components/settings/health-record-export-controls"; +import { MEASUREMENT_TYPE_SECTION } from "@/lib/doctor-report-selection"; + +const REPO_ROOT = join(__dirname, "..", ".."); +const PANEL_PATH = join( + REPO_ROOT, + "src", + "components", + "settings", + "health-record-export-panel.tsx", +); +const AGGREGATOR_PATH = join(REPO_ROOT, "src", "lib", "doctor-report-data.ts"); + +const panelSource = readFileSync(PANEL_PATH, "utf8"); +const aggregatorSource = readFileSync(AGGREGATOR_PATH, "utf8"); + +/** Strip comments so a key mentioned only in prose does not count as read. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** + * The control set, taken from the panel's own starting values rather than from + * a list this test maintains — a list would drift exactly the way the fold + * drifted. `DEFAULT_SECTIONS` is typed `SectionState`, so TypeScript already + * forces it to name every control, and every rendered row must bind one of + * them. "Declared here" and "rendered" are therefore the same set. + */ +const CONTROL_KEYS = Object.keys(DEFAULT_SECTIONS) as (keyof SectionState)[]; + +/** All controls on, as the panel's state shape. */ +function allOn(): SectionState { + return Object.fromEntries( + CONTROL_KEYS.map((k) => [k, true]), + ) as unknown as SectionState; +} + +/** + * The exact path a selection takes in production: panel state → grouped wire + * shape → the route's Zod parse (which strips anything the schema does not + * declare) → the flat prefs the aggregator consumes. + */ +function resolve(state: SectionState): DoctorReportPrefs { + return toDoctorReportPrefs( + exportSectionsSchema.parse(buildSelectionSections(state)), + ); +} + +describe("export control gating — structural guard", () => { + it("reads a plausible control set", () => { + // Sanity floor: if the control shape silently degraded, every assertion + // below would pass vacuously. + expect(CONTROL_KEYS.length).toBeGreaterThanOrEqual(20); + expect(CONTROL_KEYS).toContain("bp"); + expect(CONTROL_KEYS).toContain("mentalHealthScreeners"); + }); + + it("renders a row for every control it declares", () => { + const unrendered = CONTROL_KEYS.filter( + (key) => !panelSource.includes(`sections.${key}`), + ); + expect( + unrendered, + `These controls are declared in the selection shape but the panel binds ` + + `no row to them, so the user has no way to reach them.`, + ).toEqual([]); + }); + + it("keeps every rendered control inside the wire schema", () => { + const wire = buildSelectionSections(allOn()); + // A round trip through the schema must not lose anything the panel sends. + expect(exportSectionsSchema.parse(wire)).toEqual(wire); + }); + + it("makes flipping any single control change the resolved selection", () => { + const baseline = resolve(allOn()); + const inert: string[] = []; + + for (const key of CONTROL_KEYS) { + const flipped = resolve({ ...allOn(), [key]: false }); + if (JSON.stringify(flipped) === JSON.stringify(baseline)) { + inert.push(key); + } + } + + expect( + inert, + `These panel controls are rendered but change nothing in the resolved ` + + `export selection. Give each one a flag in DoctorReportPrefs and a ` + + `gating path in doctor-report-data.ts, or remove the control.`, + ).toEqual([]); + }); + + it("has something downstream read every flag in the resolved selection", () => { + // A flag is honoured either by gating measurement types or by name in the + // aggregator body (the structured sections: labs, allergies, the + // medication block, the glucose panel). + const mappedFlags = new Set( + Object.values(MEASUREMENT_TYPE_SECTION), + ); + const body = stripComments(aggregatorSource); + + const unread = Object.keys(DEFAULT_DOCTOR_REPORT_PREFS).filter( + (key) => !mappedFlags.has(key) && !body.includes(`sections.${key}`), + ); + + expect( + unread, + `These resolved selection flags are never read downstream, so switching ` + + `them off would change nothing that leaves the instance.`, + ).toEqual([]); + }); + + it("keeps the sensitive selections off unless asked for by name", () => { + // Mood, cycle and the screening instruments must never ride an absent key: + // the mental-health module went default-ON in v1.29.1 and silently made + // PHQ-9 and GAD-7 totals part of a default export. + const fromNothing = toDoctorReportPrefs(undefined); + expect(fromNothing.mood).toBe(false); + expect(fromNothing.cycle).toBe(false); + expect(fromNothing.mentalHealthScreeners).toBe(false); + + // A partial selection that names other things must not flip them on. + const partial = toDoctorReportPrefs({ labs: true, vitals: { bp: true } }); + expect(partial.mood).toBe(false); + expect(partial.cycle).toBe(false); + expect(partial.mentalHealthScreeners).toBe(false); + + // Only an explicit `true` does. + expect( + toDoctorReportPrefs({ mentalHealthScreeners: true }) + .mentalHealthScreeners, + ).toBe(true); + }); +}); diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index b5c5a5584..d993076dc 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -99,10 +99,13 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof body }; - expect(env.data).toEqual(body); + // The canonical shape is the full prefs object: keys the body omits fill + // from the defaults rather than being dropped. + const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body }; + expect(env.data).toEqual(canonical); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: body }, + data: { doctorReportPrefsJson: canonical }, }); }); @@ -134,10 +137,15 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { ); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof current }; - expect(env.data).toEqual({ ...current, mood: false }); + const merged = { + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }; + expect(env.data).toEqual(merged); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: { ...current, mood: false } }, + data: { doctorReportPrefsJson: merged }, }); }); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 2edf0ab3c..e5815ce7b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { PDFParse } from "pdf-parse"; +import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { @@ -31,9 +32,15 @@ vi.mock("@/lib/auth/audit", () => ({ })); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); -vi.mock("@/lib/tz/resolver", () => ({ - resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), -})); +// Partial: the aggregator's canonical-source collapse calls `userDayKey` from +// the same module, so a total mock breaks any fixture that carries rows. +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), + }; +}); // v1.18.0 — the health-record aggregator resolves the per-user module map // so a disabled data-domain module never reaches the export. Stub the gate @@ -250,6 +257,287 @@ describe("POST /api/export/health-record — outputs", () => { }); }); +/** + * The selection has to mean the same thing in every artefact. + * + * Three defects met here. The export panel rendered eight measurement switches + * that the grouped-to-flat fold discarded, so deselecting body fat or VO₂max + * exported them anyway. The medication switch documented itself as excluding + * all medication data while the drug list and the per-dose ledger went out + * unconditionally. And the route fetched allergies and family history without + * consulting the toggles at all, so `sections.allergies = false` produced a PDF + * without an allergy section and a FHIR bundle carrying every one of them — in + * the `package` format, inside the same zip. + * + * Each case below runs the real route end to end and reads the produced + * artefact. Mutation check: revert any single gate and its case goes red while + * the positive control beside it stays green, so none of them can pass by + * asserting on an empty fixture. + */ +describe("POST /api/export/health-record — the selection reaches every format", () => { + const T0 = new Date("2026-02-01T08:00:00.000Z"); + + function measurement(type: string, value: number) { + return { + type, + value, + measuredAt: T0, + source: "MANUAL", + deviceType: null, + sleepStage: null, + glucoseContext: null, + }; + } + + function allergyRow() { + return { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: null, + reactionEncrypted: null, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function familyHistoryRow() { + return { + id: "fmh-1", + relationship: "MOTHER", + condition: "Hypertension", + ageAtOnset: 52, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function medicationRow() { + return { + id: "med-1", + name: "Sample medication", + dose: "5 mg", + atcCode: null, + rxNormCode: null, + deliveryForm: null, + active: true, + schedules: [], + scheduleRevisions: [], + pauseEras: [], + doseChanges: [], + intakeEvents: [], + }; + } + + /** Resource types present in the bundle a given selection produces. */ + async function bundleResourceTypes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string; code?: unknown } }[]; + }; + return bundle.entry.map((e) => e.resource.resourceType); + } + + /** The LOINC/HealthKit codes of every Observation a selection produces. */ + async function observationCodes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { + resource: { + resourceType: string; + code?: { coding?: { code: string }[]; text?: string }; + }; + }[]; + }; + return bundle.entry + .filter((e) => e.resource.resourceType === "Observation") + .flatMap((e) => [ + ...(e.resource.code?.coding?.map((c) => c.code) ?? []), + e.resource.code?.text ?? "", + ]); + } + + it("honours every previously-dead measurement switch in the FHIR bundle", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + measurement("BONE_MASS", 3.1), + measurement("RESTING_HEART_RATE", 54), + measurement("HEART_RATE_VARIABILITY", 61), + measurement("VO2_MAX", 44), + measurement("ACTIVITY_STEPS", 8400), + measurement("WALKING_RUNNING_DISTANCE", 6200), + ] as never); + + // Positive control: with every switch on, all eight are emitted. Without + // this the exclusion assertion below could pass on an empty bundle. + const on = await observationCodes({ + vitals: { oxygenSaturation: true, bodyFat: true, bodyComposition: true }, + cardioFitness: { restingHeartRate: true, hrv: true, vo2max: true }, + activity: { steps: true, distance: true }, + }); + expect(on.length).toBeGreaterThanOrEqual(8); + + const off = await observationCodes({ + vitals: { + oxygenSaturation: false, + bodyFat: false, + bodyComposition: false, + }, + cardioFitness: { restingHeartRate: false, hrv: false, vo2max: false }, + activity: { steps: false, distance: false }, + }); + expect(off).toEqual([]); + }); + + it("keeps the deselected measurement rows out of the query in the first place", async () => { + await bundleResourceTypes({ + vitals: { bodyFat: false }, + cardioFitness: { vo2max: false }, + }); + const lastCall = vi.mocked(prisma.measurement.findMany).mock.calls.at(-1)!; + const where = lastCall[0]!.where as { type?: { notIn?: string[] } }; + expect(where.type?.notIn).toContain("BODY_FAT"); + expect(where.type?.notIn).toContain("VO2_MAX"); + }); + + it("omits the deselected vitals from the PDF table", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + ] as never); + const { POST } = await import("../route"); + + const withBoth = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: true, oxygenSaturation: true } }, + }), + ), + ); + expect(withBoth).toContain("Body fat"); + + const without = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: false, oxygenSaturation: false } }, + }), + ), + ); + expect(without).not.toContain("Body fat"); + }); + + it("holds the mental-health screening totals back unless they are asked for by name", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("PHQ9_SCORE", 11), + measurement("GAD7_SCORE", 8), + ] as never); + + // The module is on (the default since v1.29.1) and the caller sends no + // screening key: the totals must stay in the database. + expect(await observationCodes({ labs: true })).toEqual([]); + // And an omitted `sections` object entirely — the MCP / share-link case. + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir" })); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string } }[]; + }; + expect( + bundle.entry.filter((e) => e.resource.resourceType === "Observation"), + ).toEqual([]); + + // Asked for explicitly, they are emitted — the data is withheld, not lost. + const asked = await observationCodes({ mentalHealthScreeners: true }); + expect(asked).toContain("44261-6"); + expect(asked).toContain("70274-6"); + }); + + it("lets the medication switch withhold the medication list", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medicationRow(), + ] as never); + + expect( + await bundleResourceTypes({ medications: { list: true } }), + ).toContain("MedicationStatement"); + expect( + await bundleResourceTypes({ medications: { list: false } }), + ).not.toContain("MedicationStatement"); + }); + + it("keeps allergies and family history out of the FHIR bundle when deselected", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const on = await bundleResourceTypes({ + allergies: true, + familyHistory: true, + }); + expect(on).toContain("AllergyIntolerance"); + expect(on).toContain("FamilyMemberHistory"); + + const off = await bundleResourceTypes({ + allergies: false, + familyHistory: false, + }); + expect(off).not.toContain("AllergyIntolerance"); + expect(off).not.toContain("FamilyMemberHistory"); + }); + + it("never reads the deselected records from the database", async () => { + await bundleResourceTypes({ allergies: false, familyHistory: false }); + expect(prisma.allergy.findMany).not.toHaveBeenCalled(); + expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled(); + }); + + it("ships a package whose bundle agrees with its PDF", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const { POST } = await import("../route"); + const res = await POST( + mkReq({ + format: "package", + sections: { allergies: false, familyHistory: false }, + }), + ); + expect(res.status).toBe(200); + const files = unzipSync(new Uint8Array(await res.arrayBuffer())); + const bundle = JSON.parse( + new TextDecoder().decode(files["bundle.json"]!), + ) as { entry: { resource: { resourceType: string } }[] }; + const types = bundle.entry.map((e) => e.resource.resourceType); + // The compliant PDF and the violating bundle used to travel together. + expect(types).not.toContain("AllergyIntolerance"); + expect(types).not.toContain("FamilyMemberHistory"); + }); +}); + /** * The practice name is a remembered preference (`User.lastReportPracticeName`), * so a monthly report does not ask for the clinic name again every time. The diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index e040c6b6e..57ae2e92b 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -64,8 +64,6 @@ function validBody(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: "2026-01-01T00:00:00Z", rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), ...overrides, }; @@ -77,8 +75,6 @@ function storedRow(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, passphraseHash: "hash(STORED)", expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), createdAt: new Date(), @@ -241,22 +237,27 @@ describe("POST /api/share-links — create", () => { expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); }); + it("rejects the retired FHIR scope keys with 422", async () => { + // The share link has never served FHIR against its token. The keys + // are gone from the create schema rather than accepted and ignored, so a + // stale client is told plainly instead of believing it scoped something. + const res = await POST( + postReq( + validBody({ allowFhirApi: true, resourceTypes: ["Observation"] }), + ), + ); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + it("allows a documents-only share (empty report sections)", async () => { vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, - ); - const res = await POST( - postReq( - validBody({ - resourceTypes: [], - allowFhirApi: false, - documentIds: ["doc-a"], - }), - ), + storedRow({ _count: { documents: 1 } }) as never, ); + const res = await POST(postReq(validBody({ documentIds: ["doc-a"] }))); expect(res.status).toBe(201); const body = (await res.json()) as { data: { documentCount: number } }; expect(body.data.documentCount).toBe(1); @@ -267,18 +268,15 @@ describe("POST /api/share-links — create", () => { { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, + storedRow({ _count: { documents: 1 } }) as never, ); // A hostile / stale client tries to widen a document link into a record - // share: FHIR on, resource types set, sections requested. The server must - // ignore every one of them. + // share by requesting sections. The server must ignore them. const res = await POST( postReq( validBody({ documentOnly: true, - allowFhirApi: true, - resourceTypes: ["Observation", "Patient"], sections: { vitals: { bp: true }, labs: true }, documentIds: ["doc-a"], }), @@ -288,9 +286,9 @@ describe("POST /api/share-links — create", () => { const createArg = vi.mocked(prisma.clinicianShareLink.create).mock .calls[0][0]; - // Empty FHIR scope, FHIR API off — no health surface at all. - expect(createArg.data.resourceTypes).toEqual([]); - expect(createArg.data.allowFhirApi).toBe(false); + // The retired FHIR scope columns are never written at all. + expect(createArg.data).not.toHaveProperty("resourceTypes"); + expect(createArg.data).not.toHaveProperty("allowFhirApi"); // Sections persist as an explicit all-OFF blob (distinct from `{}` = full // record defaults). The clinician view reads this as "no report". expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS); diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 55b121b09..35416057d 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -94,8 +94,6 @@ function resolvedContext() { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), }; } diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts index 96072c5c7..93adec25a 100644 --- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -159,8 +159,6 @@ beforeEach(() => { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 3_600_000), } as never); }); diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx index 707c63ca1..96e17aef8 100644 --- a/src/components/settings/__tests__/share-link-create-form.test.tsx +++ b/src/components/settings/__tests__/share-link-create-form.test.tsx @@ -112,19 +112,17 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expiryDays: 30, }; - it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => { + it("document-only: empty report scope and the launched doc always rides along", () => { const payload = buildShareLinkCreatePayload({ ...common, - // Even if the caller state still holds record-scope values, they must be - // dropped: a document link never carries a health scope. - allowFhirApi: true, - resourceTypes: ["Patient", "Observation"], documentIds: ["doc-1"], documentOnly: true, }); - expect(payload.resourceTypes).toEqual([]); - expect(payload.allowFhirApi).toBe(false); expect(payload.documentOnly).toBe(true); + // The retired FHIR scope keys are gone from the wire entirely, + // so a share link can no longer advertise a face that was never served. + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); // No report sections are ever sent for a document link. expect(payload).not.toHaveProperty("sections"); // The launched document is always in the created link (pre-attach fix). @@ -132,15 +130,15 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expect(payload.label).toBe("Blood panel 2026"); }); - it("record share: keeps the full scope and omits documentIds when none picked", () => { + it("record share: keeps the window scope and omits documentIds when none picked", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: [], documentOnly: false, }); - expect(payload.resourceTypes).toEqual(["Patient", "Observation"]); + expect(payload.rangeEnd).toBeNull(); + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); expect(payload).not.toHaveProperty("documentIds"); expect(payload).not.toHaveProperty("documentOnly"); }); @@ -148,8 +146,6 @@ describe("buildShareLinkCreatePayload — scope contract", () => { it("record share: attaches the picked documents when the owner chose some", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: ["a", "b"], documentOnly: false, }); diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx index 81a5171f4..7cadc1354 100644 --- a/src/components/settings/__tests__/sharing-section.test.tsx +++ b/src/components/settings/__tests__/sharing-section.test.tsx @@ -33,8 +33,6 @@ const FIXTURE_LINKS = [ label: "Cardiology referral", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: ["Patient", "Observation"], - allowFhirApi: true, documentCount: 2, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-06-01T00:00:00.000Z", @@ -48,8 +46,6 @@ const FIXTURE_LINKS = [ label: "Old GP link", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: [], - allowFhirApi: false, documentCount: 0, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-05-01T00:00:00.000Z", @@ -100,8 +96,9 @@ describe(" — owner share-link surface (C7)", () => { // The list response shape carries no token; the markup must not surface // an `hls_` string anywhere. expect(html).not.toContain("hls_"); - // The FHIR badge surfaces for the FHIR-enabled link. - expect(html).toContain("FHIR"); + // No FHIR badge: the link has never served FHIR, so advertising + // it on the owner's list was the last place the promise still showed. + expect(html).not.toContain("FHIR"); }); it("folds revoked links into the inactive list (not the active list)", () => { diff --git a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts index 814683dc1..c7252dd65 100644 --- a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts +++ b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts @@ -39,8 +39,6 @@ function liveRow(overrides: Record = {}) { rangeStart: new Date("2026-01-01T00:00:00.000Z"), rangeEnd: null, sectionsJson: { bp: true }, - resourceTypes: ["Observation"], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), revokedAt: null, ...overrides, @@ -59,7 +57,9 @@ describe("resolveShareToken", () => { expect(ctx).not.toBeNull(); expect(ctx?.ownerUserId).toBe("owner-1"); expect(ctx?.shareLinkId).toBe("link-1"); - expect(ctx?.resourceTypes).toEqual(["Observation"]); + // The retired FHIR scope no longer rides the resolved context. + expect(ctx).not.toHaveProperty("resourceTypes"); + expect(ctx).not.toHaveProperty("allowFhirApi"); // The context must carry ONLY the owner scope — never a session/user/role. expect(ctx).not.toHaveProperty("session"); expect(ctx).not.toHaveProperty("user"); diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 5eac1b659..2317f375f 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -45,8 +45,6 @@ function ctx(overrides: Partial = {}): ShareContext { rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: new Date("2026-02-01T00:00:00Z"), sectionsJson: { mood: false }, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), ...overrides, } as ShareContext; diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index c3abd34fc..e8073ab02 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -109,7 +109,14 @@ describe("resolveDoctorReportPrefs", () => { familyHistory: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); - expect(out).toEqual({ ...current, mood: false }); + // Keys the persisted row predates (this change added the measurement-group, + // medication-list and screener flags) fill from the defaults, so the + // resolved shape is always complete for the aggregator. + expect(out).toEqual({ + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }); }); it("layers a partial update over defaults when current is null", () => {