mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
441 lines
16 KiB
TypeScript
441 lines
16 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, switchSessionTo } from "./setup";
|
|
|
|
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
|
import type { ShareDomain } from "@/lib/sharing/scope";
|
|
import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope";
|
|
|
|
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<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 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<boolean> {
|
|
return (await refusals(callerId)).length === 0;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* 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 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 bodyWithoutRequestId(outOfScope);
|
|
|
|
// 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 bodyWithoutRequestId(noGrant));
|
|
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",
|
|
});
|
|
});
|
|
});
|