mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user