Files
HealthLog/tests/integration/sharing-front-door.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

642 lines
22 KiB
TypeScript

/**
* The front door, driven as routes, against real Postgres.
*
* `/` is the first page a delegate lands on after switching, and until this
* release ten of the reads it issues refused there with `undeclared_mode`.
* Admitting them is the easy half; the half that can go quietly wrong is the
* one this file exists for. A route that stops refusing and answers with the
* CALLER's own rows is worse than the refusal it replaced, because a 403 is
* visible and a plausible wrong number is not — and this exact page has
* already shipped that bug once, when the RSC prefetch seeded the delegate's
* dashboard under the owner's banner.
*
* So no case here asserts a status code and stops. Each one seeds the owner
* AND the delegate with different values, reads the route as each of them
* WITHOUT a switch to learn what each answer looks like, asserts the two
* differ — that assertion is the evidence, without it a route returning
* nothing at all would satisfy every line below — and only then switches in
* and demands the owner's answer back, byte for byte, and not the delegate's.
*
* The actor surfaces are asserted the other way round, because for them the
* caller's own answer is the correct one: the locale setter has to write the
* DELEGATE's row and leave the owner's alone, which is a claim about two rows
* and is checked against both.
*
* Everything runs through the shipped exports — the real `apiHandler`, the
* real resolvers, the real grant table. The substitution happens above the
* handler body, so a test that rebuilt a handler would be exercising its own
* copy of it and would keep passing after the route stopped performing it.
*/
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";
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++}`;
return getPrismaClient().user.create({
data: {
username: `front-${suffix}`,
email: `front-${suffix}@example.test`,
displayName: `Front ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
/**
* A live grant at the named level with the delegate's session already inside
* the owner's record — minted through the shipped transitions, so a release
* that could no longer create the grant fails here rather than passing against
* a row this file wrote itself.
*/
async function switchInto(
ownerId: string,
delegateId: string,
access: "READ" | "WRITE" = "READ",
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegateId,
access,
scope: null,
});
const grant = await acceptGrant({
grantId: invited.id,
granteeId: delegateId,
});
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
return { grant, session };
}
async function revoke(grantId: string, ownerId: string) {
const { revokeGrant } = await import("@/lib/sharing/grants");
await revokeGrant({ grantId, grantorId: ownerId });
}
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
function request(url: string, method: string, body?: unknown): NextRequest {
return new NextRequest(`http://localhost${url}`, {
method,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
});
}
async function drive(
handler: Handler,
url: string,
method = "GET",
body?: unknown,
): Promise<Response> {
return handler(request(url, method, body), {
params: Promise.resolve({}),
});
}
async function envelope(response: Response): Promise<{
data: unknown;
error: unknown;
meta?: { errorCode?: string };
}> {
return response.json();
}
/** No grant, or a grant that is gone. */
async function expectAccessDenied(response: Response) {
expect(response.status).toBe(403);
expect((await envelope(response)).meta?.errorCode).toBe(
"sharing.access.denied",
);
}
/** The route never declared it can be used under a switch. */
async function expectNotPermitted(response: Response) {
expect(response.status).toBe(403);
expect((await envelope(response)).meta?.errorCode).toBe(
"sharing.not_permitted",
);
}
async function ok(response: Response): Promise<unknown> {
expect(response.status).toBe(200);
return (await envelope(response)).data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
/* -------------------------------------------------------------------------- */
/* The shape of a front-door read */
/* -------------------------------------------------------------------------- */
interface FrontDoorRead {
/** Seed one account so its answer is recognisable. */
seed: (userId: string, marker: string) => Promise<void>;
/** Call the shipped GET export against whatever session is current. */
call: () => Promise<Response>;
/** The part of the payload that identifies whose record answered. */
read: (data: never) => unknown;
}
function frontDoorRead(name: string, route: FrontDoorRead) {
describe(name, () => {
it("answers with the owner's record, not the caller's own", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await route.seed(owner.id, "owner");
await route.seed(delegate.id, "delegate");
// What each account's own answer looks like, read the ordinary way.
// This is also the "unchanged for a caller who never switched" case:
// both calls run with no carrier at all.
await signIn(owner.id);
const ownerAnswer = route.read((await ok(await route.call())) as never);
await signIn(delegate.id);
const delegateAnswer = route.read(
(await ok(await route.call())) as never,
);
// The evidence. Without it every assertion below is satisfied by a
// route that answers the same thing to everybody — including nothing.
expect(ownerAnswer).not.toEqual(delegateAnswer);
await switchInto(owner.id, delegate.id);
const switched = route.read((await ok(await route.call())) as never);
expect(switched).toEqual(ownerAnswer);
expect(switched).not.toEqual(delegateAnswer);
});
it("refuses the next request after the grant is revoked", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await route.seed(owner.id, "owner");
const { grant } = await switchInto(owner.id, delegate.id);
expect((await route.call()).status).toBe(200);
// Same browser, same session, next request.
await revoke(grant.id, owner.id);
await expectAccessDenied(await route.call());
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
await route.seed(owner.id, "owner");
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await expectAccessDenied(await route.call());
});
});
}
/* -------------------------------------------------------------------------- */
/* The aggregates */
/* -------------------------------------------------------------------------- */
frontDoorRead("GET /api/dashboard/snapshot", {
// The snapshot names the account it was built for, which makes the marker
// the account itself rather than a value that happens to differ.
seed: async () => {},
call: async () => {
const { GET } = await import("@/app/api/dashboard/snapshot/route");
return drive(GET as Handler, "/api/dashboard/snapshot");
},
read: (data: { user: { username: string } }) => data.user.username,
});
/** A parseable cached briefing — the only thing the digest lifts prose from. */
function cachedBriefing(marker: string): string {
return JSON.stringify({
dailyBriefing: {
paragraph: `Briefing for the ${marker}. Second sentence.`,
keyFindings: [],
},
});
}
frontDoorRead("GET /api/daily/digest", {
seed: async (userId, marker) => {
await getPrismaClient().user.update({
where: { id: userId },
data: {
insightsCachedText: cachedBriefing(marker),
insightsCachedAt: new Date(),
},
});
},
call: async () => {
const { GET } = await import("@/app/api/daily/digest/route");
return drive(GET as Handler, "/api/daily/digest");
},
read: (data: { briefingLead: string | null }) => data.briefingLead,
});
frontDoorRead("GET /api/gamification/achievements", {
// Badges are earned from the record's own history, so the marker is the
// history: the owner has logged readings and the delegate has not. Dates are
// relative to now — a fixed date slides out of the trailing windows the
// badge engine counts over and takes the difference with it.
seed: async (userId, marker) => {
if (marker !== "owner") return;
const prisma = getPrismaClient();
for (let back = 0; back < 6; back++) {
await prisma.measurement.create({
data: {
userId,
type: "WEIGHT",
value: 80 + back,
unit: "kg",
measuredAt: new Date(Date.now() - back * 24 * 60 * 60 * 1000),
source: "MANUAL",
},
});
}
},
call: async () => {
const { GET } = await import("@/app/api/gamification/achievements/route");
return drive(GET as Handler, "/api/gamification/achievements");
},
// Per-badge progress rather than the headline tally: six readings move
// several counters without necessarily unlocking anything, and a summary
// that reads 0/0 for both accounts would make the comparison vacuous.
read: (data: { achievements: { id: string; current: number }[] }) =>
data.achievements.map((a) => `${a.id}:${a.current}`).join("|"),
});
/* -------------------------------------------------------------------------- */
/* The Coach reads */
/* -------------------------------------------------------------------------- */
async function seedAssistantMessage(userId: string, at: string) {
const prisma = getPrismaClient();
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
const conversation = await prisma.coachConversation.create({
data: { userId, title: "Nudge" },
});
await prisma.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes("A proactive line."),
createdAt: new Date(at),
},
});
}
frontDoorRead("GET /api/insights/coach/nudge-status", {
seed: async (userId, marker) => {
await seedAssistantMessage(
userId,
marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z",
);
},
call: async () => {
const { GET } = await import("@/app/api/insights/coach/nudge-status/route");
return drive(GET as Handler, "/api/insights/coach/nudge-status");
},
read: (data: { nudgedAt: string | null }) => data.nudgedAt,
});
frontDoorRead("GET /api/coach/reminders", {
// The note is encrypted at rest, so the marker only appears in the response
// if the route read the right rows AND decrypted them.
seed: async (userId, marker) => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
await getPrismaClient().coachReminder.create({
data: {
userId,
noteEncrypted: encryptToBytes(`Remind the ${marker} about this.`),
triggerKind: "date",
status: "active",
source: "manual",
},
});
},
call: async () => {
const { GET } = await import("@/app/api/coach/reminders/route");
return drive(GET as Handler, "/api/coach/reminders");
},
read: (data: { reminders: { note: string }[] }) =>
data.reminders.map((r) => r.note),
});
/* -------------------------------------------------------------------------- */
/* The presentation trio — read admitted, write refused */
/* -------------------------------------------------------------------------- */
frontDoorRead("GET /api/settings/reminder-thresholds", {
seed: async (userId, marker) => {
await getPrismaClient().user.update({
where: { id: userId },
data: {
notificationPrefs: {
medication: { lowStockRunwayDays: marker === "owner" ? 21 : 3 },
},
},
});
},
call: async () => {
const { GET } =
await import("@/app/api/settings/reminder-thresholds/route");
return drive(GET as Handler, "/api/settings/reminder-thresholds");
},
read: (data: { lowStockRunwayDays: number | null }) =>
data.lowStockRunwayDays,
});
async function seedDashboardLayout(userId: string, marker: string) {
const { serializeDashboardLayout, DEFAULT_DASHBOARD_LAYOUT } =
await import("@/lib/dashboard-layout");
const layout = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
comparisonBaseline: marker === "owner" ? "lastYear" : "lastMonth",
});
await getPrismaClient().user.update({
where: { id: userId },
// The blob is a plain JSON column; the serializer above produced the shape
// the resolver reads back.
data: { dashboardWidgetsJson: layout as never },
});
}
frontDoorRead("GET /api/dashboard/widgets", {
seed: seedDashboardLayout,
call: async () => {
const { GET } = await import("@/app/api/dashboard/widgets/route");
return drive(GET as Handler, "/api/dashboard/widgets");
},
read: (data: { comparisonBaseline: string }) => data.comparisonBaseline,
});
async function seedMedicationLayout(userId: string, marker: string) {
const { serializeMedicationListLayout } =
await import("@/lib/medication-list-layout");
const layout = serializeMedicationListLayout({
view: marker === "owner" ? "table" : "cards",
order: [`${marker}-med-id`],
});
await getPrismaClient().user.update({
where: { id: userId },
data: { medicationListLayoutJson: layout as never },
});
}
frontDoorRead("GET /api/medications/layout", {
seed: seedMedicationLayout,
call: async () => {
const { GET } = await import("@/app/api/medications/layout/route");
return drive(GET as Handler, "/api/medications/layout");
},
read: (data: { view: string; order: string[] }) =>
`${data.view}:${data.order.join(",")}`,
});
/* -------------------------------------------------------------------------- */
/* The write arms the split leaves refusing */
/* -------------------------------------------------------------------------- */
describe("a delegate cannot rewrite the record's presentation", () => {
it("refuses PUT /api/dashboard/widgets under a WRITE grant and changes nothing", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedDashboardLayout(owner.id, "owner");
// A WRITE grant, the strongest thing a delegate can hold. The read arm
// above admits this caller; the write arm still must not.
await switchInto(owner.id, delegate.id, "WRITE");
const { PUT } = await import("@/app/api/dashboard/widgets/route");
await expectNotPermitted(
await drive(PUT as Handler, "/api/dashboard/widgets", "PUT", {
version: 1,
comparisonBaseline: "lastMonth",
}),
);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: owner.id },
select: { dashboardWidgetsJson: true },
});
expect(
(row.dashboardWidgetsJson as { comparisonBaseline?: string })
?.comparisonBaseline,
).toBe("lastYear");
});
it("refuses PUT /api/medications/layout under a WRITE grant and changes nothing", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMedicationLayout(owner.id, "owner");
await switchInto(owner.id, delegate.id, "WRITE");
const { PUT } = await import("@/app/api/medications/layout/route");
await expectNotPermitted(
await drive(PUT as Handler, "/api/medications/layout", "PUT", {
version: 1,
view: "cards",
}),
);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: owner.id },
select: { medicationListLayoutJson: true },
});
expect((row.medicationListLayoutJson as { view?: string })?.view).toBe(
"table",
);
});
it("refuses POST /api/coach/reminders under a WRITE grant and writes no row", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await switchInto(owner.id, delegate.id, "WRITE");
const { POST } = await import("@/app/api/coach/reminders/route");
await expectNotPermitted(
await drive(POST as Handler, "/api/coach/reminders", "POST", {
note: "Put this in somebody else's Coach memory.",
}),
);
expect(await getPrismaClient().coachReminder.count()).toBe(0);
});
it("still lets the owner write their own presentation", async () => {
// The regression guard for the split: the arms above refuse a DELEGATE,
// and a caller who never switched must not be able to tell any of this
// happened.
const plain = await makeUser("plain");
await seedDashboardLayout(plain.id, "owner");
await signIn(plain.id);
const { PUT } = await import("@/app/api/dashboard/widgets/route");
const response = await drive(
PUT as Handler,
"/api/dashboard/widgets",
"PUT",
{ version: 1, comparisonBaseline: "lastMonth" },
);
expect(response.status).toBe(200);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: plain.id },
select: { dashboardWidgetsJson: true },
});
expect(
(row.dashboardWidgetsJson as { comparisonBaseline?: string })
?.comparisonBaseline,
).toBe("lastMonth");
});
});
/* -------------------------------------------------------------------------- */
/* The actor surfaces */
/* -------------------------------------------------------------------------- */
describe("PUT /api/auth/me/locale — the language belongs to the person", () => {
it("writes the delegate's row and leaves the owner's untouched", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: owner.id },
data: { locale: "en" },
});
await switchInto(owner.id, delegate.id, "WRITE");
const { PUT } = await import("@/app/api/auth/me/locale/route");
const response = await drive(PUT as Handler, "/api/auth/me/locale", "PUT", {
locale: "fr",
});
expect(response.status).toBe(200);
// Both rows, because "the delegate's row changed" and "the owner's row
// did not" are two claims and only the pair rules out a substitution.
expect(
(
await prisma.user.findUniqueOrThrow({
where: { id: delegate.id },
select: { locale: true },
})
).locale,
).toBe("fr");
expect(
(
await prisma.user.findUniqueOrThrow({
where: { id: owner.id },
select: { locale: true },
})
).locale,
).toBe("en");
});
it("refuses a selector header, which an actor surface never has a use for", async () => {
const caller = await makeUser("caller");
await signIn(caller.id);
const { ACCOUNT_SELECTOR_HEADER } =
await import("@/lib/auth/acting-carrier");
headerJar.set(ACCOUNT_SELECTOR_HEADER, "some-account-id");
const { PUT } = await import("@/app/api/auth/me/locale/route");
await expectNotPermitted(
await drive(PUT as Handler, "/api/auth/me/locale", "PUT", {
locale: "fr",
}),
);
});
});
describe("GET /api/feature-flags — the deployment, not the record", () => {
it("keeps answering while a switch is on", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/feature-flags/route");
const data = (await ok(
await drive(GET as Handler, "/api/feature-flags"),
)) as { assistant: { coach: boolean } };
// Not a bare 200: the shell gates the Coach launcher on this field, so an
// empty envelope would satisfy a status-only assertion and still break the
// surface this route exists to keep alive.
expect(data.assistant.coach).toBe(true);
});
it("refuses a selector header", async () => {
const caller = await makeUser("caller");
await signIn(caller.id);
const { ACCOUNT_SELECTOR_HEADER } =
await import("@/lib/auth/acting-carrier");
headerJar.set(ACCOUNT_SELECTOR_HEADER, "some-account-id");
const { GET } = await import("@/app/api/feature-flags/route");
await expectNotPermitted(await drive(GET as Handler, "/api/feature-flags"));
});
});