diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 4a4bbf87f..fa26c5129 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -78,6 +78,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "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 = [ "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("; diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 864d274b8..178b312e0 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 736fc6925..04677474c 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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(); + }); });