Files
HealthLog/tests/integration/sharing-scope-fence.test.ts
T
Marc-André Bombeck da3cabb55f Give a grant a section set and a third level
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
2026-08-04 17:11:40 +02:00

413 lines
14 KiB
TypeScript

/**
* 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 } 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 getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
return grant;
}
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
async function drive(handler: Handler, url: string): Promise<Response> {
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 refusals(callerId: string): Promise<{ reason?: string }[]> {
await new Promise((resolve) => setTimeout(resolve, 150));
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 reason on the most recent one, or null when there is none. */
async function lastRefusal(
callerId: string,
): Promise<{ reason?: string } | null> {
const rows = await refusals(callerId);
return rows.length === 0 ? null : rows[rows.length - 1];
}
/* -------------------------------------------------------------------------- */
/* One representative route per section */
/* -------------------------------------------------------------------------- */
interface Probe {
url: string;
call: () => Promise<Response>;
}
const PROBES: Record<ShareDomain | "record", Probe> = {
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 lastRefusal(delegate.id)).toBeNull();
});
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 lastRefusal(delegate.id)).toBeNull();
},
);
});
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 getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: 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",
});
});
});