diff --git a/src/app/api/fhir/$everything/route.ts b/src/app/api/fhir/$everything/route.ts index e804b1f0e..e42a49b80 100644 --- a/src/app/api/fhir/$everything/route.ts +++ b/src/app/api/fhir/$everything/route.ts @@ -10,6 +10,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { @@ -32,6 +33,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.everything.read" } }); + // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as + // `/api/export/health-record` (the doctor-report builder), right down to + // the decrypted insurance number on the Patient resource. That export + // gates on the `doctorReport` module; this surface did not, so the module + // could be off and `/api/fhir/*` still handed out the full record. + // + // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is + // a whole-record export, not an incremental feed: there is no partial + // answer that is still a truthful FHIR Bundle, and no background client + // depends on it draining to stay consistent. Mirroring the sibling export's + // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps + // the errorCode the clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index a8e489309..747b8f71b 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -9,6 +9,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources"; @@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.medicationadministration.search" } }); + // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as + // `/api/export/health-record` (the doctor-report builder), right down to + // the decrypted insurance number on the Patient resource. That export + // gates on the `doctorReport` module; this surface did not, so the module + // could be off and `/api/fhir/*` still handed out the full record. + // + // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is + // a whole-record export, not an incremental feed: there is no partial + // answer that is still a truthful FHIR Bundle, and no background client + // depends on it draining to stay consistent. Mirroring the sibling export's + // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps + // the errorCode the clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index 6cbafb86b..b7565735d 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -10,6 +10,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { medicationStatementsFromReportData } from "@/lib/fhir/resources"; @@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.medicationstatement.search" } }); + // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as + // `/api/export/health-record` (the doctor-report builder), right down to + // the decrypted insurance number on the Patient resource. That export + // gates on the `doctorReport` module; this surface did not, so the module + // could be off and `/api/fhir/*` still handed out the full record. + // + // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is + // a whole-record export, not an incremental feed: there is no partial + // answer that is still a truthful FHIR Bundle, and no background client + // depends on it draining to stay consistent. Mirroring the sibling export's + // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps + // the errorCode the clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index c440b2577..22760e71b 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -10,6 +10,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { observationsFromReportData } from "@/lib/fhir/resources"; @@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.observation.search" } }); + // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as + // `/api/export/health-record` (the doctor-report builder), right down to + // the decrypted insurance number on the Patient resource. That export + // gates on the `doctorReport` module; this surface did not, so the module + // could be off and `/api/fhir/*` still handed out the full record. + // + // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is + // a whole-record export, not an incremental feed: there is no partial + // answer that is still a truthful FHIR Bundle, and no background client + // depends on it draining to stay consistent. Mirroring the sibling export's + // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps + // the errorCode the clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index 19d6e1aa8..c6e2f3b74 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -9,6 +9,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { patientResource } from "@/lib/fhir/resources"; @@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); annotate({ action: { name: "fhir.patient.search" } }); + // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as + // `/api/export/health-record` (the doctor-report builder), right down to + // the decrypted insurance number on the Patient resource. That export + // gates on the `doctorReport` module; this surface did not, so the module + // could be off and `/api/fhir/*` still handed out the full record. + // + // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is + // a whole-record export, not an incremental feed: there is no partial + // answer that is still a truthful FHIR Bundle, and no background client + // depends on it draining to stay consistent. Mirroring the sibling export's + // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps + // the errorCode the clients already branch on for a disabled module. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts new file mode 100644 index 000000000..f869a3e75 --- /dev/null +++ b/src/app/api/fhir/__tests__/module-gate.test.ts @@ -0,0 +1,179 @@ +/** + * `/api/fhir/*` — doctorReport module gate. + * + * The FHIR REST face serves the same whole-record aggregate as + * `/api/export/health-record`, including the decrypted insurance number on + * the Patient resource. The export gates on the `doctorReport` module; the + * FHIR routes did not, so the module could be off and `$everything` still + * returned the full Bundle to the same token. + * + * REFUSE, not omit — a whole-record export has no truthful partial answer. + * Every data route 403s with the shared `module.disabled` envelope; only the + * static CapabilityStatement at `/api/fhir/metadata` stays open (server + * metadata, no user data). + * + * Behavioural: the assertions read status codes and check that no aggregate + * was ever loaded, so removing a gate turns them red. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +// The module gate itself is NOT mocked — the whole point is to exercise the +// real resolver and the real 403 envelope. Only its data sources are stubbed, +// so the module state is driven the same way production drives it: through +// the persisted `modulePreferencesJson` allowlist. +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + cycleProfile: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/modules/operator-availability") + >(); + return { ...actual, getOperatorModuleAvailability: vi.fn() }; +}); +vi.mock("@/lib/fhir/rest", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadFhirContext: vi.fn() }; +}); +vi.mock("@/lib/fhir/resources", () => ({ + GERMAN_ATC_DEFAULT_LOCALES: ["de"], + patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })), + coverageResource: vi.fn(() => null), + observationsFromReportData: vi.fn(() => []), + medicationStatementsFromReportData: vi.fn(() => []), + medicationAdministrationsFromReportData: vi.fn(() => []), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as patientGet } from "../Patient/route"; +import { GET as observationGet } from "../Observation/route"; +import { GET as medStatementGet } from "../MedicationStatement/route"; +import { GET as medAdminGet } from "../MedicationAdministration/route"; +import { GET as everythingGet } from "../$everything/route"; +import { GET as metadataGet } from "../metadata/route"; + +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate"; +import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability"; +import { loadFhirContext } from "@/lib/fhir/rest"; +import { prisma } from "@/lib/db"; + +/** + * Drive the REAL gate the way production does — through the persisted + * `modulePreferencesJson` disabled-allowlist, where only a literal `false` + * turns a module off. + */ +function setDoctorReportModule(enabled: boolean): void { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + gender: null, + disableCoach: false, + modulePreferencesJson: enabled ? {} : { doctorReport: false }, + } as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); +} + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +/** Every data route under `/api/fhir`, by the path a caller would hit. */ +const DATA_ROUTES: ReadonlyArray< + [string, (req: NextRequest) => Promise] +> = [ + ["Patient", patientGet], + ["Observation", observationGet], + ["MedicationStatement", medStatementGet], + ["MedicationAdministration", medAdminGet], + ["$everything", everythingGet], +]; + +function req(path: string): NextRequest { + return new NextRequest(`http://localhost/api/fhir/${path}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: Date.now(), + } as never); + // Operator layer available for every module; the per-user layer is what + // each test drives. + vi.mocked(getOperatorModuleAvailability).mockResolvedValue( + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, + ); + vi.mocked(loadFhirContext).mockResolvedValue({ + data: {} as never, + identity: { insuranceNumber: "A123456789" }, + germanAtc: false, + }); +}); + +describe("/api/fhir/* — doctorReport module gate", () => { + describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => { + it("403s with module.disabled when the module is off", async () => { + setDoctorReportModule(false); + + const res = await handler(req(path)); + expect(res.status).toBe(403); + + const body = (await res.json()) as { + data: unknown; + error: string; + meta?: { errorCode?: string; module?: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE); + expect(body.meta?.module).toBe("doctorReport"); + + // Refused before any record was assembled — the insurance number + // never left the store. + expect(loadFhirContext).not.toHaveBeenCalled(); + expect(JSON.stringify(body)).not.toContain("A123456789"); + }); + + it("serves the Bundle when the module is on", async () => { + setDoctorReportModule(true); + + const res = await handler(req(path)); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain( + "application/fhir+json", + ); + + const bundle = (await res.json()) as { resourceType: string }; + expect(bundle.resourceType).toBe("Bundle"); + expect(loadFhirContext).toHaveBeenCalledWith("user-1"); + }); + }); + + it("leaves the static CapabilityStatement reachable while the module is off", async () => { + // Server metadata, no user data — the one FHIR route that stays open. + setDoctorReportModule(false); + const res = await metadataGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { resourceType: string }; + expect(body.resourceType).toBe("CapabilityStatement"); + }); +}); diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index 78b000b4e..0a061e8da 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -16,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + isModuleEnabled: vi.fn(), +})); vi.mock("@/lib/fhir/rest", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadFhirContext: vi.fn() }; @@ -36,6 +40,7 @@ import { GET as observationGet } from "../Observation/route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest"; +import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; import { observationsFromReportData } from "@/lib/fhir/resources"; const SESSION_OK = { @@ -65,6 +70,10 @@ beforeEach(() => { count: 1, resetAt: Date.now(), } as never); + // The doctorReport module is ON for the contract assertions below; the + // OFF behaviour is pinned in `module-gate.test.ts`. + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(isModuleEnabled).mockResolvedValue(true); vi.mocked(loadFhirContext).mockResolvedValue({ data: {} as never, identity: { insuranceNumber: null }, diff --git a/src/lib/fhir/__tests__/rest-module-backstop.test.ts b/src/lib/fhir/__tests__/rest-module-backstop.test.ts new file mode 100644 index 000000000..e048ee6c5 --- /dev/null +++ b/src/lib/fhir/__tests__/rest-module-backstop.test.ts @@ -0,0 +1,83 @@ +/** + * `loadFhirContext` — doctorReport module backstop. + * + * The loader is the one door to the whole-record aggregate the FHIR face + * serves, including the decrypted insurance number. Every `/api/fhir/*` data + * route carries its own `requireModuleEnabled` gate (that is what produces the + * clean 403); this test pins the SECOND layer — the loader refuses outright + * rather than trusting that every present and future caller remembered. + * + * The gate resolver is exercised for real; only its data sources are stubbed. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + cycleProfile: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/modules/operator-availability") + >(); + return { ...actual, getOperatorModuleAvailability: vi.fn() }; +}); +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), + normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })), +})); +vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") })); + +import { loadFhirContext } from "@/lib/fhir/rest"; +import { MODULE_KEYS } from "@/lib/modules/gate"; +import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { prisma } from "@/lib/db"; + +function setDoctorReportModule(enabled: boolean): void { + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + select?: Record; + }) => { + // The gate's own read and the loader's identity read hit the same model. + if (args?.select?.modulePreferencesJson) { + return { + gender: null, + disableCoach: false, + modulePreferencesJson: enabled ? {} : { doctorReport: false }, + }; + } + return { insuranceNumberEncrypted: "cipher", locale: "de" }; + }) as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getOperatorModuleAvailability).mockResolvedValue( + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, + ); + vi.mocked(collectDoctorReportData).mockResolvedValue({} as never); +}); + +describe("loadFhirContext — doctorReport backstop", () => { + it("refuses to assemble the record when the module is off", async () => { + setDoctorReportModule(false); + + await expect(loadFhirContext("user-1")).rejects.toThrow( + /doctorReport.*disabled/, + ); + + // Nothing was aggregated and nothing was decrypted. + expect(collectDoctorReportData).not.toHaveBeenCalled(); + }); + + it("assembles the record when the module is on", async () => { + setDoctorReportModule(true); + + const ctx = await loadFhirContext("user-1"); + expect(collectDoctorReportData).toHaveBeenCalledTimes(1); + expect(ctx.identity.insuranceNumber).toBe("A123456789"); + }); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index a72d345a3..9094504ed 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -22,6 +22,7 @@ import { type DoctorReportData, } from "@/lib/doctor-report-data"; import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources"; +import { isModuleEnabled } from "@/lib/modules/gate"; import type { FhirBundleEntry, FhirBundleLink, @@ -182,12 +183,28 @@ export function operationOutcome( * The window matches the document export's default (`normaliseDateRange` * with no override). KVNR decryption is fail-soft: a key-rotation gap on a * single row omits the identifier rather than 500-ing the read. + * + * MODULE BACKSTOP. Every `/api/fhir/*` data route calls + * `requireModuleEnabled(userId, "doctorReport")` itself, which is what + * produces the clean 403 envelope. This assertion is the second layer: the + * loader is the one door to the whole-record aggregate (including the + * decrypted insurance number), so it refuses outright rather than trusting + * that every present and future caller remembered the gate. It throws + * instead of returning an envelope because reaching it means a caller is + * missing its gate — a bug to surface, not a flow to serve. */ export async function loadFhirContext(userId: string): Promise<{ data: DoctorReportData; identity: { insuranceNumber: string | null }; germanAtc: boolean; }> { + if (!(await isModuleEnabled(userId, "doctorReport"))) { + throw new Error( + 'loadFhirContext called with the "doctorReport" module disabled — ' + + "the calling route is missing its requireModuleEnabled gate", + ); + } + const range = normaliseDateRange(undefined); const [data, userRow] = await Promise.all([ collectDoctorReportData(userId, range, {}),