/** * v1.37.0 — the section fence, driven as routes against real Postgres. * * A scoped grant opens the sections it names and leaves the rest unanswered. * That sentence is one line of code in the resolver and fifty-seven * classifications on the call sites, and the classifications are the part a * machine cannot check: the guard freezes the domain a module declares against * a literal transcribed from the same table a human read, so a domain assigned * wrongly in both places agrees with itself perfectly. It would look like * nothing, anywhere, while a delegate scoped to medications quietly read the * labs. * * So this file drives one representative route per section, in-scope and * out-of-scope, through the shipped GET exports and the shipped grant * transitions. Sixteen legs plus the record-wide pair. What that buys is not * proof that every one of the fifty-seven is right — it is proof that the * mechanism separates the sections at all, per section, so a wholesale failure * (the resolver ignoring the scope, the normaliser reading a set as * permissive, one section's routes classified into another's) cannot be * silent. The per-module classification stays a review question and says so in * the guard. * * Evidence over verdict, throughout: the in-scope leg asserts the route was * REACHED, the out-of-scope leg asserts the refusal AND its audit reason. A * file that only asserted the refusals would pass against a resolver that * refused everything. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareDomain } from "@/lib/sharing/scope"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), cookies: vi.fn(async () => ({ get: (name: string) => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, set: (name: string, value: string) => { cookieJar.set(name, value); }, delete: (name: string) => { cookieJar.delete(name); }, })), }; }); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); let counter = 0; async function makeUser(label: string) { const suffix = `${label}-${counter++}`; const user = await getPrismaClient().user.create({ data: { username: `scope-${suffix}`, email: `scope-${suffix}@example.test`, displayName: `Scope ${suffix}`, role: "USER", timezone: "Europe/Berlin", // The cycle probe sits behind its own module gate, which derives from // the account's gender when the toggle is NULL. Opted in explicitly so // that a 403 from that route means the section fence and not the module // gate — two masks, both real, and this file is about one of them. gender: "FEMALE", }, }); await getPrismaClient().cycleProfile.create({ data: { userId: user.id, cycleTrackingEnabled: true }, }); return user; } async function signIn(userId: string) { return getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 60_000) }, }); } /** * A live, accepted grant with the delegate's browser already inside the * owner's record. * * Minted through the shipped `inviteGrant` / `acceptGrant` rather than by * writing the row: the scope column's write path is part of what is under * test, and a row this file wrote itself would keep passing after the invite * path stopped storing what it was given. */ async function switchInto( ownerId: string, delegateId: string, scope: ShareDomain[] | null, ) { const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); const invited = await inviteGrant({ grantorId: ownerId, granteeId: delegateId, access: "READ", scope, }); const grant = await acceptGrant({ grantId: invited.id, granteeId: delegateId, }); const session = await signIn(delegateId); cookieJar.set("healthlog_session", session.id); await switchSessionTo(session.id, ownerId); return grant; } type Handler = ( request: NextRequest, // eslint-disable-next-line @typescript-eslint/no-explicit-any context: { params: Promise }, ) => Promise; async function drive(handler: Handler, url: string): Promise { return handler(new NextRequest(`http://localhost${url}`, { method: "GET" }), { params: Promise.resolve({}), }); } /** * The sharing refusals filed for this caller so far. * * The resolver writes them fire-and-forget — the 403 is what protects the * record and an audit write that failed must never turn it into a 500 — so * the row lands a moment after the response does. Everything here therefore * settles first. Without the settle the positive legs below would read an * empty table and agree with themselves, which is the exact failure this * repository keeps rediscovering: a check that cannot fail. */ async function readRefusals(callerId: string): Promise<{ reason?: string }[]> { const rows = await getPrismaClient().auditLog.findMany({ where: { userId: callerId, action: "sharing.access.denied" }, orderBy: { createdAt: "asc" }, }); // `details` is stored as a JSON STRING on this table, not a JSON object — // matching it as an object would compare against a string and fail, or, with // a looser matcher, pass against anything. return rows.map((row) => typeof row.details === "string" ? (JSON.parse(row.details) as { reason?: string }) : ((row.details ?? {}) as { reason?: string }), ); } /** * The refusal audit row is written WITHOUT being awaited (`auditRefusal` in * `src/lib/api-handler.ts` drops the promise on purpose, so the audit write * never delays the 403 a caller is waiting on). The row therefore lands some * time after the response returns, and the two assertions here need opposite * treatment of that gap. * * A fixed 150 ms wait served both until a slow CI runner missed it and the * positive case read an empty table, which reads exactly like "the section * reached the wire" — the one thing this file exists to catch. So the * positive case polls until the row appears, and only the absence case pays * a fixed wait, which is what makes an absence claim mean anything. */ const REFUSAL_APPEAR_TIMEOUT_MS = 5_000; const REFUSAL_ABSENCE_WAIT_MS = 750; /** The most recent refusal, waited for. Fails the test when none arrives. */ async function lastRefusal(callerId: string): Promise<{ reason?: string }> { const deadline = Date.now() + REFUSAL_APPEAR_TIMEOUT_MS; for (;;) { const rows = await readRefusals(callerId); if (rows.length > 0) return rows[rows.length - 1]; if (Date.now() >= deadline) { throw new Error( `no sharing.access.denied audit row for ${callerId} within ${REFUSAL_APPEAR_TIMEOUT_MS} ms`, ); } await new Promise((resolve) => setTimeout(resolve, 25)); } } /** Every refusal on file, after a wait long enough for a late write. */ async function refusals(callerId: string): Promise<{ reason?: string }[]> { await new Promise((resolve) => setTimeout(resolve, REFUSAL_ABSENCE_WAIT_MS)); return readRefusals(callerId); } /** No refusal was recorded. Waits before concluding that. */ async function noRefusal(callerId: string): Promise { return (await refusals(callerId)).length === 0; } /* -------------------------------------------------------------------------- */ /* One representative route per section */ /* -------------------------------------------------------------------------- */ interface Probe { url: string; call: () => Promise; } const PROBES: Record = { measurements: { url: "/api/measurements", call: async () => drive( (await import("@/app/api/measurements/route")).GET as Handler, "/api/measurements", ), }, medications: { url: "/api/medications", call: async () => drive( (await import("@/app/api/medications/route")).GET as Handler, "/api/medications", ), }, labs: { url: "/api/labs", call: async () => drive((await import("@/app/api/labs/route")).GET as Handler, "/api/labs"), }, profile: { url: "/api/allergies", call: async () => drive( (await import("@/app/api/allergies/route")).GET as Handler, "/api/allergies", ), }, illness: { url: "/api/illness/episodes", call: async () => drive( (await import("@/app/api/illness/episodes/route")).GET as Handler, "/api/illness/episodes", ), }, mind: { url: "/api/mood-entries", call: async () => drive( (await import("@/app/api/mood-entries/route")).GET as Handler, "/api/mood-entries", ), }, cycle: { url: "/api/cycle/cycles", call: async () => drive( (await import("@/app/api/cycle/cycles/route")).GET as Handler, "/api/cycle/cycles", ), }, documents: { url: "/api/documents/inbound", call: async () => drive( (await import("@/app/api/documents/inbound/route")).GET as Handler, "/api/documents/inbound", ), }, record: { url: "/api/dashboard/widgets", call: async () => drive( (await import("@/app/api/dashboard/widgets/route")).GET as Handler, "/api/dashboard/widgets", ), }, }; beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); }); describe("the probe set covers the vocabulary", () => { it("has one route per section, plus the record-wide case", () => { // The non-zero half. Every leg below iterates this table, so a table that // lost a section would silently stop testing it — and a section added to // the production enum without a probe would go untested from birth. expect(SHARE_DOMAINS.length).toBeGreaterThan(0); expect(Object.keys(PROBES).sort()).toEqual( [...SHARE_DOMAINS, "record"].sort(), ); }); }); describe("a scoped grant opens the sections it names", () => { it.each(SHARE_DOMAINS)("%s answers when the grant names it", async (open) => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, [open]); const response = await PROBES[open].call(); // Reached, not merely "not 403": a route that answered 500 would satisfy // an inequality against 403 and prove nothing about the fence. expect(response.status, PROBES[open].url).toBe(200); expect(await noRefusal(delegate.id)).toBe(true); }); it.each(SHARE_DOMAINS)( "%s is the only section that answers", async (open) => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, [open]); const closed = [...SHARE_DOMAINS, "record" as const].filter( (d) => d !== open, ); // Non-zero: the loop below must actually run. expect(closed.length).toBeGreaterThan(0); for (const domain of closed) { const response = await PROBES[domain].call(); expect(response.status, PROBES[domain].url).toBe(403); const body = await response.json(); expect(body.meta?.errorCode, PROBES[domain].url).toBe( "sharing.access.denied", ); // The reason is the audit row's, never the caller's. If the section // ever reaches the wire this assertion is the one that has to move, // which is the point of asserting it here rather than on the body. expect( await lastRefusal(delegate.id), PROBES[domain].url, ).toMatchObject({ reason: "out_of_scope" }); } }, ); }); describe("a NULL scope still opens the whole record", () => { it.each([...SHARE_DOMAINS, "record" as const])( "%s answers exactly as it did before scoping existed", async (domain) => { // The no-regression leg. Every grant in the product today is this one, // and the release is byte-identical for all of them. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, null); const response = await PROBES[domain].call(); expect(response.status, PROBES[domain].url).toBe(200); expect(await noRefusal(delegate.id)).toBe(true); }, ); }); describe("an unreadable scope opens nothing", () => { it("refuses every section, including one the garbage names", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const grant = await switchInto(owner.id, delegate.id, ["medications"]); // Planted directly, because the invite path refuses to write it — which is // the point: the read side has to hold on its own, against a value that // reached the column by a route this application does not have (a restore // from an older shape, a hand-edited row, a future writer with a bug). await getPrismaClient().$executeRawUnsafe( `UPDATE account_grants SET scope_json = '"medications"'::jsonb WHERE id = $1`, grant.id, ); for (const domain of [...SHARE_DOMAINS, "record" as const]) { const response = await PROBES[domain].call(); expect(response.status, PROBES[domain].url).toBe(403); expect(await lastRefusal(delegate.id), PROBES[domain].url).toMatchObject({ reason: "out_of_scope", }); } }); it("refuses even the section a well-formed set would have opened", async () => { // The sharpest form of the same claim, and the one that separates // fail-closed from fail-open: the string spells a real section name. A // normaliser that reached for anything it recognised inside a value it // could not parse would let this through. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const grant = await switchInto(owner.id, delegate.id, ["medications"]); // Evidence first: the grant works before the column is corrupted, so the // refusal below is the corruption and not the fixture. expect((await PROBES.medications.call()).status).toBe(200); await getPrismaClient().$executeRawUnsafe( `UPDATE account_grants SET scope_json = '["medications", "not-a-section"]'::jsonb WHERE id = $1`, grant.id, ); expect((await PROBES.medications.call()).status).toBe(403); expect(await lastRefusal(delegate.id)).toMatchObject({ reason: "out_of_scope", }); }); }); describe("the refusal does not distinguish itself", () => { it("says the same bytes as a caller who was granted nothing", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, ["medications"]); const outOfScope = await PROBES.labs.call(); const outOfScopeBody = await outOfScope.text(); // A stranger naming the same record. Different story, same answer. const stranger = await makeUser("stranger"); const session = await signIn(stranger.id); cookieJar.set("healthlog_session", session.id); await switchSessionTo(session.id, owner.id); const noGrant = await PROBES.labs.call(); expect(outOfScope.status).toBe(noGrant.status); expect(outOfScopeBody).toBe(await noGrant.text()); expect(outOfScopeBody).not.toContain("scope"); // …and the operator's trail tells them apart, which is the half that has // to keep working while the wire stays silent. expect(await lastRefusal(stranger.id)).toMatchObject({ reason: "no_active_grant", }); }); });