diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 5132e2a40..4eb497233 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -38,13 +38,7 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; * `coach-route-gate-inventory.test.ts`; listed here so * coach-bearing routes are not flagged as ungated. * - * 3. BUILDER-GATED — the route delegates its whole payload to an - * aggregator that resolves `resolveModuleMap(userId)` once and - * excludes disabled-module sections/resources from the build - * (`collectDoctorReportData`, `loadFhirContext`). The exclusion is - * structural at the builder, so the route carries no per-key call. - * - * 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a + * 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a * toggleable domain but are deliberately NOT gated, each with the * reason at the entry. The two reasons in play: * - DATA LAYER: raw CRUD over the domain's own rows. Writing or @@ -56,9 +50,30 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; * - INFRA / UI-ONLY: settings, availability probes, and the static * FHIR CapabilityStatement carry no module data. * - * Anything that doesn't fit one of the four buckets is an orphan and + * Anything that doesn't fit one of the three buckets is an orphan and * fails the test BY NAME so the fix is one search-and-add: gate the route * or move it onto the EXEMPT allowlist with a documented reason. + * + * WHAT THIS TEST DOES NOT PROVE, AND WHO PROVES IT + * ----------------------------------------------- + * This file is a DISCOVERY test: it proves no route escapes classification. + * It cannot prove a gate actually refuses, because it only reads source text. + * + * That distinction used to be blurred. A fourth "BUILDER-GATED" bucket + * accepted the mere MENTION of an aggregator's name (`loadFhirContext`, + * `collectDoctorReportData`) as proof that the route was gated. It was not + * proof of anything: `loadFhirContext` filtered per-domain modules but never + * evaluated `doctorReport`, so all five `/api/fhir/*` data routes sat in a + * green bucket while serving the whole record — including the decrypted + * insurance number — to an account that had the doctor-report module off. + * The bucket is gone. A route is now only counted as gated when it CALLS a + * gate, and `REQUIRED_TREE_MODULE` below pins which key it must name. + * + * The behaviour itself — a disabled module actually producing a 403 or an + * omission — is proven by the per-surface tests listed in + * `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so + * deleting the behavioural coverage fails here with a pointer to what was + * lost rather than silently leaving only a text-match behind. */ /** @@ -99,9 +114,10 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "src/app/api/medications", // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + // availability probe) was orphaned dead code (no production caller) and - // removed. The live doctor-report / FHIR surface is `/api/export/health-record`, - // which gates on the `doctorReport` module directly AND through the - // `collectDoctorReportData` builder. + // removed. The doctor-report surface is `/api/export/health-record` plus + // the FHIR REST face below; both gate on `doctorReport` directly. The FHIR + // routes previously relied on the removed builder-mention bucket and were + // in practice ungated — see the header note. "src/app/api/fhir", // v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards / // correlations / derived / narrative / pregenerate / rhythm-events route @@ -273,12 +289,55 @@ const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; // `requireCycleEnabled`. Recognised as a delegated gate so illness routes // are not flagged as ungated. const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled("; -// Builder aggregators that resolve `resolveModuleMap` once and exclude -// disabled-module sections/resources at the build boundary. -const BUILDER_GATE_NEEDLES: ReadonlyArray = [ - "collectDoctorReportData", - "loadFhirContext", -]; + +/** + * Trees whose routes must gate on ONE specific module key. Naming the key + * here means a future route in the tree cannot satisfy the inventory by + * gating on some other, more permissive module — the failure mode that a + * generic "is there any gate?" check cannot see. + * + * `/api/fhir` is the entry that matters: every data route there serves the + * whole-record doctor-report aggregate, so `doctorReport` is the only + * correct key. + */ +const REQUIRED_TREE_MODULE: Readonly> = { + "src/app/api/fhir": "doctorReport", +}; + +/** + * Surfaces whose module behaviour is proven by a real behavioural test + * (module off ⇒ 403 or omission; module on ⇒ served). Listed so that + * deleting the proof fails the inventory instead of quietly downgrading the + * guarantee back to a text match. + * + * The three aggregate surfaces below are the ones that re-served per-domain + * data ungated: two of them (`/api/sync/changes`, `/api/dashboard/summary`) + * are cross-domain feeds that live outside `MODULE_ROUTE_TREES` entirely, so + * without this list nothing in the inventory would notice them at all. + */ +const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> = + [ + { + path: "src/app/api/fhir/__tests__/module-gate.test.ts", + proves: + "every /api/fhir data route 403s with module.disabled when doctorReport is off", + }, + { + path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts", + proves: + "loadFhirContext itself refuses to assemble the record when doctorReport is off", + }, + { + path: "src/app/api/sync/changes/__tests__/module-gate.test.ts", + proves: + "the sync delta feed omits cycleDays/cycles (and never queries them) when cycle is off", + }, + { + path: "src/app/api/dashboard/summary/__tests__/module-gate.test.ts", + proves: + "the dashboard summary drops disabled-module metric cards while core vitals still ship", + }, + ]; /** * True when the file contains the needle on a line that is NOT a pure @@ -373,7 +432,7 @@ describe("module API route gate inventory", () => { } }); - it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => { + it("every toggleable-module route is gated, delegated, or explicitly exempt", () => { const routes = findAllModuleRouteFiles(); expect(routes.length).toBeGreaterThan(0); @@ -387,15 +446,16 @@ describe("module API route gate inventory", () => { if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue; if (fileHasCall(text, COACH_GATE_NEEDLE)) continue; if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue; - if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue; if (exempt.has(path)) continue; orphans.push({ path, reason: - 'no module gate found — add requireModuleEnabled(""), a delegated gate, ' + - "a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason", + 'no module gate found — add requireModuleEnabled("") or a delegated gate, ' + + "or move the route onto EXEMPT_ROUTES with a documented reason. " + + "Importing a module-aware builder is NOT a gate: a builder can filter some " + + "modules and never evaluate yours.", }); } @@ -432,6 +492,55 @@ describe("module API route gate inventory", () => { ).toEqual([]); }); + it("routes in a key-pinned tree gate on that tree's module key", () => { + // A generic "is there any gate?" check cannot tell `doctorReport` from + // some other, more permissive key. Where a whole tree serves exactly one + // module, name it. + const wrong: Array<{ path: string; found: string[] }> = []; + + for (const [tree, requiredKey] of Object.entries(REQUIRED_TREE_MODULE)) { + for (const path of findRouteFiles(tree)) { + if (EXEMPT_ROUTES.includes(path)) continue; + const text = readFileSync(resolve(repoRoot, path), "utf8"); + const { keys } = extractModuleKeys(text); + if (!keys.has(requiredKey)) { + wrong.push({ path, found: [...keys] }); + } + } + } + + expect( + wrong, + [ + "Routes that must gate on their tree's module key but do not:", + ...wrong.map( + (w) => + ` - ${w.path}: found [${w.found.join(", ") || "no literal key"}]`, + ), + ].join("\n"), + ).toEqual([]); + }); + + it("the behavioural gate tests that prove refusal still exist", () => { + // This inventory only reads source text. The listed files are what + // actually exercise a disabled module end to end; losing one silently + // downgrades the guarantee back to a text match, which is precisely the + // failure this test was rewritten to prevent. + const missing = BEHAVIOURAL_GATE_TESTS.filter( + (t) => !existsSync(resolve(repoRoot, t.path)), + ); + + expect( + missing, + [ + "Behavioural module-gate coverage has gone missing.", + "This inventory cannot prove a gate refuses — these tests do:", + ...missing.map((m) => ` - ${m.path}\n proves: ${m.proves}`), + "Restore the file, or drop the entry and say what replaces it.", + ].join("\n"), + ).toEqual([]); + }); + it("EXEMPT_ROUTES does not reference deleted route files", () => { const known = new Set(findAllModuleRouteFiles()); const stale = EXEMPT_ROUTES.filter((p) => !known.has(p)); @@ -457,8 +566,7 @@ describe("module API route gate inventory", () => { fileHasCall(text, MODULE_GATE_NEEDLE) || fileHasCall(text, CYCLE_GATE_NEEDLE) || fileHasCall(text, COACH_GATE_NEEDLE) || - fileHasCall(text, ILLNESS_GATE_NEEDLE) || - BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n)) + fileHasCall(text, ILLNESS_GATE_NEEDLE) ) { stillGated.push(path); } diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts new file mode 100644 index 000000000..577966bf1 --- /dev/null +++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts @@ -0,0 +1,212 @@ +/** + * `GET /api/dashboard/summary` — module gate. + * + * This route builds its own payload instead of going through + * `readDashboardSnapshotCached`, so it never picked up the gate the shared + * snapshot builder applies: it emitted glucose and sleep cards on data + * presence alone while `/api/dashboard/snapshot` correctly withheld them for + * the very same account. + * + * OMIT, not 403 — the payload spans ten metric cards across several domains, + * and blanking an iOS dashboard because one module is off would be the wrong + * trade. Core vitals must keep flowing. + * + * Behavioural: the assertions read the emitted card list, so reverting the + * filter in the route turns them red. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +// The gate resolver runs for real; only its data sources are stubbed, so the +// module state is driven the way production drives it. +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/feature-flags", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAssistantFlags: vi.fn() }; +}); +// The heavy builder is stubbed: this test pins the GATE, not the aggregate +// (which has its own coverage). It returns one card per emitted kind. +vi.mock("@/lib/cache/server-cache", () => ({ + caches: { analytics: {} }, + cachedSwr: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { MODULE_KEYS } from "@/lib/modules/gate"; +import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; + +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, + displayName: null, + locale: "en", + timezone: "UTC", + }, +}; + +/** Every card kind the builder can emit, one card each. */ +const ALL_KINDS = [ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", +] as const; + +function card(kind: string) { + return { + id: kind, + kind, + titleKey: `dashboard.metric.title.${kind}`, + latestValue: 1, + secondaryValue: null, + unitKey: `dashboard.metric.unit.${kind}`, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: "flat", + sparkline: [1], + updatedAt: new Date().toISOString(), + allTimeCount: 5, + lastSeenAt: new Date().toISOString(), + }; +} + +/** Drive the real gate through the persisted disabled-allowlist. */ +function setDisabledModules(disabled: string[]): void { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + gender: null, + disableCoach: false, + modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])), + } as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); +} + +async function emittedKinds(): Promise { + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Array<{ kind: string }> }; + }; + return body.data.metrics.map((m) => m.kind); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(getOperatorModuleAvailability).mockResolvedValue( + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, + ); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never); + vi.mocked(cachedSwr).mockImplementation((async () => ({ + greeting: { salutation: "hi", date: new Date().toISOString() }, + streak: { currentDays: 0, longest: 0, label: "" }, + compliance: { scheduledToday: 0, takenToday: 0 }, + highlightInsight: null, + metrics: ALL_KINDS.map(card), + sleepRhythm: null, + lastUpdated: new Date().toISOString(), + })) as never); +}); + +describe("GET /api/dashboard/summary — module gate", () => { + it("emits every card when no module is disabled", async () => { + setDisabledModules([]); + expect(await emittedKinds()).toEqual([...ALL_KINDS]); + }); + + it("drops the glucose card when the glucose module is off", async () => { + setDisabledModules(["glucose"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("glucose"); + // Everything else survives — proves an omission, not a refusal. + expect(kinds).toContain("weight"); + expect(kinds).toContain("sleep"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + + it("drops the sleep card when the sleep module is off", async () => { + setDisabledModules(["sleep"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("sleep"); + expect(kinds).toContain("glucose"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + + it("drops both when both modules are off", async () => { + setDisabledModules(["glucose", "sleep"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("glucose"); + expect(kinds).not.toContain("sleep"); + expect(kinds).toHaveLength(ALL_KINDS.length - 2); + }); + + it("never drops a core vital, whatever is disabled", async () => { + // Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship. + setDisabledModules([...MODULE_KEYS]); + const kinds = await emittedKinds(); + + for (const core of [ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", + ]) { + expect(kinds).toContain(core); + } + }); + + it("gates the cached body, so a toggle takes effect on the next request", async () => { + // The analytics LRU is not evicted by a module toggle. The filter must + // therefore run on the way out, not inside the cached build. + setDisabledModules([]); + expect(await emittedKinds()).toContain("glucose"); + + // Same cached body, module now off. + setDisabledModules(["glucose"]); + expect(await emittedKinds()).not.toContain("glucose"); + }); +}); diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 466b89dc4..3bf6040e0 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +// Every module is on for the aggregate assertions below; the gate's own +// behaviour (which cards a disabled module drops) is pinned in the sibling +// `module-gate.test.ts`. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => + Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])), + ), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { // v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index dbf4490f2..00b399f98 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -44,6 +44,12 @@ * subsequent iOS polls inside the window hit memory. Invalidation runs * via the existing `invalidateUserMeasurements` + the v1.4.38-extended * `invalidateUserMedications` hooks. + * + * Module gate: the emitted `metrics` array is filtered against the account's + * module map before it leaves the handler, so a card whose measurement type + * belongs to a disabled module never ships. The decision comes from the same + * map the shared snapshot builder uses (`disabledSummaryTypes`); the filter + * runs OUTSIDE the cache because a module toggle does not evict it. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules"; import { summarizeSleepNights, reconstructSleepNights, @@ -364,7 +372,35 @@ export const GET = apiHandler(async () => { annotate, ); - return apiSuccess(body); + // ── module gate ─────────────────────────────────────────────────── + // This route builds its own payload instead of going through + // `readDashboardSnapshotCached`, so it never picked up the module gate + // the shared snapshot builder applies — it emitted glucose and sleep + // tiles on data presence alone, while `/api/dashboard/snapshot` correctly + // withheld them for the same account. + // + // OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is + // an aggregate of ten metric cards across several domains; refusing the + // whole request would blank the iOS dashboard because one module is off. + // The card simply does not appear, which is exactly what a disabled module + // means on every other dashboard surface. + // + // Applied AFTER the cache read, deliberately. The analytics LRU is keyed + // `|dashboard-summary` with a 60 s fresh / 1 h stale window, and + // toggling a module does not evict it — gating inside the builder would + // leave the disabled tile served for up to an hour after the user turned + // it off. Filtering the cached body makes the toggle effective on the very + // next request. + const modules = await resolveModuleMap(user.id); + const metrics = gateMetricCardsByModules(body.metrics, modules); + annotate({ + meta: { + metrics_emitted: metrics.length, + metrics_gated: body.metrics.length - metrics.length, + }, + }); + + return apiSuccess({ ...body, metrics }); }); interface SummaryBuilderContext { 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/app/api/sync/changes/__tests__/module-gate.test.ts b/src/app/api/sync/changes/__tests__/module-gate.test.ts new file mode 100644 index 000000000..5b5e004c9 --- /dev/null +++ b/src/app/api/sync/changes/__tests__/module-gate.test.ts @@ -0,0 +1,198 @@ +/** + * `GET /api/sync/changes` — cycle module gate. + * + * The delta feed multiplexes five domains over one cursor and used to serve + * `cycleDays` / `cycles` ungated, so an account with cycle tracking off got + * period days and per-day symptom rows over the very token that + * `/api/cycle/*` refuses with a 403. + * + * Behavioural, not structural: the assertions read the response body and the + * Prisma call log, so reverting the gate in the route 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), +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + }, +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { decodeCursor } from "@/lib/sync/cursor"; + +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 }, +}; + +const NOW = new Date("2026-07-01T12:00:00.000Z"); + +/** One live cycle-day row carrying a symptom — the payload that must not leak. */ +const CYCLE_DAY_ROW = { + id: "cd-1", + userId: "user-1", + externalId: "ext-cd-1", + date: NOW, + flow: "MEDIUM", + notes: null, + notesEncrypted: null, + syncVersion: 3, + deletedAt: null, + updatedAt: NOW, + createdAt: NOW, + symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }], +}; + +const CYCLE_ROW = { + id: "mc-1", + userId: "user-1", + startDate: NOW, + endDate: null, + cycleLength: 28, + periodLength: 5, + predicted: false, + syncVersion: 2, + deletedAt: null, + updatedAt: NOW, + createdAt: NOW, +}; + +interface FeedBody { + data: { + changes: { + measurements: { upserts: unknown[]; tombstones: unknown[] }; + cycleDays: { upserts: unknown[]; tombstones: unknown[] }; + cycles: { upserts: unknown[]; tombstones: unknown[] }; + }; + cursor: string | null; + }; +} + +function req(query = ""): NextRequest { + return new NextRequest(`http://localhost/api/sync/changes${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); + // Non-cycle domains always return rows so a 403/empty-everything result + // cannot be mistaken for a passing "cycle omitted" assertion. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + externalId: "ext-m-1", + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: NOW, + source: "MANUAL", + notes: null, + notesEncrypted: null, + syncVersion: 1, + deletedAt: null, + updatedAt: NOW, + }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + CYCLE_DAY_ROW, + ] as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([ + CYCLE_ROW, + ] as never); +}); + +describe("GET /api/sync/changes — cycle module gate", () => { + it("omits cycle rows and never queries them when the module is off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + + const res = await GET(req()); + // OMIT, not refuse — the other four domains must keep syncing. + expect(res.status).toBe(200); + + const body = (await res.json()) as FeedBody; + expect(body.data.changes.cycleDays.upserts).toEqual([]); + expect(body.data.changes.cycleDays.tombstones).toEqual([]); + expect(body.data.changes.cycles.upserts).toEqual([]); + expect(body.data.changes.cycles.tombstones).toEqual([]); + + // The rest of the feed is untouched — proves the empty cycle blocks are + // the gate, not a wholesale refusal. + expect(body.data.changes.measurements.upserts).toHaveLength(1); + + // Not read out of Postgres at all. + expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled(); + expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled(); + + // No symptom string anywhere in the serialised payload. + expect(JSON.stringify(body)).not.toContain("CRAMPS"); + expect(JSON.stringify(body)).not.toContain("ext-cd-1"); + }); + + it("serves cycle rows when the module is on", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(true); + + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as FeedBody; + expect(body.data.changes.cycleDays.upserts).toHaveLength(1); + expect(body.data.changes.cycles.upserts).toHaveLength(1); + expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1); + expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1); + }); + + it("gates on the cycle module key specifically", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(true); + await GET(req()); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle"); + }); + + it("leaves the cycle cursor watermarks untouched while the module is off", async () => { + // A skipped domain must not advance its watermark, so re-enabling + // resumes from where the client left off rather than skipping the rows + // that changed while the module was hidden. + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const res = await GET(req()); + const body = (await res.json()) as FeedBody; + + const cursor = decodeCursor(body.data.cursor as string); + expect(cursor).not.toBeNull(); + expect(cursor!.cycleDays).toBeUndefined(); + expect(cursor!.cycles).toBeUndefined(); + // The domain that did return rows advanced normally. + expect(cursor!.measurements).toBeDefined(); + }); +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index b790d4350..750ea47f5 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -37,6 +37,9 @@ * been pruned, so an incremental delta could silently miss it). * - `syncVersion` is echoed per row so the client can keep its mirror's * version monotonic. + * - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with + * cycle tracking off gets both blocks empty (never a 403 — see the + * gate note in the handler) and their cursor watermarks untouched. * * `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer). * Read-only — no idempotency, no write side-effect (unlike the legacy @@ -67,6 +70,7 @@ import { type CycleDayLogDTO, type MenstrualCycleDTO, } from "@/lib/cycle/dto"; +import { isModuleEnabled } from "@/lib/modules/gate"; const DEFAULT_LIMIT = 200; const MAX_LIMIT = 500; @@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => { const serverNow = new Date(); + // ── module gate: cycle ──────────────────────────────────────────── + // The `cycleDays` / `cycles` domains carry period days and per-day + // symptom rows — the same data every `/api/cycle/*` route refuses with a + // 403 when the module is off. Without this the delta feed re-served them + // ungated to the very same token. + // + // OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export. + // This feed multiplexes five domains over one cursor: refusing the whole + // request would wedge measurement / mood / intake catch-up for every + // account with cycle tracking off, which (the gate delegates to + // `isCycleEnabled`) is the default for most accounts. A partial payload + // is the only outcome that keeps the client syncing while still honouring + // the promise that a disabled module hands out nothing. + // + // The two cycle watermarks are deliberately NOT advanced while the module + // is off (the queries are skipped, so `advanceCursor` sees an empty page + // and leaves the prior watermark untouched). Re-enabling therefore resumes + // from where the client actually left off instead of silently skipping the + // rows that changed while the module was hidden. + // + // KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the + // module was turned off keeps its stale local mirror. Emitting tombstones + // for them would be wrong — a tombstone means deleted, and disabling a + // module must leave the rows intact so re-enabling finds a complete + // history. Purging a paired client's mirror needs an explicit + // "module went dark" signal in the feed contract plus client support for + // it; that is an additive wire change and an iOS-client change, not a + // server-side gate fix, so it is left for a coordinated contract cycle. + const cycleEnabled = await isModuleEnabled(user.id, "cycle"); + // The retention horizon: tombstones older than this may have been // pruned by the cleanup job, so a cursor that predates it can no longer // be trusted to deliver every deletion incrementally. @@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => { updatedAt: true, }, }), - prisma.cycleDayLog.findMany({ - where: { userId: user.id, ...keysetFilter(cursor.cycleDays) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - include: dayLogSymptomInclude, - }), - prisma.menstrualCycle.findMany({ - where: { userId: user.id, ...keysetFilter(cursor.cycles) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - }), + // Skipped outright when the module is off — not filtered after the + // fact — so the disabled rows are never read out of Postgres at all. + cycleEnabled + ? prisma.cycleDayLog.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycleDays) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + include: dayLogSymptomInclude, + }) + : [], + cycleEnabled + ? prisma.menstrualCycle.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycles) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + }) + : [], ]); const nextCursor: SyncCursor = { ...cursor }; @@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => { cycle_day_tombstones: cycleDayTombstones.length, cycle_upserts: cycleUpserts.length, cycle_tombstones: cycleTombstones.length, + cycle_module_enabled: cycleEnabled, has_more: hasMore, cursor_present: Boolean(parsed.data.cursor), }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 5ce296350..82b63d4c2 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -118,7 +118,7 @@ export { } from "@/lib/dashboard/widget-modules"; import { WIDGET_MODULE_BY_ID, - SUMMARY_TYPE_MODULE, + disabledSummaryTypes, } from "@/lib/dashboard/widget-modules"; const GLUCOSE_CONTEXTS = [ @@ -965,10 +965,9 @@ function gateSummariesByModules( summaries: Record; lastSeenByType: Record; } { - const dropped = new Set(); - for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { - if (moduleKey && modules[moduleKey] === false) dropped.add(type); - } + // The drop decision is shared with `GET /api/dashboard/summary` — see + // `disabledSummaryTypes`. Only the filtering below is snapshot-shaped. + const dropped = disabledSummaryTypes(modules); if (dropped.size === 0) return { summaries, lastSeenByType }; const outSummaries: Record = {}; for (const [type, summary] of Object.entries(summaries)) { diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index b03ddd973..ee12a8dfa 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial> = { // on the `nutrients` module like the rest of that store's surfaces. NUTRIENT_WATER: "nutrients", }; + +/** + * The summary types a module map turns off. Lifted out of + * `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two + * dashboard aggregates that both surface per-metric data — the snapshot + * builder and the iOS `GET /api/dashboard/summary` payload — decide from + * ONE map instead of each carrying its own copy. + * + * The two callers shape their payloads differently (the snapshot keys + * summaries by `MeasurementType`; the summary route emits an array of + * metric cards keyed by an iOS `MetricKind`), so what is shared is this + * decision, not the filtering itself. + */ +export function disabledSummaryTypes( + modules: Partial>, +): Set { + const dropped = new Set(); + for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { + if (moduleKey && modules[moduleKey] === false) dropped.add(type); + } + return dropped; +} + +/** + * iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric + * card) → the `MeasurementType` it is built from. The summary route emits + * cards by kind, but module membership is defined per measurement type in + * `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the + * summary payload gates off the SAME source of truth as the snapshot rather + * than a second, drift-prone kind→module list. + * + * Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood + * pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core + * vitals and always pass through — they are listed anyway so a reader can + * see the full emitted set and so a new card cannot be added without + * deciding which type backs it. + */ +export const SUMMARY_METRIC_TYPE_BY_KIND: Record = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", +}; + +/** + * Drop the metric cards whose backing measurement type belongs to a module + * the account turned off. A card whose kind is absent from + * `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric + * or a new one, and silently hiding it would be worse than surfacing it. + */ +export function gateMetricCardsByModules( + cards: ReadonlyArray, + modules: Partial>, +): T[] { + const dropped = disabledSummaryTypes(modules); + if (dropped.size === 0) return [...cards]; + return cards.filter((card) => { + const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind]; + return !type || !dropped.has(type); + }); +} 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, {}),