mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(capabilities): advertise the FHIR REST face and share-link surface
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the $everything operation and honoured search params) to fhir/rest.ts; the metadata CapabilityStatement and the share-link resource-type enum now derive from it so the advertised set can never drift from what is routed. Extend GET /api/meta/capabilities with the FHIR REST descriptor (restBaseUrl, readScope, resourceTypes, operations, searchParams) and a clinician-share descriptor (supported, maxDays, resourceTypes, scopeable sections) — every list sourced from its canonical registry, guarded by the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to match. Fill the clinician test-matrix gap: assert the clinician view never requests a KVNR / identifier opt-in from the aggregator (default-OFF by construction) and resolves a rolling window without reaching data older than the frozen start.
This commit is contained in:
1 parent
93694cacd0
commit
d4686ea2a6
7 files changed
+240
-24
No files matched your search
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* v1.11.0 (Epic C, C8) — clinician-view scoped data load guarantees.
|
||||
*
|
||||
* The public clinician view aggregates ONLY the data the owner froze into the
|
||||
* link, and it must NEVER surface the insurance number (KVNR). KVNR is
|
||||
* default-OFF by construction: `loadShareViewData` calls the doctor-report
|
||||
* aggregator with the frozen window + section toggles and nothing else — no
|
||||
* identifier opt-in, no decrypt path. This test pins that contract so a future
|
||||
* change can't silently widen the clinician view to leak the KVNR.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/validations/doctor-report-prefs", () => ({
|
||||
parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}),
|
||||
}));
|
||||
|
||||
import { loadShareViewData } from "../share-view-data";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { ShareContext } from "../resolve-share-token";
|
||||
|
||||
const collect = collectDoctorReportData as ReturnType<typeof vi.fn>;
|
||||
|
||||
function ctx(overrides: Partial<ShareContext> = {}): ShareContext {
|
||||
return {
|
||||
shareLinkId: "link-1",
|
||||
ownerUserId: "owner-1",
|
||||
label: "Clinic",
|
||||
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;
|
||||
}
|
||||
|
||||
describe("loadShareViewData — KVNR default OFF", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
collect.mockResolvedValue({
|
||||
patient: { displayName: "Shared record" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the aggregator to the OWNER from the share context, never the wire", async () => {
|
||||
await loadShareViewData(ctx());
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
expect(collect.mock.calls[0]![0]).toBe("owner-1");
|
||||
});
|
||||
|
||||
it("never requests an identifier / KVNR opt-in from the aggregator", async () => {
|
||||
await loadShareViewData(ctx());
|
||||
const opts = collect.mock.calls[0]![2] as Record<string, unknown>;
|
||||
// The only option ever passed is the frozen section toggles — no
|
||||
// includeIdentifiers, no kvnr, no decrypt flag. Default-OFF by absence.
|
||||
expect(opts).toBeDefined();
|
||||
expect(opts).not.toHaveProperty("includeIdentifiers");
|
||||
expect(opts).not.toHaveProperty("kvnr");
|
||||
expect(opts).not.toHaveProperty("insuranceNumber");
|
||||
expect(Object.keys(opts)).toEqual(["sections"]);
|
||||
});
|
||||
|
||||
it("returns a report payload carrying no insurance number", async () => {
|
||||
const { report } = await loadShareViewData(ctx());
|
||||
const patient = (report as { patient?: Record<string, unknown> }).patient;
|
||||
expect(patient).not.toHaveProperty("insuranceNumber");
|
||||
expect(patient).not.toHaveProperty("kvnr");
|
||||
});
|
||||
|
||||
it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => {
|
||||
const now = Date.now();
|
||||
await loadShareViewData(ctx({ rangeEnd: null }));
|
||||
const range = collect.mock.calls[0]![1] as {
|
||||
start: Date;
|
||||
end: Date;
|
||||
days: number;
|
||||
};
|
||||
expect(range.start.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
// Rolling end materialises near "now", never before the frozen start.
|
||||
expect(range.end.getTime()).toBeGreaterThanOrEqual(now - 5_000);
|
||||
expect(range.days).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,30 @@ import type {
|
||||
/** The Bearer scope a narrow-scoped token must carry to read the FHIR face. */
|
||||
export const FHIR_READ_SCOPE = "fhir:read";
|
||||
|
||||
/**
|
||||
* Canonical catalogue of FHIR R4 resource types the read-only REST face serves
|
||||
* (`read` + `search-type` interactions only — no write, ever). One source of
|
||||
* truth: the `metadata` CapabilityStatement, the `/api/meta/capabilities`
|
||||
* discovery surface, and the share-link resource-type enum all derive from
|
||||
* this so the advertised set can never drift from what is actually routed.
|
||||
*/
|
||||
export const FHIR_REST_RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The whole-record operation the REST face exposes (`GET /api/fhir/$everything`),
|
||||
* returning the existing `type: "document"` Bundle. Surfaced in discovery so a
|
||||
* client knows the snapshot pull exists alongside the per-resource searches.
|
||||
*/
|
||||
export const FHIR_EVERYTHING_OPERATION = "$everything";
|
||||
|
||||
/** Search parameters honoured uniformly across the search routes. */
|
||||
export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const;
|
||||
|
||||
/** Canonical FHIR media type for every response (success or outcome). */
|
||||
export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8";
|
||||
|
||||
|
||||
@@ -1952,8 +1952,40 @@ const capabilitiesResponse = z
|
||||
germanAtcDefaultLocales: z
|
||||
.array(z.string())
|
||||
.describe("App locales that default the additive BfArM ATC coding on."),
|
||||
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."),
|
||||
resourceTypes: z
|
||||
.array(z.string())
|
||||
.describe("FHIR resource types the REST face serves (read + search)."),
|
||||
operations: z
|
||||
.array(z.string())
|
||||
.describe("Whole-record operations exposed (e.g. $everything)."),
|
||||
searchParams: z
|
||||
.array(z.string())
|
||||
.describe("Search parameters honoured uniformly across the search routes."),
|
||||
})
|
||||
.describe("FHIR coding constants the health-record export emits."),
|
||||
.describe(
|
||||
"FHIR coding constants + the read-only REST face descriptor (v1.11).",
|
||||
),
|
||||
share: z
|
||||
.object({
|
||||
supported: z.boolean().describe("Whether clinician share links are served."),
|
||||
maxDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Maximum lifetime of a share link, in days. No never-expiring share."),
|
||||
resourceTypes: z
|
||||
.array(z.string())
|
||||
.describe("FHIR resource types a share link may be scoped to serve."),
|
||||
sections: z
|
||||
.array(z.string())
|
||||
.describe("Scopeable report sections a share link may toggle."),
|
||||
})
|
||||
.describe("Clinician share-link surface descriptor (v1.11)."),
|
||||
})
|
||||
.meta({
|
||||
id: "CapabilitiesResponse",
|
||||
|
||||
@@ -13,23 +13,21 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest";
|
||||
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
|
||||
|
||||
/** Maximum lifetime of a share link, in days. No never-expiring share. */
|
||||
export const SHARE_LINK_MAX_DAYS = 90;
|
||||
|
||||
/**
|
||||
* The FHIR resource types a share link may be scoped to serve — the read-only
|
||||
* catalogue the REST face exposes. A create request may select any subset;
|
||||
* an empty array means "no FHIR resources" (view-only, when `allowFhirApi` is
|
||||
* off this is moot).
|
||||
* The FHIR resource types a share link may be scoped to serve — exactly the
|
||||
* read-only catalogue the REST face exposes. Derived from the single canonical
|
||||
* `FHIR_REST_RESOURCE_TYPES` so a share can never be scoped to a type the REST
|
||||
* face does not actually route. A create request may select any subset; an
|
||||
* empty array means "no FHIR resources" (view-only, moot when `allowFhirApi`
|
||||
* is off).
|
||||
*/
|
||||
export const SHARE_LINK_RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
export const SHARE_LINK_RESOURCE_TYPES = FHIR_REST_RESOURCE_TYPES;
|
||||
|
||||
export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
|
||||
|
||||
|
||||
Reference in new issue
Block a user