test(insights): walk the insights AI tree in the module-route gate inventory

Add `src/app/api/insights` to the inventory's walked trees so every
`route.ts` beneath it must classify: the 14 AI-narrative routes as
insights-module-gated, the Coach sub-tree as coach-delegated, and the
AI-settings / provider-chain / tile-layout / feedback / targets /
glp1-timeline routes as explicit infra-and-config EXEMPT entries with
documented reasons. A future ungated insights AI route now fails the
inventory BY NAME rather than leaking the surface over a Bearer token when
the account turned insights off.

Add representative insights-off → 403 module.disabled coverage to the
blood-pressure-status and correlations route tests (alongside the existing
insights-on → 200 path).
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:45:23 +02:00
1 parent b2684a2e21
commit 261f3cea1d
3 files changed
+92

No files matched your search

@@ -78,6 +78,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/gamification",
"src/app/api/doctor-report",
"src/app/api/fhir",
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
// correlations / derived / narrative / pregenerate / rhythm-events route
// gates on the `insights` module; the Coach sub-tree delegates to the
// coach assistant surface; the AI-settings / tile-layout / therapy-timeline
// infra-and-config routes are EXEMPT below. Walking the tree means a NEW
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
];
/**
@@ -122,6 +130,37 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/doctor-report/availability/route.ts",
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
// ── INFRA / CONFIG (insights) ─────────────────────────────────────
// v1.18.0 (B2) — the insights tree's non-narrative routes. The
// `insights` module gates the AI-narrative SURFACES (status cards,
// correlations, derived scores, period narrative, the rhythm-event
// timeline); these six carry no narrative payload, so gating them would
// only break configuration / settings reads while the module is off.
//
// AI provider + privacy settings and the read-only chain summary — pure
// configuration of the assistant, surfaced under Settings → AI, not an
// insights surface. Editing the AI config while insights is off must work
// (e.g. to set up a provider before re-enabling the module).
"src/app/api/insights/settings/route.ts",
"src/app/api/insights/provider-chain/route.ts",
// Insights tile-order + visibility layout (GET/PUT/DELETE) — the user's
// own persisted preference blob, the insights peer of
// `/api/dashboard/widgets`. Pure UI configuration, no module data; the
// /insights page itself is nav-gated on the module.
"src/app/api/insights/layout/route.ts",
// Insight feedback write (👍/👎 on a generated card) — a feedback row,
// not an insights READ. Harmless while the module is off and never
// surfaces module data.
"src/app/api/insights/feedback/route.ts",
// Target-range reference values (BMI / BP / sleep / steps classifiers +
// compliance context) — deterministic threshold config consumed across
// surfaces, not an AI-narrative insights read.
"src/app/api/insights/targets/route.ts",
// GLP-1 therapy-timeline aggregator backing the /insights/medications
// component — a MEDICATIONS-domain data merge (dose / injection /
// inventory / side-effect events), and medications is a core, always-on
// domain with no module gate. It is not an insights-module surface.
"src/app/api/insights/glp1-timeline/route.ts",
];
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
@@ -53,6 +53,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -101,3 +102,33 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
expect(res.status).toBe(401);
});
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
// Representative coverage that a disabled module short-circuits with the
// 403 module.disabled envelope before any provider / cache work; the
// inventory test guards that EVERY insights AI route carries this gate.
describe("GET /api/insights/blood-pressure-status — insights module gate", () => {
it("returns 200 when insights is enabled (default)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
});
it("returns 403 + module.disabled when insights is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("insights");
});
});
@@ -52,6 +52,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -159,4 +160,25 @@ describe("GET /api/insights/correlations", () => {
// The limited request never reaches the series reads.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("insights");
// The disabled-module request never reaches the series reads.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
});