Files
HealthLog/src/app/api/fhir/__tests__/route.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

176 lines
6.3 KiB
TypeScript

/**
* v1.11.0 (Epic C, C2) — read-only FHIR R4 REST face.
*
* Asserts the searchset Bundle shape (total + self/next links + `match`
* mode + `application/fhir+json`), `_count` clamping (≤200), paging, the
* `fhir:read` scope wiring, and the `OperationOutcome` rate-limit envelope.
* The shared data loader is stubbed so the test pins the REST contract, not
* the aggregator (which has its own coverage).
*/
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),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
observationsFromReportData: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
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 = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function obs(id: number) {
return {
resourceType: "Observation" as const,
id: `obs-${id}`,
status: "final" as const,
code: { text: `m-${id}` },
subject: { reference: "Patient/patient-1" },
};
}
function req(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/fhir/Observation${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
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 },
germanAtc: false,
});
});
describe("GET /api/fhir/Observation — searchset", () => {
it("returns a searchset Bundle with total, self link, and match mode", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
] as never);
const res = await observationGet(req());
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const bundle = (await res.json()) as {
resourceType: string;
type: string;
total: number;
link: Array<{ relation: string; url: string }>;
entry: Array<{ search: { mode: string }; fullUrl: string }>;
};
expect(bundle.resourceType).toBe("Bundle");
expect(bundle.type).toBe("searchset");
expect(bundle.total).toBe(2);
expect(bundle.entry).toHaveLength(2);
expect(bundle.entry[0].search.mode).toBe("match");
expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/obs-1");
expect(bundle.link.find((l) => l.relation === "self")).toBeTruthy();
// Single page → no next link.
expect(bundle.link.find((l) => l.relation === "next")).toBeUndefined();
});
it("clamps _count above the ceiling and emits a next link when more remain", async () => {
vi.mocked(observationsFromReportData).mockReturnValue(
Array.from({ length: 250 }, (_, i) => obs(i)) as never,
);
const res = await observationGet(req("?_count=9999&_offset=0"));
const bundle = (await res.json()) as {
total: number;
link: Array<{ relation: string; url: string }>;
entry: unknown[];
};
// 250 total, clamped page of MAX_COUNT.
expect(bundle.total).toBe(250);
expect(bundle.entry).toHaveLength(MAX_COUNT);
const next = bundle.link.find((l) => l.relation === "next");
expect(next).toBeTruthy();
expect(next!.url).toContain(`_count=${MAX_COUNT}`);
expect(next!.url).toContain(`_offset=${MAX_COUNT}`);
});
it("pages with _offset", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
obs(3),
] as never);
const res = await observationGet(req("?_count=2&_offset=2"));
const bundle = (await res.json()) as {
total: number;
entry: Array<{ resource: { id: string } }>;
};
expect(bundle.total).toBe(3);
expect(bundle.entry).toHaveLength(1);
expect(bundle.entry[0].resource.id).toBe("obs-3");
});
it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => {
// No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a
// token lacking the scope; here no auth at all yields 401, proving the
// route does gate auth before doing any work.
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await observationGet(req());
expect(res.status).toBe(401);
expect(loadFhirContext).not.toHaveBeenCalled();
});
it("returns an OperationOutcome on rate-limit", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
count: 999,
resetAt: Date.now(),
} as never);
const res = await observationGet(req());
expect(res.status).toBe(429);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const body = (await res.json()) as {
resourceType: string;
issue: Array<{ severity: string; code: string }>;
};
expect(body.resourceType).toBe("OperationOutcome");
expect(body.issue[0].code).toBe("throttled");
});
});