Files
HealthLog/tests/integration/routes/fhir-module-gate.test.ts
T
Marc-André Bombeck 5ca3994523 fix(api): declare the FHIR refusals as the OperationOutcomes they are
A /api/fhir/* handler answers its own refusals as FHIR: a bad search
parameter is a 400 OperationOutcome, the hourly fhir bucket a 429
OperationOutcome (issue code throttled), both served as
application/fhir+json, the same way the 404 on a read already was. The
contract declared no 400 and the generic JSON 429 for the 429, and no
403 for the doctorReport module gate, which every data route runs.

The searches now declare the 400, every data route the 403
module.disabled envelope and the FHIR 429 with the rate-limit headers
apiHandler attaches to it. The module gate keeps answering the JSON
envelope: it is the errorCode every client already branches on for a
disabled module, and it runs before the FHIR face does. The route tests
drop their skips and assert the 429's content type and Retry-After.
2026-10-22 07:07:34 +02:00

194 lines
6.4 KiB
TypeScript

/**
* `/api/fhir/*` and the `doctorReport` module, against the real database and
* through the real API kit.
*
* The FHIR face serves the same whole-record aggregate as the document
* export, down to the decrypted insurance number on the Patient resource, so
* it answers to the same module. A whole-record export has no truthful partial
* answer: with the module off every data route, search and read alike,
* refuses with the shared 403 `module.disabled` envelope and none of the
* record leaves; the static CapabilityStatement carries no user data and
* stays reachable. The module is switched the way the settings switch it,
* through the stored per-account preferences.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET as METADATA } from "@/app/api/fhir/metadata/route";
import { GET as PATIENT_SEARCH } from "@/app/api/fhir/Patient/route";
import { GET as PATIENT_READ } from "@/app/api/fhir/Patient/[id]/route";
import { GET as EVERYTHING } from "@/app/api/fhir/Patient/$everything/route";
import { GET as OBSERVATION_SEARCH } from "@/app/api/fhir/Observation/route";
import { GET as OBSERVATION_READ } from "@/app/api/fhir/Observation/[id]/route";
import { GET as STATEMENT_SEARCH } from "@/app/api/fhir/MedicationStatement/route";
import { GET as STATEMENT_READ } from "@/app/api/fhir/MedicationStatement/[id]/route";
import { GET as ADMINISTRATION_SEARCH } from "@/app/api/fhir/MedicationAdministration/route";
import { GET as ADMINISTRATION_READ } from "@/app/api/fhir/MedicationAdministration/[id]/route";
import { GET as COVERAGE_SEARCH } from "@/app/api/fhir/Coverage/route";
import { GET as COVERAGE_READ } from "@/app/api/fhir/Coverage/[id]/route";
import { encrypt } from "@/lib/crypto";
import { FHIR_READ_SCOPE } from "@/lib/fhir/rest";
import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate";
import { callRoute, type RouteAuth } from "../../kit/call-route";
import {
makeMeasurement,
makeMedication,
makeSession,
makeToken,
makeUser,
} from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const INSURANCE_NUMBER = "A123456789";
type Handler = Parameters<typeof callRoute>[0];
/** Every data route, by the path a caller hits; the reads name a held id. */
const DATA_ROUTES: ReadonlyArray<{
handler: Handler;
route: string;
params?: { id: string };
}> = [
{ handler: PATIENT_SEARCH, route: "/api/fhir/Patient" },
{ handler: COVERAGE_SEARCH, route: "/api/fhir/Coverage" },
{ handler: OBSERVATION_SEARCH, route: "/api/fhir/Observation" },
{ handler: STATEMENT_SEARCH, route: "/api/fhir/MedicationStatement" },
{
handler: ADMINISTRATION_SEARCH,
route: "/api/fhir/MedicationAdministration",
},
{ handler: EVERYTHING, route: "/api/fhir/Patient/$everything" },
{
handler: PATIENT_READ,
route: "/api/fhir/Patient/{id}",
params: { id: "patient-1" },
},
{
handler: COVERAGE_READ,
route: "/api/fhir/Coverage/{id}",
params: { id: "coverage-1" },
},
{
handler: OBSERVATION_READ,
route: "/api/fhir/Observation/{id}",
params: { id: "obs-1" },
},
{
handler: STATEMENT_READ,
route: "/api/fhir/MedicationStatement/{id}",
params: { id: "med-1" },
},
{
handler: ADMINISTRATION_READ,
route: "/api/fhir/MedicationAdministration/{id}",
params: { id: "medadmin-1" },
},
];
beforeEach(async () => {
await truncateAllTables(prisma);
});
/** An account whose saved selection reaches every routed resource type. */
async function seedOwner(doctorReport: boolean) {
const now = Date.now();
const user = await makeUser({
fullName: "Sample Patient",
insurerName: "Example Insurer",
insuranceNumberEncrypted: encrypt(INSURANCE_NUMBER),
reportSelectionJson: {
v: 2,
leaves: [
"PATIENT_IDENTITY",
"INSURANCE",
"WEIGHT",
"MEDICATION_LIST",
"MEDICATION_ADMINISTRATIONS",
],
},
modulePreferencesJson: doctorReport ? {} : { doctorReport: false },
});
await makeMeasurement(user, {
value: 79.5,
measuredAt: new Date(now - 24 * 60 * 60 * 1000),
});
const medication = await makeMedication(user, {
name: "Example Drug",
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000),
});
const takenAt = new Date(now - 24 * 60 * 60 * 1000);
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: medication.id,
scheduledFor: takenAt,
takenAt,
},
});
return user;
}
async function callEach(auth: RouteAuth) {
const results = [];
for (const { handler, route, params } of DATA_ROUTES) {
const res = await callRoute(handler, {
method: "GET",
route,
params,
auth,
});
results.push({ route, res });
}
return results;
}
describe("/api/fhir/* and the doctorReport module", () => {
it("refuses every data route with module.disabled while the module is off, and none of the record leaves", async () => {
const owner = await seedOwner(false);
for (const auth of [
await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }),
await makeSession(owner),
]) {
for (const { route, res } of await callEach(auth)) {
expect(res.status, route).toBe(403);
expect(res.data, route).toBeNull();
expect(res.meta, route).toMatchObject({
errorCode: MODULE_DISABLED_ERROR_CODE,
module: "doctorReport",
});
const body = JSON.stringify(res.body);
for (const stored of [INSURANCE_NUMBER, "Sample Patient", "79.5"]) {
expect(body, route).not.toContain(stored);
}
}
}
});
it("serves every data route once the module is on", async () => {
const owner = await seedOwner(true);
const auth = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] });
for (const { route, res } of await callEach(auth)) {
expect(res.status, route).toBe(200);
expect(res.headers.get("content-type"), route).toContain(
"application/fhir+json",
);
}
});
it("leaves the CapabilityStatement reachable while the module is off", async () => {
const owner = await seedOwner(false);
const res = await callRoute(METADATA, {
method: "GET",
route: "/api/fhir/metadata",
auth: await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }),
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ resourceType: "CapabilityStatement" });
});
});