test(modules): pin every module-gated route with a gate-inventory invariant

Walk every per-domain route tree that serves a toggleable module and
classify each route.ts as module-gated, delegated (cycle/coach),
builder-gated (doctor-report/FHIR), or explicitly exempt with a reason.
A new ungated module route trips the guard by name.

Close four route-level gate gaps the inventory surfaced: the mood
analysis reads (/api/mood/insights, /api/mood/analytics) and the sleep
view reads (/api/sleep/night, /api/sleep/rhythm) now gate on their
module instead of staying reachable over a Bearer token while the module
is off. Pin the gate to enabled in the three pre-existing route suites.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 23:38:37 +02:00
1 parent 0fdeafc65d
commit 7c8d82f565
8 files changed
+368

No files matched your search

@@ -0,0 +1,322 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 — module API route gate inventory.
*
* The v1.18.0 module retrofit can hide a toggleable module across the
* nav, dashboard tiles, Insights pills, reminder jobs, doctor-report
* sections, and the AI surfaces. The ONE server-side enforcement point
* is `@/lib/modules/gate` — `requireModuleEnabled(userId, key)` in API
* routes, `isModuleEnabled` / `resolveModuleMap` in builders + jobs +
* components. Without a discovery test, a future contributor who lands a
* new per-domain route for a toggleable module (a new mood-analysis read,
* a new sleep view, a new glucose panel) would have to remember the gate
* by hand. A silent miss leaks the surface over a Bearer token even when
* the account — or the operator — turned the module off.
*
* This test walks every per-domain route tree that serves a TOGGLEABLE
* module and pins each `route.ts` into exactly one of these buckets:
*
* 1. MODULE-GATED — the file calls `requireModuleEnabled(...)` for the
* right key (mood / sleep / glucose / workouts / recovery / labs /
* achievements). This is the direct gate.
*
* 2. DELEGATED — the module's enabled-state is owned elsewhere and the
* route gates on that single source of truth instead of re-deriving
* it (no double source of truth, mirroring the registry's
* `delegatesTo`):
* - cycle → `requireCycleEnabled(...)` (the cycle gate the
* `cycle` ModuleKey delegates to).
* - coach → `requireAssistantSurface("coach")` (the assistant
* master flag + per-user opt-out the `coach` ModuleKey
* delegates to). Covered in depth by the sibling
* `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
* 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
* reading a disabled module's data is harmless — the module gate
* governs whether the domain SURFACES (nav, tiles, analysis,
* reports), not whether the row store accepts writes. Disabling
* a module must not wedge an importer / sync / the user's ability
* to clean up data, and re-enabling must find the data intact.
* - 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
* 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.
*/
/**
* The per-domain route trees that serve a toggleable module. Each entry
* is a directory under `src/app/api`; every `route.ts` beneath it is
* enumerated and classified. A domain that is owned entirely by one
* builder (doctor-report / FHIR) or one delegated gate (cycle) is still
* walked so a NEW route in the tree must justify itself.
*/
const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/mood",
"src/app/api/mood-entries",
"src/app/api/sleep",
"src/app/api/workouts",
"src/app/api/labs",
"src/app/api/cycle",
"src/app/api/gamification",
"src/app/api/doctor-report",
"src/app/api/fhir",
];
/**
* EXEMPT — routes that serve a toggleable domain but are deliberately
* ungated. Each carries its reason inline per the bucket-4 contract.
*/
const EXEMPT_ROUTES: ReadonlyArray<string> = [
// ── DATA LAYER (mood) ─────────────────────────────────────────────
// Raw MoodEntry CRUD. The module gate governs whether mood SURFACES
// (nav, dashboard tile, analysis, doctor report), not whether the row
// store accepts writes. An importer / the moodLog sync / the user's
// ability to delete or restore entries must keep working while the
// module is off, and re-enabling must find the rows intact.
"src/app/api/mood-entries/route.ts",
"src/app/api/mood-entries/[id]/route.ts",
"src/app/api/mood-entries/bulk/route.ts",
"src/app/api/mood-entries/bulk-delete/route.ts",
"src/app/api/mood-entries/restore/route.ts",
// Mood tag taxonomy CRUD — the user's tag library + per-tag layout.
// Pure configuration over the mood vocabulary, not analysis prose;
// editing the taxonomy while the module is off must not break.
"src/app/api/mood/tags/route.ts",
"src/app/api/mood/tags/layout/route.ts",
"src/app/api/mood/tags/custom/route.ts",
"src/app/api/mood/tags/custom/[key]/route.ts",
"src/app/api/mood/tags/groups/route.ts",
"src/app/api/mood/tags/groups/[key]/route.ts",
"src/app/api/mood/tags/[key]/hidden/route.ts",
// ── DATA LAYER (workouts / labs) ──────────────────────────────────
// Raw Workout + LabResult CRUD + the iOS batch ingest. Same data-layer
// reasoning: the workouts / labs modules gate the surfaces (Insights
// pill, dashboard tile, doctor-report section), not the row store.
"src/app/api/workouts/route.ts",
"src/app/api/workouts/[id]/route.ts",
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Section-availability presence probe for the doctor-report dialog —
// returns per-section row-count booleans, no module data. The dialog
// itself is hidden when the doctorReport module is off.
"src/app/api/doctor-report/availability/route.ts",
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
];
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// Builder aggregators that resolve `resolveModuleMap` once and exclude
// disabled-module sections/resources at the build boundary.
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
"collectDoctorReportData",
"loadFhirContext",
];
/**
* True when the file contains the needle on a line that is NOT a pure
* comment. A docstring that merely MENTIONS the gate must not satisfy the
* presence check — otherwise deleting the real call but leaving the
* comment would slip through.
*/
function fileHasCall(text: string, needle: string): boolean {
return text.split("\n").some((line) => {
if (!line.includes(needle)) return false;
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
return true;
});
}
/**
* Extract the module key from a `requireModuleEnabled(user.id, "<key>")`
* call. Returns the literal key, or null when the key is computed (the
* parameterised insights routes resolve it from a metric map — those gate
* dynamically and are accepted as gated without a literal-key assertion).
*/
function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set<string> } {
const keys = new Set<string>();
let hasLiteral = false;
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
hasLiteral = true;
keys.add(m[1]);
}
return { hasLiteral, keys };
}
const repoRoot = resolve(__dirname, "..", "..", "..", "..");
/** Walk one route tree, returning POSIX paths relative to the repo root. */
function findRouteFiles(treeRel: string): string[] {
const root = resolve(repoRoot, treeRel);
const hits: string[] = [];
function walk(dir: string): void {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
walk(full);
continue;
}
if (entry !== "route.ts") continue;
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
walk(root);
return hits;
}
function findAllModuleRouteFiles(): string[] {
return MODULE_ROUTE_TREES.flatMap(findRouteFiles).sort();
}
describe("module API route gate inventory", () => {
it("the module registry covers the keys this inventory reasons about", () => {
// Guard against a registry key being added without the inventory
// gaining an opinion about its routes. Every gated/delegated key the
// test pins must be a real ModuleKey.
const known = new Set<ModuleKey>(MODULE_KEYS);
for (const key of [
"mood",
"sleep",
"glucose",
"workouts",
"recovery",
"labs",
"achievements",
"cycle",
"coach",
"doctorReport",
"insights",
] as const) {
expect(known.has(key), `unknown module key in inventory: ${key}`).toBe(
true,
);
}
});
it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => {
const routes = findAllModuleRouteFiles();
expect(routes.length).toBeGreaterThan(0);
const exempt = new Set(EXEMPT_ROUTES);
const orphans: Array<{ path: string; reason: string }> = [];
for (const path of routes) {
const text = readFileSync(resolve(repoRoot, path), "utf8");
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_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",
});
}
expect(
orphans,
[
"Module API route gate inventory found unaccounted-for handler(s):",
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
].join("\n"),
).toEqual([]);
});
it("directly module-gated routes pin a real module key", () => {
const routes = findAllModuleRouteFiles();
const known = new Set<string>(MODULE_KEYS);
const bad: Array<{ path: string; key: string }> = [];
for (const path of routes) {
const text = readFileSync(resolve(repoRoot, path), "utf8");
if (!fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
const { keys } = extractModuleKeys(text);
// Parameterised routes (computed key) carry no literal — accepted.
for (const key of keys) {
if (!known.has(key)) bad.push({ path, key });
}
}
expect(
bad,
[
"requireModuleEnabled() called with an unknown module key:",
...bad.map((b) => ` - ${b.path}: "${b.key}"`),
].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));
expect(
stale,
[
"EXEMPT_ROUTES points to files that no longer exist —",
"delete the stale entries from",
"`src/app/api/__tests__/module-route-gate-inventory.test.ts`:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
it("EXEMPT_ROUTES does not hide a route that actually carries a gate", () => {
// A route that gained a real gate should be removed from the exempt
// list so the list stays an honest record of the ungated surfaces.
const stillGated: string[] = [];
for (const path of EXEMPT_ROUTES) {
const text = readFileSync(resolve(repoRoot, path), "utf8");
if (
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
) {
stillGated.push(path);
}
}
expect(
stillGated,
[
"These EXEMPT_ROUTES now carry a gate — drop them from the exempt list:",
...stillGated.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});
@@ -37,6 +37,12 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
// v1.18.0 — pin the mood module gate to "enabled" so this suite stays
// focused on the mood-series engine; the gate is covered elsewhere.
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
+7
View File
@@ -6,12 +6,19 @@ import {
buildMoodDailySeries,
type MoodDailySeries,
} from "@/lib/analytics/mood-series";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Per-domain gate: the mood daily series feeds the mood module's
// analysis surfaces (sparkline + mood page), so it gates on the mood
// module (mirrors `/api/insights/mood-status`). Disabled ⇒ 403.
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
// v1.17.1 — single mood engine: the route and the dashboard snapshot
// both read through `buildMoodDailySeries`, so the dashboard tile, the
// insights mood sparkline, and the iOS client all see the same number.
+7
View File
@@ -6,6 +6,7 @@ import {
fetchMoodAggregates,
type MoodAggregates,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
@@ -28,6 +29,12 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Per-domain gate: the mood-insights aggregate surface only serves the
// mood module's analysis page, so it must be gated on the mood module
// (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled.
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const result = await cachedSwr(
caches.moodInsights as ServerCache<MoodAggregates>,
user.id,
@@ -21,6 +21,13 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays
// focused on the hypnogram read; the gate itself is covered by the module
// gate + route-gate-inventory suites.
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
+6
View File
@@ -38,6 +38,7 @@ import {
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
@@ -93,6 +94,11 @@ function serializeSession(s: SleepSession) {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Per-domain gate: the hypnogram read serves only the sleep module's
// view surfaces, so it gates on the sleep module. Disabled ⇒ 403.
const gate = await requireModuleEnabled(user.id, "sleep");
if (!gate.enabled) return gate.response;
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
@@ -22,6 +22,12 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays
// focused on the sleep-rhythm read; the gate is covered elsewhere.
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
+7
View File
@@ -22,10 +22,17 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Per-domain gate: the sleep-rhythm read serves only the sleep module's
// view surfaces (Sleep page + iOS), so it gates on the sleep module.
// Disabled ⇒ 403 module.disabled.
const gate = await requireModuleEnabled(user.id, "sleep");
if (!gate.enabled) return gate.response;
const rhythm = await buildSleepRhythm(user.id);
annotate({