Files
HealthLog/src/lib/fhir/__tests__/rest-module-backstop.test.ts
T
Marc-André Bombeck 4548df84bf Gate the FHIR REST face on the doctorReport module
/api/fhir/* serves the same whole-record aggregate as
/api/export/health-record -- right down to the decrypted insurance number
on the Patient resource -- but never consulted the module gate. The
export refuses with a 403 module.disabled envelope when the account turns
the doctor-report module off; the FHIR face happily returned the full
Bundle to the same token.

Gate all five data routes (Patient, Observation, MedicationStatement,
MedicationAdministration, $everything). Refuse rather than omit: a
whole-record export has no truthful partial answer, and unlike the sync
delta feed no background client depends on it draining to stay
consistent. The refusal reuses the shared module.disabled envelope so the
errorCode clients already branch on stays the same.

loadFhirContext gains a fail-closed assertion as a second layer. It is
the one door to the aggregate, so it refuses outright instead of trusting
that every future caller remembers its gate; reaching it means a route is
missing one, which is a bug to surface rather than a flow to serve.

The static CapabilityStatement at /api/fhir/metadata stays open -- server
metadata, no user data.
2026-07-19 01:39:58 +02:00

84 lines
3.0 KiB
TypeScript

/**
* `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<string, boolean>;
}) => {
// 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");
});
});