mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19
This commit is contained in:
commit
aed541910c
17 files changed
+1083
-41
No files matched your search
@@ -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<string> = [
|
||||
"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<string> = [
|
||||
"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<Record<string, ModuleKey>> = {
|
||||
"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("<key>"), a delegated gate, ' +
|
||||
"a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason",
|
||||
'no module gate found — add requireModuleEnabled("<key>") 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);
|
||||
}
|
||||
|
||||
@@ -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<typeof import("@/lib/feature-flags")>();
|
||||
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<string[]> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>();
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// `<userId>|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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof import("@/lib/fhir/rest")>();
|
||||
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<Response>]
|
||||
> = [
|
||||
["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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/fhir/rest")>();
|
||||
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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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<string, DataSummary>;
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
} {
|
||||
const dropped = new Set<string>();
|
||||
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<string, DataSummary> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
|
||||
@@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// 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<Record<ModuleKey, boolean>>,
|
||||
): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
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<string, string> = {
|
||||
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<T extends { kind: string }>(
|
||||
cards: ReadonlyArray<T>,
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): 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);
|
||||
});
|
||||
}
|
||||
@@ -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<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");
|
||||
});
|
||||
});
|
||||
@@ -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, {}),
|
||||
|
||||
Reference in new issue
Block a user