Files
HealthLog/src/__tests__/proxy-demo-mode-allowlist.test.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

196 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { globSync, readFileSync } from "node:fs";
import { join, sep } from "node:path";
/**
* Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy
* 403s every non-GET `/api/*` request except a narrow allowlist. Login is
* the historical baseline; the rest are the writes the demo has to make to
* walk its own flows — the two dashboard display-pref blobs, the setup
* flow's closed-enum answers and checkpoints, and the baseline profile
* fields. None carries health data or free text.
*
* The demo is one published account every visitor signs into, so what a
* visitor writes is what the next visitor finds. The refusals below are as
* much of the contract as the admissions: free text that reaches the Coach
* prompt, and anything that creates a second record, stay out.
*
* This guard locks the allowlist shape in place so a refactor can't
* silently widen it (admitting a data-bearing mutation) or narrow it
* (re-breaking the demo toggles). The whole block is gated on
* `DEMO_MODE === "true"`, so production is unaffected by construction —
* the final case proves the gate.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function makeRequest(pathname: string, method: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`, { method });
}
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
describe("proxy.ts DEMO_MODE mutation allowlist", () => {
beforeEach(() => {
process.env.DEMO_MODE = "true";
});
afterEach(() => {
if (ORIGINAL_DEMO_MODE === undefined) {
delete process.env.DEMO_MODE;
} else {
process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
}
});
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
const res = proxy(makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"));
expect(res.status).not.toBe(403);
});
it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => {
const res = proxy(makeRequest("/api/dashboard/widgets", "PUT"));
expect(res.status).not.toBe(403);
});
it("permits the three setup-flow writes so the demo can finish onboarding", () => {
// v1.39 — the design spec requires the demo to complete the same flow
// with preset answers, and none of the three creates health data: closed
// enum answers, the step ledger, and the module map derived from them.
for (const [path, method] of [
["/api/onboarding/answers", "PATCH"],
["/api/onboarding/complete", "POST"],
["/api/onboarding/restart", "POST"],
] as const) {
expect(
proxy(makeRequest(path, method)).status,
`${method} ${path} is blocked in the demo`,
).not.toBe(403);
}
});
it("does not drag in a sibling verb on an allowlisted onboarding path", () => {
// The allowlist pins method per path, so admitting the answers PATCH must
// not open a DELETE somebody adds to that route later.
expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe(
403,
);
expect(proxy(makeRequest("/api/onboarding/tour", "DELETE")).status).toBe(
403,
);
});
it("admits every mutating onboarding route, read off the route files", () => {
// v1.39 — the demo has to be able to walk the whole flow, and the
// flow's welcome and exit make writes the C1 allowlist did not carry: the
// disclaimer acknowledgment (both welcome buttons await it) and the tour
// checkpoint. A hand-kept list of three paths could not see the other
// two, so this reads the route files instead: every exported mutating
// handler under `src/app/api/onboarding` must pass the proxy.
const onboardingApi = join(process.cwd(), "src/app/api/onboarding");
const routes = globSync("**/route.ts", { cwd: onboardingApi })
.map((p) => p.split(sep).join("/"))
.sort();
expect(routes.length, "the onboarding route files").toBeGreaterThan(3);
const missing: string[] = [];
for (const rel of routes) {
const source = readFileSync(join(onboardingApi, rel), "utf8");
const path = `/api/onboarding/${rel.replace(/\/route\.ts$/, "")}`;
for (const method of ["POST", "PATCH", "PUT", "DELETE"] as const) {
if (!new RegExp(`export const ${method}\\b`).test(source)) continue;
if (proxy(makeRequest(path, method)).status === 403) {
missing.push(`${method} ${path}`);
}
}
}
expect(missing, "onboarding writes the demo cannot make").toEqual([]);
});
it("permits the profile write the baseline step makes", () => {
// v1.39 — a demo visitor who typed a height got a 403 and a generic
// toast, because the confirm screen's own write was not on the list.
// Date of birth, height and sex are closed, validated fields on the
// account's own record and no health data, so the step can complete.
expect(
proxy(makeRequest("/api/auth/profile", "PUT")).status,
"PUT /api/auth/profile is blocked in the demo",
).not.toBe(403);
});
it("refuses the free-text self-context write, which the demo shares", () => {
// The demo is ONE published account every visitor signs into. Free text
// that persists for the next visitor is not a demo affordance, and this
// particular text is fed into the Coach prompt — an anonymous write into
// an LLM prompt on a public instance, held until the next reseed. The
// anamnesis card renders read-only in the demo instead, so nothing here
// reaches a visitor as a failed save.
expect(proxy(makeRequest("/api/coach/about-me", "PUT")).status).toBe(403);
});
it("refuses the managed-profile create, which needs a factor the demo has not got", () => {
// `POST /api/managed-profiles` opens with `requireFreshMfa`. The demo
// account has no second factor, so admitting it at the edge buys the
// guardian arm nothing: the call is refused a layer later either way,
// and the confirm screen already says the profile could not be created
// without one.
expect(proxy(makeRequest("/api/managed-profiles", "POST")).status).toBe(
403,
);
});
it("does not drag in a sibling verb on the baseline paths", () => {
expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403);
expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe(
403,
);
// The per-profile routes are a different path and stay closed.
expect(
proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status,
).toBe(403);
});
it("permits the record search, a read that travels as a POST", () => {
// The query rides the body so it stays out of URLs and access logs; the
// route writes nothing, so the demo searches like any other instance.
expect(proxy(makeRequest("/api/search", "POST")).status).not.toBe(403);
expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
});
it("refuses the Coach chat with a code a client can name", async () => {
// The chat POST is not on the allowlist. The refusal carries a stable
// code, so the Coach says "not in the demo" instead of reading the 403
// as a provider outage.
const res = proxy(makeRequest("/api/insights/chat", "POST"));
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { demo?: boolean; errorCode?: string };
};
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
});
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
// The allowlist pins method per path; admitting the widgets PUT
// must not open DELETE (which wipes the user's dashboard layout).
const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE"));
expect(res.status).toBe(403);
});
it("is inert when DEMO_MODE is off — production is unaffected", () => {
delete process.env.DEMO_MODE;
// With the demo gate off, the proxy never short-circuits an API
// mutation; the route's own auth/handler runs instead.
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).not.toBe(403);
});
});