mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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),
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user