Files
HealthLog/tests/integration/sharing-delegable-routes.test.ts
T

2156 lines
74 KiB
TypeScript

/**
* The migrated routes, driven as routes, against real Postgres.
*
* Every case below imports the shipped `GET` export and calls it. Nothing is
* reimplemented: the real `apiHandler`, the real `requireRecordAuth`, the real
* grant table, the real Prisma queries. That matters more here than anywhere
* else in this feature, because the thing being asserted is a substitution
* that happens ABOVE the handler body — a test that rebuilt the handler would
* be testing its own copy of the substitution and would keep passing after the
* route stopped performing it.
*
* Four questions per domain, and the fourth is the one that decides whether
* this release is safe to ship:
*
* 1. a delegate with a live READ grant reads the OWNER's rows;
* 2. the same delegate without a grant is refused `sharing.access.denied`;
* 3. after revocation the very NEXT request is refused — no re-login;
* 4. a caller who never switched gets exactly what they got before.
*
* (4) is the regression. Forty-four routes changed; a person who shares
* nothing must not be able to tell.
*
* Owner and delegate are BOTH seeded with data in every case, and the
* assertions name the owner's values and the delegate's separately. An empty
* result satisfies "the delegate's rows are absent" while hiding the exact
* failure this feature exists to avoid.
*/
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";
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: `deleg-${suffix}`,
email: `deleg-${suffix}@example.test`,
displayName: `Deleg ${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 and a session already inside the owner's record.
*
* Written straight to the tables rather than driven through invite / accept /
* switch, because `sharing-lifecycle.test.ts` already proves those three
* routes produce exactly these rows and repeating the handshake once per
* domain would buy nothing but wall-clock. The rows are the contract; this
* file is about what the migrated routes do once they exist.
*/
async function switchInto(ownerId: string, delegateId: string) {
const grant = await getPrismaClient().accountGrant.create({
data: {
grantorId: ownerId,
granteeId: delegateId,
access: "READ",
acceptedAt: new Date(),
},
});
const session = await signIn(delegateId);
await switchSessionTo(session.id, ownerId);
return { grant, session };
}
/** The delegate is inside the record, but the grant is gone. */
async function revokeGrant(grantId: string) {
await getPrismaClient().accountGrant.update({
where: { id: grantId },
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
});
}
/**
* A route's `GET` export, seen from outside.
*
* Each shipped handler declares its own params shape (`{ id: string }` and so
* on), which is exactly the sort of per-route difference a driver like this one
* has no business knowing. The cast at each call site narrows to this, and the
* `params` object handed in is checked by the route's own Zod / lookup logic
* one line later — a wrong id produces a 404 from the handler, not a type
* error here.
*/
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
function get(url: string): NextRequest {
return new NextRequest(`http://localhost${url}`, { method: "GET" });
}
async function call(
handler: Handler,
url: string,
params: Record<string, string> = {},
): Promise<Response> {
return handler(get(url), { params: Promise.resolve(params) });
}
async function body(response: Response): Promise<{
data: unknown;
error: unknown;
meta?: { errorCode?: string };
}> {
return response.json();
}
/** The stable refusal, asserted by code rather than by status alone. */
async function expectDenied(response: Response) {
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("sharing.access.denied");
}
/**
* The four-part contract, run against one route.
*
* `seed` writes one row for whichever user it is handed and returns the value
* that identifies it in the response; `read` pulls those identifying values
* back out of the response body. The domain suites below supply both, so each
* one is four assertions of substance rather than four of shape.
*/
function contract<T>(
name: string,
route: {
call: () => Promise<Response>;
seed: (userId: string, marker: T) => Promise<void>;
read: (payload: never) => T[];
ownerMarker: T;
delegateMarker: T;
},
) {
describe(name, () => {
it("serves the owner's rows to a delegate holding a live grant", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await route.seed(owner.id, route.ownerMarker);
await route.seed(delegate.id, route.delegateMarker);
await switchInto(owner.id, delegate.id);
const response = await route.call();
expect(response.status).toBe(200);
const rows = route.read((await body(response)).data as never);
expect(rows).toContainEqual(route.ownerMarker);
// The delegate's own row is seeded and must be absent. Asserting the
// owner's presence alone would pass on a handler that returned
// everybody's rows.
expect(rows).not.toContainEqual(route.delegateMarker);
});
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, route.ownerMarker);
const session = await signIn(stranger.id);
await switchSessionTo(session.id, owner.id);
await expectDenied(await route.call());
});
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, route.ownerMarker);
const { grant } = await switchInto(owner.id, delegate.id);
expect((await route.call()).status).toBe(200);
// No sign-out, no new session, no cache flush. The same browser, the
// next request.
await revokeGrant(grant.id);
await expectDenied(await route.call());
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await route.seed(plain.id, route.ownerMarker);
await signIn(plain.id);
const response = await route.call();
expect(response.status).toBe(200);
expect(route.read((await body(response)).data as never)).toContainEqual(
route.ownerMarker,
);
});
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
/* -------------------------------------------------------------------------- */
/* Measurements */
/* -------------------------------------------------------------------------- */
async function seedWeight(userId: string, value: number) {
await getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
// Relative to now: a fixed date ages out of the 90-day series window.
measuredAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000),
source: "MANUAL",
},
});
}
contract<number>("GET /api/measurements", {
call: async () => {
const { GET } = await import("@/app/api/measurements/route");
return call(GET as Handler, "/api/measurements");
},
seed: seedWeight,
read: (payload: { measurements: { value: number }[] }) =>
payload.measurements.map((m) => m.value),
ownerMarker: 81,
delegateMarker: 64,
});
contract<number>("GET /api/measurements/series", {
call: async () => {
const { GET } = await import("@/app/api/measurements/series/route");
return call(GET as Handler, "/api/measurements/series?kind=weight&days=90");
},
seed: seedWeight,
read: (payload: { points: { value: number }[] }) =>
payload.points.map((p) => p.value),
ownerMarker: 81,
delegateMarker: 64,
});
contract<number>("GET /api/measurements/series-batch", {
call: async () => {
const { GET } = await import("@/app/api/measurements/series-batch/route");
return call(
GET as Handler,
`/api/measurements/series-batch?types=WEIGHT&from=${new Date(Date.now() - 365 * 86_400_000).toISOString()}&to=${new Date(Date.now() + 86_400_000).toISOString()}`,
);
},
seed: seedWeight,
read: (payload: { series: Record<string, { value: number }[]> }) =>
(payload.series.WEIGHT ?? []).map((row) => row.value),
ownerMarker: 81,
delegateMarker: 64,
});
describe("GET /api/measurements/[id]", () => {
it("serves one of the owner's readings and refuses the delegate's reach past it", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
await seedWeight(delegate.id, 64);
const ownerRow = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/measurements/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/measurements/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ value: 81 });
// The fetch-then-guard arm now guards against the OWNER. The delegate's
// own reading is out of scope while they are inside somebody else's
// record — which is the correct answer and a surprising one, so it is
// asserted rather than assumed.
const theirs = await call(
GET as Handler,
`/api/measurements/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/measurements/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedWeight(plain.id, 77);
const row = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/measurements/[id]/route");
const response = await call(GET as Handler, `/api/measurements/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ value: 77 });
});
});
describe("the 422 breadcrumb a delegate leaves on the owner's record", () => {
it("files under the owner and names the delegate as the actor", async () => {
// The one write a delegable measurements GET performs. It was a bare
// `prisma.auditLog.create` before the migration, which files the row with
// `actorUserId` NULL — indistinguishable from the owner having typed the
// bad query themselves.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/measurements/route");
const refused = await call(
GET as Handler,
"/api/measurements?limit=not-a-number",
);
expect(refused.status).toBe(422);
await vi.waitFor(async () => {
const row = await getPrismaClient().auditLog.findFirst({
where: { action: "measurements.list.validation-failed" },
});
expect(row?.userId).toBe(owner.id);
expect(row?.actorUserId).toBe(delegate.id);
});
});
});
/* -------------------------------------------------------------------------- */
/* Measurement reminders */
/* -------------------------------------------------------------------------- */
async function seedReminder(userId: string, label: string) {
await getPrismaClient().measurementReminder.create({
data: {
userId,
label,
measurementType: "WEIGHT",
intervalDays: 7,
enabled: true,
},
});
}
contract<string>("GET /api/measurement-reminders", {
call: async () => {
const { GET } = await import("@/app/api/measurement-reminders/route");
return call(GET as Handler, "/api/measurement-reminders");
},
seed: seedReminder,
read: (payload: { label: string }[]) => payload.map((r) => r.label),
ownerMarker: "owner weigh-in",
delegateMarker: "delegate weigh-in",
});
describe("GET /api/measurement-reminders/[id]", () => {
it("serves the owner's reminder and refuses once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedReminder(owner.id, "owner weigh-in");
await seedReminder(delegate.id, "delegate weigh-in");
const ownerRow =
await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow =
await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/measurement-reminders/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/measurement-reminders/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ label: "owner weigh-in" });
const theirs = await call(
GET as Handler,
`/api/measurement-reminders/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/measurement-reminders/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedReminder(plain.id, "plain weigh-in");
const row = await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/measurement-reminders/[id]/route");
const response = await call(
GET as Handler,
`/api/measurement-reminders/${row.id}`,
{ id: row.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
label: "plain weigh-in",
});
});
});
/* -------------------------------------------------------------------------- */
/* Labs and biomarkers */
/* -------------------------------------------------------------------------- */
async function seedLab(userId: string, analyte: string) {
await getPrismaClient().labResult.create({
data: {
userId,
analyte,
value: 5.2,
unit: "mmol/L",
takenAt: new Date("2026-07-10T09:00:00Z"),
},
});
}
contract<string>("GET /api/labs", {
call: async () => {
const { GET } = await import("@/app/api/labs/route");
return call(GET as Handler, "/api/labs");
},
seed: seedLab,
read: (payload: { results: { analyte: string }[] }) =>
payload.results.map((r) => r.analyte),
ownerMarker: "owner-hba1c",
delegateMarker: "delegate-hba1c",
});
async function seedBiomarker(userId: string, name: string) {
await getPrismaClient().biomarker.create({
data: { userId, name, unit: "mmol/L" },
});
}
contract<string>("GET /api/biomarkers", {
call: async () => {
const { GET } = await import("@/app/api/biomarkers/route");
return call(GET as Handler, "/api/biomarkers");
},
seed: seedBiomarker,
read: (payload: { biomarkers: { name: string }[] }) =>
payload.biomarkers.map((b) => b.name),
ownerMarker: "owner-ferritin",
delegateMarker: "delegate-ferritin",
});
describe("GET /api/labs/[id] and /api/biomarkers/[id]", () => {
it("resolve by id against the owner, not the caller", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedLab(owner.id, "owner-hba1c");
await seedLab(delegate.id, "delegate-hba1c");
await seedBiomarker(owner.id, "owner-ferritin");
const ownerLab = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateLab = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: delegate.id },
});
const ownerMarker = await getPrismaClient().biomarker.findFirstOrThrow({
where: { userId: owner.id },
});
const labs = await import("@/app/api/labs/[id]/route");
const markers = await import("@/app/api/biomarkers/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const lab = await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, {
id: ownerLab.id,
});
expect(lab.status).toBe(200);
expect((await body(lab)).data).toMatchObject({ analyte: "owner-hba1c" });
// The delegate's own result is not reachable from inside the owner's
// record. The fetch-then-guard arm now guards against the owner.
const theirs = await call(
labs.GET as Handler,
`/api/labs/${delegateLab.id}`,
{ id: delegateLab.id },
);
expect(theirs.status).toBe(404);
const marker = await call(
markers.GET as Handler,
`/api/biomarkers/${ownerMarker.id}`,
{ id: ownerMarker.id },
);
expect(marker.status).toBe(200);
expect((await body(marker)).data).toMatchObject({ name: "owner-ferritin" });
await revokeGrant(grant.id);
await expectDenied(
await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, {
id: ownerLab.id,
}),
);
await expectDenied(
await call(markers.GET as Handler, `/api/biomarkers/${ownerMarker.id}`, {
id: ownerMarker.id,
}),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedLab(plain.id, "plain-hba1c");
const row = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/labs/[id]/route");
const response = await call(GET as Handler, `/api/labs/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
analyte: "plain-hba1c",
});
});
});
/* -------------------------------------------------------------------------- */
/* Clinical record */
/* -------------------------------------------------------------------------- */
async function seedAllergy(userId: string, substance: string) {
await getPrismaClient().allergy.create({ data: { userId, substance } });
}
contract<string>("GET /api/allergies", {
call: async () => {
const { GET } = await import("@/app/api/allergies/route");
return call(GET as Handler, "/api/allergies");
},
seed: seedAllergy,
read: (payload: { substance: string }[]) => payload.map((a) => a.substance),
ownerMarker: "owner-penicillin",
delegateMarker: "delegate-penicillin",
});
async function seedFamilyHistory(userId: string, condition: string) {
await getPrismaClient().familyHistoryEntry.create({
data: { userId, relationship: "MOTHER", condition },
});
}
contract<string>("GET /api/family-history", {
call: async () => {
const { GET } = await import("@/app/api/family-history/route");
return call(GET as Handler, "/api/family-history");
},
seed: seedFamilyHistory,
read: (payload: { condition: string }[]) => payload.map((e) => e.condition),
ownerMarker: "owner-condition",
delegateMarker: "delegate-condition",
});
describe("GET /api/allergies/[id] and /api/family-history/[id]", () => {
it("resolve by id against the owner and refuse once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedAllergy(owner.id, "owner-penicillin");
await seedAllergy(delegate.id, "delegate-penicillin");
await seedFamilyHistory(owner.id, "owner-condition");
const ownerAllergy = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateAllergy = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: delegate.id },
});
const ownerEntry =
await getPrismaClient().familyHistoryEntry.findFirstOrThrow({
where: { userId: owner.id },
});
const allergies = await import("@/app/api/allergies/[id]/route");
const history = await import("@/app/api/family-history/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
allergies.GET as Handler,
`/api/allergies/${ownerAllergy.id}`,
{ id: ownerAllergy.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({
substance: "owner-penicillin",
});
const theirs = await call(
allergies.GET as Handler,
`/api/allergies/${delegateAllergy.id}`,
{ id: delegateAllergy.id },
);
expect(theirs.status).toBe(404);
const entry = await call(
history.GET as Handler,
`/api/family-history/${ownerEntry.id}`,
{ id: ownerEntry.id },
);
expect(entry.status).toBe(200);
expect((await body(entry)).data).toMatchObject({
condition: "owner-condition",
});
await revokeGrant(grant.id);
await expectDenied(
await call(
allergies.GET as Handler,
`/api/allergies/${ownerAllergy.id}`,
{ id: ownerAllergy.id },
),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedAllergy(plain.id, "plain-penicillin");
const row = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/allergies/[id]/route");
const response = await call(GET as Handler, `/api/allergies/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
substance: "plain-penicillin",
});
});
});
async function seedFact(userId: string, value: string) {
// Through the real writer, because the revision's value is an encrypted
// column and a hand-built row would read back as `unreadable`.
const { createHealthProfileFact } =
await import("@/lib/profile/health-facts");
await createHealthProfileFact(userId, "SMOKING_STATUS", value as never);
}
contract<string>("GET /api/anamnesis/facts", {
call: async () => {
const { GET } = await import("@/app/api/anamnesis/facts/route");
return call(GET as Handler, "/api/anamnesis/facts");
},
seed: seedFact,
read: (payload: {
current: Record<string, { value: string | null } | null>;
}) =>
Object.values(payload.current)
.map((row) => row?.value ?? null)
.filter((v): v is string => v !== null),
ownerMarker: "CURRENT",
delegateMarker: "NEVER",
});
/* -------------------------------------------------------------------------- */
/* Mental health — the module gate follows the record */
/* -------------------------------------------------------------------------- */
async function seedAssessment(userId: string, totalScore: number) {
await getPrismaClient().mentalHealthAssessment.create({
data: {
userId,
instrument: "PHQ9",
locale: "en",
responsesEncrypted: Buffer.from("sealed"),
totalScore,
severityBand: "mild",
takenAt: new Date("2026-07-12T10:00:00Z"),
},
});
}
async function setModulePreference(userId: string, enabled: boolean) {
await getPrismaClient().user.update({
where: { id: userId },
data: { modulePreferencesJson: { mentalHealth: enabled } },
});
}
describe("GET /api/mental-health/assessments", () => {
it("reads the owner's screener history when the OWNER enabled the module", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, true);
await seedAssessment(owner.id, 11);
await seedAssessment(delegate.id, 3);
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
assessments: { totalScore: number }[];
};
expect(payload.assessments.map((a) => a.totalScore)).toEqual([11]);
});
it("refuses when the OWNER switched the module off, whatever the delegate set", async () => {
// The decision recorded in the classification: the module map governs
// which parts of a RECORD exist, so it is read off the record. A delegate
// who enabled the screener on their own account does not thereby gain a
// screener view inside somebody else's.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, false);
await setModulePreference(delegate.id, true);
await seedAssessment(owner.id, 11);
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("module.disabled");
});
it("refuses a caller with no grant, and refuses the request after revocation", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, true);
await seedAssessment(owner.id, 11);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect(
(await call(GET as Handler, "/api/mental-health/assessments")).status,
).toBe(200);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, "/api/mental-health/assessments"),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await setModulePreference(plain.id, true);
await seedAssessment(plain.id, 7);
await signIn(plain.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
assessments: { totalScore: number }[];
};
expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]);
});
});
/* -------------------------------------------------------------------------- */
/* Mood, custom metrics, personal records */
/* -------------------------------------------------------------------------- */
async function seedMood(userId: string, score: number) {
await getPrismaClient().moodEntry.create({
data: {
userId,
date: "2026-07-14",
mood: "GUT",
score,
moodLoggedAt: new Date("2026-07-14T19:00:00Z"),
},
});
}
contract<number>("GET /api/mood-entries", {
call: async () => {
const { GET } = await import("@/app/api/mood-entries/route");
return call(GET as Handler, "/api/mood-entries");
},
seed: seedMood,
read: (payload: { entries: { score: number }[] }) =>
payload.entries.map((e) => e.score),
ownerMarker: 4,
delegateMarker: 2,
});
describe("GET /api/mood-entries/[id]", () => {
it("resolves against the owner and refuses once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMood(owner.id, 4);
await seedMood(delegate.id, 2);
const ownerRow = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/mood-entries/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/mood-entries/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ score: 4 });
const theirs = await call(
GET as Handler,
`/api/mood-entries/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/mood-entries/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedMood(plain.id, 5);
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/mood-entries/[id]/route");
const response = await call(GET as Handler, `/api/mood-entries/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ score: 5 });
});
});
async function seedCustomMetric(userId: string, name: string) {
const metric = await getPrismaClient().customMetric.create({
data: { userId, name, unit: "ml" },
});
await getPrismaClient().customMetricEntry.create({
data: {
userId,
customMetricId: metric.id,
value: 250,
unit: "ml",
measuredAt: new Date("2026-07-14T12:00:00Z"),
},
});
}
contract<string>("GET /api/custom-metrics", {
call: async () => {
const { GET } = await import("@/app/api/custom-metrics/route");
return call(GET as Handler, "/api/custom-metrics");
},
seed: seedCustomMetric,
read: (payload: { customMetrics: { name: string }[] }) =>
payload.customMetrics.map((m) => m.name),
ownerMarker: "owner-water",
delegateMarker: "delegate-water",
});
describe("GET /api/custom-metrics/[id] and its entries", () => {
it("reach the owner's metric and refuse once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCustomMetric(owner.id, "owner-water");
await seedCustomMetric(delegate.id, "delegate-water");
const ownerMetric = await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateMetric =
await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: delegate.id },
});
const detail = await import("@/app/api/custom-metrics/[id]/route");
const entries = await import("@/app/api/custom-metrics/[id]/entries/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
detail.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}`,
{ id: ownerMetric.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ name: "owner-water" });
// The entries route establishes ownership on the METRIC first and only
// then queries by `customMetricId`. That hop now resolves against the
// owner, so the delegate's own metric is unreachable from in here.
const theirEntries = await call(
entries.GET as Handler,
`/api/custom-metrics/${delegateMetric.id}/entries`,
{ id: delegateMetric.id },
);
expect(theirEntries.status).toBe(404);
const ownerEntries = await call(
entries.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}/entries`,
{ id: ownerMetric.id },
);
expect(ownerEntries.status).toBe(200);
const payload = (await body(ownerEntries)).data as {
entries: { value: number }[];
};
expect(payload.entries.map((e) => e.value)).toEqual([250]);
await revokeGrant(grant.id);
await expectDenied(
await call(
entries.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}/entries`,
{ id: ownerMetric.id },
),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedCustomMetric(plain.id, "plain-water");
const metric = await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/custom-metrics/[id]/route");
const response = await call(
GET as Handler,
`/api/custom-metrics/${metric.id}`,
{ id: metric.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ name: "plain-water" });
});
});
async function seedPersonalRecord(userId: string, value: number) {
await getPrismaClient().personalRecord.create({
data: {
userId,
metricType: "WEIGHT",
direction: "MIN",
value,
unit: "kg",
achievedAt: new Date("2026-06-01T08:00:00Z"),
},
});
}
contract<number>("GET /api/personal-records", {
call: async () => {
const { GET } = await import("@/app/api/personal-records/route");
return call(GET as Handler, "/api/personal-records");
},
seed: seedPersonalRecord,
read: (payload: { value: number }[]) => payload.map((r) => r.value),
ownerMarker: 79.5,
delegateMarker: 61.5,
});
/* -------------------------------------------------------------------------- */
/* Sleep, nutrients, illness, workouts */
/* -------------------------------------------------------------------------- */
async function seedSleepStage(userId: string, minutes: number) {
await getPrismaClient().measurement.create({
data: {
userId,
type: "SLEEP_DURATION",
value: minutes,
unit: "min",
measuredAt: new Date(Date.now() - 8 * 3_600_000),
source: "MANUAL",
sleepStage: "DEEP",
},
});
}
describe("GET /api/sleep/night", () => {
it("reads the owner's night and refuses once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedSleepStage(owner.id, 90);
await seedSleepStage(delegate.id, 30);
const { GET } = await import("@/app/api/sleep/night/route");
const { grant } = await switchInto(owner.id, delegate.id);
const response = await call(GET as Handler, "/api/sleep/night");
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
main: { stages?: unknown; totalMinutes?: number } | null;
};
// The owner's 90 minutes, not the delegate's 30.
expect(JSON.stringify(payload)).toContain("90");
expect(JSON.stringify(payload)).not.toContain('"30"');
await revokeGrant(grant.id);
await expectDenied(await call(GET as Handler, "/api/sleep/night"));
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedSleepStage(plain.id, 90);
await signIn(plain.id);
const { GET } = await import("@/app/api/sleep/night/route");
const response = await call(GET as Handler, "/api/sleep/night");
expect(response.status).toBe(200);
expect(JSON.stringify((await body(response)).data)).toContain("90");
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
const session = await signIn(stranger.id);
await switchSessionTo(session.id, owner.id);
const { GET } = await import("@/app/api/sleep/night/route");
await expectDenied(await call(GET as Handler, "/api/sleep/night"));
});
});
describe("GET /api/sleep/rhythm", () => {
it("follows the record's sleep module, not the delegate's", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await getPrismaClient().user.update({
where: { id: owner.id },
data: { modulePreferencesJson: { sleep: false } },
});
await getPrismaClient().user.update({
where: { id: delegate.id },
data: { modulePreferencesJson: { sleep: true } },
});
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/sleep/rhythm/route");
const response = await call(GET as Handler, "/api/sleep/rhythm");
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("module.disabled");
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await signIn(plain.id);
const { GET } = await import("@/app/api/sleep/rhythm/route");
expect((await call(GET as Handler, "/api/sleep/rhythm")).status).toBe(200);
});
});
async function seedNutrient(userId: string, amount: number) {
// Opt-in module, default off. Enabled on whoever is seeded, so the delegate
// case below exercises the substitution rather than the gate.
await getPrismaClient().user.update({
where: { id: userId },
data: { modulePreferencesJson: { nutrients: true } },
});
const day = new Date().toISOString().slice(0, 10);
await getPrismaClient().nutrientIntakeDay.create({
data: { userId, day, nutrient: "magnesium", amount, unit: "mg" },
});
}
contract<number>("GET /api/nutrients", {
call: async () => {
const { GET } = await import("@/app/api/nutrients/route");
return call(GET as Handler, "/api/nutrients?days=30");
},
seed: seedNutrient,
read: (payload: { nutrients: { latestAmount: number }[] }) =>
payload.nutrients.map((n) => n.latestAmount),
ownerMarker: 320,
delegateMarker: 145,
});
contract<number>("GET /api/nutrients/daily", {
call: async () => {
const { GET } = await import("@/app/api/nutrients/daily/route");
return call(GET as Handler, "/api/nutrients/daily?nutrient=magnesium");
},
seed: seedNutrient,
read: (payload: { days: { amount: number }[] }) =>
payload.days.map((d) => d.amount).filter((a) => a > 0),
ownerMarker: 320,
delegateMarker: 145,
});
async function seedEpisode(userId: string, label: string) {
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId,
label,
type: "INFECTION",
onsetAt: new Date("2026-07-01T00:00:00Z"),
},
});
await getPrismaClient().illnessDayLog.create({
data: {
userId,
episodeId: episode.id,
date: "2026-07-02",
functionalImpact: 3,
},
});
}
contract<string>("GET /api/illness/episodes", {
call: async () => {
const { GET } = await import("@/app/api/illness/episodes/route");
return call(GET as Handler, "/api/illness/episodes");
},
seed: seedEpisode,
read: (payload: { label: string }[]) => payload.map((e) => e.label),
ownerMarker: "owner-flu",
delegateMarker: "delegate-flu",
});
describe("GET /api/illness/episodes/[id] and its day logs", () => {
it("resolve the ownership hop against the record", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedEpisode(owner.id, "owner-flu");
await seedEpisode(delegate.id, "delegate-flu");
const ownerEpisode =
await getPrismaClient().illnessEpisode.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateEpisode =
await getPrismaClient().illnessEpisode.findFirstOrThrow({
where: { userId: delegate.id },
});
const detail = await import("@/app/api/illness/episodes/[id]/route");
const logs = await import("@/app/api/illness/episodes/[id]/day-logs/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
detail.GET as Handler,
`/api/illness/episodes/${ownerEpisode.id}`,
{ id: ownerEpisode.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ label: "owner-flu" });
// `loadOwnedEpisode` is what makes the episode-scoped day-log query safe.
// It now resolves against the owner, so the delegate's own episode 404s
// from in here rather than leaking its logs.
const theirs = await call(
logs.GET as Handler,
`/api/illness/episodes/${delegateEpisode.id}/day-logs?date=2026-07-02`,
{ id: delegateEpisode.id },
);
expect(theirs.status).toBe(404);
const ownerLog = await call(
logs.GET as Handler,
`/api/illness/episodes/${ownerEpisode.id}/day-logs?date=2026-07-02`,
{ id: ownerEpisode.id },
);
expect(ownerLog.status).toBe(200);
expect((await body(ownerLog)).data).toMatchObject({
functionalImpact: 3,
});
await revokeGrant(grant.id);
await expectDenied(
await call(
detail.GET as Handler,
`/api/illness/episodes/${ownerEpisode.id}`,
{ id: ownerEpisode.id },
),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedEpisode(plain.id, "plain-flu");
const episode = await getPrismaClient().illnessEpisode.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/illness/episodes/[id]/route");
const response = await call(
GET as Handler,
`/api/illness/episodes/${episode.id}`,
{ id: episode.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ label: "plain-flu" });
});
});
async function seedWorkout(userId: string, durationSec: number) {
await getPrismaClient().workout.create({
data: {
userId,
sportType: "RUNNING",
startedAt: new Date("2026-07-11T06:00:00Z"),
endedAt: new Date("2026-07-11T07:00:00Z"),
durationSec,
},
});
}
contract<number>("GET /api/workouts", {
call: async () => {
const { GET } = await import("@/app/api/workouts/route");
return call(GET as Handler, "/api/workouts");
},
seed: seedWorkout,
read: (payload: { workouts: { durationSec: number | null }[] }) =>
payload.workouts
.map((w) => w.durationSec)
.filter((d): d is number => d !== null),
ownerMarker: 3600,
delegateMarker: 1800,
});
/* -------------------------------------------------------------------------- */
/* Cycle — the gate reads the record's own gender */
/* -------------------------------------------------------------------------- */
/**
* A cycle-tracking account. `cycleTrackingEnabled` is an explicit opt-in that
* overrides the gender derivation, so the fixture does not depend on the enum
* normalisation the gate's own comment flags as still pending.
*/
async function seedCycle(userId: string, startDate: string) {
await getPrismaClient().user.update({
where: { id: userId },
data: { gender: "FEMALE" },
});
await getPrismaClient().cycleProfile.upsert({
where: { userId },
create: { userId, cycleTrackingEnabled: true },
update: { cycleTrackingEnabled: true },
});
await getPrismaClient().menstrualCycle.create({
data: { userId, startDate },
});
}
contract<string>("GET /api/cycle/cycles", {
call: async () => {
const { GET } = await import("@/app/api/cycle/cycles/route");
return call(GET as Handler, "/api/cycle/cycles");
},
seed: seedCycle,
read: (payload: { cycles: { startDate: string }[] }) =>
payload.cycles.map((c) => c.startDate),
ownerMarker: "2026-07-01",
delegateMarker: "2026-06-02",
});
describe("GET /api/cycle/profile", () => {
it("resolves the gate against the record, not the caller", async () => {
// The delegate does not track a cycle. The owner does. A gate reading the
// caller's own gender and profile would refuse this request — the module
// decision belongs to the record.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCycle(owner.id, "2026-07-01");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/cycle/profile/route");
const response = await call(GET as Handler, "/api/cycle/profile");
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
cycleTrackingEnabled: true,
});
});
it("refuses when the RECORD does not track a cycle, whatever the caller does", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCycle(delegate.id, "2026-06-02");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/cycle/profile/route");
const response = await call(GET as Handler, "/api/cycle/profile");
expect(response.status).toBe(403);
});
it("refuses the next request after the grant is revoked", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCycle(owner.id, "2026-07-01");
const { GET } = await import("@/app/api/cycle/profile/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect((await call(GET as Handler, "/api/cycle/profile")).status).toBe(200);
await revokeGrant(grant.id);
await expectDenied(await call(GET as Handler, "/api/cycle/profile"));
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedCycle(plain.id, "2026-07-01");
await signIn(plain.id);
const { GET } = await import("@/app/api/cycle/profile/route");
const response = await call(GET as Handler, "/api/cycle/profile");
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
cycleTrackingEnabled: true,
});
});
});
describe("GET /api/cycle/day-logs and /api/cycle/symptoms/custom", () => {
it("read the owner's rows and refuse once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCycle(owner.id, "2026-07-01");
await seedCycle(delegate.id, "2026-06-02");
await getPrismaClient().cycleDayLog.create({
data: { userId: owner.id, date: "2026-07-03", basalBodyTempC: 36.7 },
});
await getPrismaClient().cycleDayLog.create({
data: { userId: delegate.id, date: "2026-07-03", basalBodyTempC: 36.1 },
});
const logs = await import("@/app/api/cycle/day-logs/route");
const symptoms = await import("@/app/api/cycle/symptoms/custom/route");
const { grant } = await switchInto(owner.id, delegate.id);
const log = await call(
logs.GET as Handler,
"/api/cycle/day-logs?date=2026-07-03",
);
expect(log.status).toBe(200);
expect((await body(log)).data).toMatchObject({ basalBodyTempC: 36.7 });
expect(
(await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom"))
.status,
).toBe(200);
await revokeGrant(grant.id);
await expectDenied(
await call(logs.GET as Handler, "/api/cycle/day-logs?date=2026-07-03"),
);
await expectDenied(
await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom"),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedCycle(plain.id, "2026-07-01");
await getPrismaClient().cycleDayLog.create({
data: { userId: plain.id, date: "2026-07-03", basalBodyTempC: 36.4 },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/cycle/day-logs/route");
const response = await call(
GET as Handler,
"/api/cycle/day-logs?date=2026-07-03",
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
basalBodyTempC: 36.4,
});
});
});
/* -------------------------------------------------------------------------- */
/* Medications */
/* -------------------------------------------------------------------------- */
async function seedMedication(userId: string, name: string) {
const medication = await getPrismaClient().medication.create({
data: { userId, name, dose: "10mg" },
});
await getPrismaClient().medicationSchedule.create({
data: {
medicationId: medication.id,
windowStart: "08:00",
windowEnd: "10:00",
},
});
await getPrismaClient().medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor: new Date("2026-07-14T08:00:00Z"),
takenAt: new Date("2026-07-14T08:12:00Z"),
},
});
}
contract<string>("GET /api/medications", {
call: async () => {
const { GET } = await import("@/app/api/medications/route");
return call(GET as Handler, "/api/medications");
},
seed: seedMedication,
read: (payload: { name: string }[]) => payload.map((m) => m.name),
ownerMarker: "owner-statin",
delegateMarker: "delegate-statin",
});
/** Today's dose, so the `scope=today` window actually contains it. */
async function seedTodaysDose(userId: string, name: string) {
const medication = await getPrismaClient().medication.create({
data: { userId, name, dose: "10mg" },
});
await getPrismaClient().medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor: new Date(),
},
});
return medication;
}
describe("GET /api/medications/intake", () => {
async function today(): Promise<Response> {
const { GET } = await import("@/app/api/medications/intake/route");
return call(GET as Handler, "/api/medications/intake?scope=today");
}
it("lists the owner's doses and none of the delegate's", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const ownerMed = await seedTodaysDose(owner.id, "owner-statin");
const delegateMed = await seedTodaysDose(delegate.id, "delegate-statin");
const { grant } = await switchInto(owner.id, delegate.id);
const response = await today();
expect(response.status).toBe(200);
const ids = ((await body(response)).data as { medicationId: string }[]).map(
(e) => e.medicationId,
);
expect(ids).toContain(ownerMed.id);
expect(ids).not.toContain(delegateMed.id);
await revokeGrant(grant.id);
await expectDenied(await today());
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
await seedTodaysDose(owner.id, "owner-statin");
const session = await signIn(stranger.id);
await switchSessionTo(session.id, owner.id);
await expectDenied(await today());
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
const med = await seedTodaysDose(plain.id, "plain-statin");
await signIn(plain.id);
const response = await today();
expect(response.status).toBe(200);
const ids = ((await body(response)).data as { medicationId: string }[]).map(
(e) => e.medicationId,
);
expect(ids).toContain(med.id);
});
});
/**
* The GLP-1 read, against the section that opens it.
*
* Its own block because the asymmetry it closes was invisible to every other
* leg in this file: the POST on this path has resolved the record since
* v1.37.0 and the GET beside it resolved the CALLER, so a manager could add a
* titration step to a medication whose history the same request path refused
* them. May write, may not read — a shape no reading of the sharing model
* produces, and one nothing here would have caught, because a route that
* refuses every switched caller looks exactly like a route nobody classified.
*
* Both legs are evidence rather than verdict. The positive one asserts the
* OWNER's dose value comes back and the delegate's own does not, so a handler
* that returned everybody's rows fails it; the negative one asserts the
* refusal code and not merely a 403, so the pre-fix `sharing.not_permitted`
* (which is also a 403) fails it too.
*/
describe("GET /api/medications/[id]/glp1 — the read follows the section", () => {
/** A live accepted grant scoped to the named sections, minted the shipped way. */
async function switchIntoScoped(
ownerId: string,
delegateId: string,
scope: ("medications" | "labs")[],
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegateId,
access: "READ",
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
const session = await signIn(delegateId);
await switchSessionTo(session.id, ownerId);
}
async function seedTitration(userId: string, doseValue: number) {
const medication = await getPrismaClient().medication.create({
data: { userId, name: `glp1-${doseValue}`, dose: `${doseValue}mg` },
});
await getPrismaClient().medicationDoseChange.create({
data: {
medicationId: medication.id,
effectiveFrom: new Date("2026-07-14T00:00:00Z"),
doseValue,
doseUnit: "mg",
},
});
return medication;
}
async function readGlp1(medicationId: string) {
const { GET } = await import("@/app/api/medications/[id]/glp1/route");
return call(GET as Handler, `/api/medications/${medicationId}/glp1`, {
id: medicationId,
});
}
it("serves the owner's titration history to a delegate scoped to medications", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const ownerMed = await seedTitration(owner.id, 1.5);
const delegateMed = await seedTitration(delegate.id, 7.5);
await switchIntoScoped(owner.id, delegate.id, ["medications"]);
const response = await readGlp1(ownerMed.id);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
doseChanges: { doseValue: number }[];
};
expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([1.5]);
// The delegate's own medication is seeded and must stay out of reach:
// the ownership guard resolves against the RECORD, not the caller, so
// their own row is the one that 404s while they are switched in.
expect((await readGlp1(delegateMed.id)).status).toBe(404);
});
it("refuses the same delegate when the grant does not name medications", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const ownerMed = await seedTitration(owner.id, 1.5);
await switchIntoScoped(owner.id, delegate.id, ["labs"]);
// The code, not the status: before the read resolved the record it also
// answered 403 here, with `sharing.not_permitted`, and a status-only
// assertion would have passed against the defect.
await expectDenied(await readGlp1(ownerMed.id));
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
const med = await seedTitration(plain.id, 2.5);
await signIn(plain.id);
const response = await readGlp1(med.id);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
doseChanges: { doseValue: number }[];
};
expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([2.5]);
});
});
describe("the per-medication reads", () => {
it("resolve the ownership guard against the record", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMedication(owner.id, "owner-statin");
await seedMedication(delegate.id, "delegate-statin");
const ownerMed = await getPrismaClient().medication.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateMed = await getPrismaClient().medication.findFirstOrThrow({
where: { userId: delegate.id },
});
const detail = await import("@/app/api/medications/[id]/route");
const cadence = await import("@/app/api/medications/[id]/cadence/route");
const revisions =
await import("@/app/api/medications/[id]/schedule-revisions/route");
const sideEffects =
await import("@/app/api/medications/[id]/side-effects/route");
const inventory =
await import("@/app/api/medications/[id]/inventory/route");
const phaseConfig =
await import("@/app/api/medications/[id]/phase-config/route");
const intake = await import("@/app/api/medications/[id]/intake/route");
const glp1 = await import("@/app/api/medications/[id]/glp1/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
detail.GET as Handler,
`/api/medications/${ownerMed.id}`,
{ id: ownerMed.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ name: "owner-statin" });
// Every sibling reaches the owner's medication...
for (const route of [
cadence.GET,
revisions.GET,
sideEffects.GET,
inventory.GET,
phaseConfig.GET,
intake.GET,
glp1.GET,
]) {
const response = await call(
route as Handler,
`/api/medications/${ownerMed.id}/sub`,
{ id: ownerMed.id },
);
expect(response.status).toBe(200);
}
// ...and none of them reaches the delegate's own, because
// `assertMedicationOwnership` now asks about the record.
for (const route of [
detail.GET,
cadence.GET,
revisions.GET,
sideEffects.GET,
inventory.GET,
phaseConfig.GET,
intake.GET,
glp1.GET,
]) {
const response = await call(
route as Handler,
`/api/medications/${delegateMed.id}/sub`,
{ id: delegateMed.id },
);
expect(response.status).toBe(404);
}
await revokeGrant(grant.id);
for (const route of [detail.GET, cadence.GET, intake.GET, glp1.GET]) {
await expectDenied(
await call(route as Handler, `/api/medications/${ownerMed.id}/sub`, {
id: ownerMed.id,
}),
);
}
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedMedication(plain.id, "plain-statin");
const med = await getPrismaClient().medication.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const detail = await import("@/app/api/medications/[id]/route");
const response = await call(
detail.GET as Handler,
`/api/medications/${med.id}`,
{ id: med.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
name: "plain-statin",
});
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
await seedMedication(owner.id, "owner-statin");
const med = await getPrismaClient().medication.findFirstOrThrow({
where: { userId: owner.id },
});
const session = await signIn(stranger.id);
await switchSessionTo(session.id, owner.id);
const detail = await import("@/app/api/medications/[id]/route");
await expectDenied(
await call(detail.GET as Handler, `/api/medications/${med.id}`, {
id: med.id,
}),
);
});
});
describe("GET /api/medications/compliance — the bucket follows the actor", () => {
it("spends the delegate's quota, not the owner's", async () => {
// The one delegable read that costs a quota. If the bucket keyed on the
// resolved user, a delegate could lock the owner out of their own
// cabinet, and could collect a fresh allowance by switching records. The
// rate_limits row is read from the table rather than inferred from a 429,
// because a key that is merely wrong still produces a 200.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMedication(owner.id, "owner-statin");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/medications/compliance/route");
const response = await call(GET as Handler, "/api/medications/compliance");
expect(response.status).toBe(200);
const keys = (
await getPrismaClient().rateLimit.findMany({
where: { key: { startsWith: "medication-compliance-summary:" } },
})
).map((row) => row.key);
expect(keys).toEqual([`medication-compliance-summary:${delegate.id}`]);
expect(keys).not.toContain(`medication-compliance-summary:${owner.id}`);
});
it("still keys on the caller when nobody has switched", async () => {
const plain = await makeUser("plain");
await seedMedication(plain.id, "plain-statin");
await signIn(plain.id);
const { GET } = await import("@/app/api/medications/compliance/route");
expect(
(await call(GET as Handler, "/api/medications/compliance")).status,
).toBe(200);
const keys = (
await getPrismaClient().rateLimit.findMany({
where: { key: { startsWith: "medication-compliance-summary:" } },
})
).map((row) => row.key);
expect(keys).toEqual([`medication-compliance-summary:${plain.id}`]);
});
it("refuses the next request after the grant is revoked", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMedication(owner.id, "owner-statin");
const { GET } = await import("@/app/api/medications/compliance/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect(
(await call(GET as Handler, "/api/medications/compliance")).status,
).toBe(200);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, "/api/medications/compliance"),
);
});
});
/* -------------------------------------------------------------------------- */
/* The document vault */
/* -------------------------------------------------------------------------- */
/**
* v1.36.x — `/documents` carries `sharedRecord: true`, so the shell offered
* the destination under a switch while every read behind it refused: the vault
* painted a query-error card, and the illness page's episode-documents tile
* painted another beside it. A clinical letter is health data belonging to the
* record, and reading it is much of the point of handing somebody the record.
*
* Five GET arms moved. The upload, the retype, the delete, the episode link
* and every AI verb did not, and `sharing-surface-guard.test.ts` is what
* freezes that split — this file asserts the moved arms actually substitute.
*/
async function enableDocuments(userId: string, enabled = true) {
await getPrismaClient().user.update({
where: { id: userId },
data: { modulePreferencesJson: { inboundDocuments: enabled } },
});
}
async function seedDocument(userId: string, title: string) {
const { encryptDocumentContent } = await import("@/lib/documents/store");
const { content, codec } = encryptDocumentContent(
Buffer.from(`${title} body`, "utf8"),
);
return getPrismaClient().inboundDocument.create({
data: {
userId,
title,
filename: `${title}.pdf`,
mimeType: "application/pdf",
byteSize: 11,
contentEncrypted: content,
contentCodec: codec,
},
});
}
describe("GET /api/documents/inbound", () => {
it("lists the owner's documents to a delegate, never the delegate's own", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
await enableDocuments(delegate.id);
await seedDocument(owner.id, "owner-letter");
await seedDocument(delegate.id, "delegate-letter");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/documents/inbound/route");
const response = await call(GET as Handler, "/api/documents/inbound");
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
documents: { title: string | null }[];
};
// Both named, separately: an empty list satisfies "the delegate's rows are
// absent" while hiding the failure this whole feature is about.
expect(payload.documents.map((d) => d.title)).toEqual(["owner-letter"]);
});
it("follows the OWNER's module switch, not the delegate's", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id, false);
await enableDocuments(delegate.id, true);
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/documents/inbound/route");
const response = await call(GET as Handler, "/api/documents/inbound");
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("module.disabled");
});
it("refuses the next request after the grant is revoked", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
await seedDocument(owner.id, "owner-letter");
const { GET } = await import("@/app/api/documents/inbound/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect((await call(GET as Handler, "/api/documents/inbound")).status).toBe(
200,
);
await revokeGrant(grant.id);
await expectDenied(await call(GET as Handler, "/api/documents/inbound"));
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await enableDocuments(plain.id);
await seedDocument(plain.id, "my-letter");
await signIn(plain.id);
const { GET } = await import("@/app/api/documents/inbound/route");
const response = await call(GET as Handler, "/api/documents/inbound");
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
documents: { title: string | null }[];
};
expect(payload.documents.map((d) => d.title)).toEqual(["my-letter"]);
});
it("refuses the upload arm beside it, at either grant level", async () => {
// The half that must NOT have moved. The module-level split is the whole
// reason the two arms declare separately, and a future edit that hoisted
// one resolver to the top of the file would make this go red rather than
// quietly admitting uploads.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
const grant = await getPrismaClient().accountGrant.create({
data: {
grantorId: owner.id,
granteeId: delegate.id,
access: "WRITE",
acceptedAt: new Date(),
},
});
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
expect(grant.access).toBe("WRITE");
const { POST } = await import("@/app/api/documents/inbound/route");
const form = new FormData();
form.set(
"file",
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "scan.pdf", {
type: "application/pdf",
}),
);
const response = await (
POST as unknown as (r: Request) => Promise<Response>
)(
new NextRequest("http://localhost/api/documents/inbound", {
method: "POST",
body: form,
}),
);
// The undeclared-mode refusal: the POST arm names no sharing mode, so the
// carrier is refused before any grant is read.
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe(
"sharing.not_permitted",
);
expect(
await getPrismaClient().inboundDocument.count({
where: { userId: owner.id },
}),
).toBe(0);
expect(
await getPrismaClient().inboundDocument.count({
where: { userId: delegate.id },
}),
).toBe(0);
});
});
describe("the per-document reads", () => {
it("serves the owner's document, its file and its thumbnail slot", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
const doc = await seedDocument(owner.id, "owner-letter");
// The delegate's own document, so a handler that fell back to the caller
// would resolve something rather than 404 and the assertion would be about
// absence instead of substitution.
const mine = await seedDocument(delegate.id, "delegate-letter");
await switchInto(owner.id, delegate.id);
const detail = await import("@/app/api/documents/inbound/[id]/route");
const detailResponse = await call(
detail.GET as Handler,
`/api/documents/inbound/${doc.id}`,
{ id: doc.id },
);
expect(detailResponse.status).toBe(200);
expect(
((await body(detailResponse)).data as { title: string | null }).title,
).toBe("owner-letter");
// The delegate's OWN document is unreachable from inside the record: the
// query is scoped to the resolved user, so their id resolves to nothing.
const mineResponse = await call(
detail.GET as Handler,
`/api/documents/inbound/${mine.id}`,
{ id: mine.id },
);
expect(mineResponse.status).toBe(404);
const original =
await import("@/app/api/documents/inbound/[id]/original/route");
const fileResponse = await call(
original.GET as Handler,
`/api/documents/inbound/${doc.id}/original`,
{ id: doc.id },
);
expect(fileResponse.status).toBe(200);
expect(await fileResponse.text()).toContain("owner-letter body");
});
it("keys the read quota on the ACTOR, not the record", async () => {
// The `medications/compliance` rule, applied to the two reads that spend
// decryption CPU: a delegate burns their own allowance rather than locking
// the owner out, and cannot collect a fresh one by switching records.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
const doc = await seedDocument(owner.id, "owner-letter");
await switchInto(owner.id, delegate.id);
const original =
await import("@/app/api/documents/inbound/[id]/original/route");
expect(
(
await call(
original.GET as Handler,
`/api/documents/inbound/${doc.id}/original`,
{ id: doc.id },
)
).status,
).toBe(200);
const keys = (
await getPrismaClient().rateLimit.findMany({
where: { key: { startsWith: "documents-original:" } },
})
).map((row) => row.key);
expect(keys).toEqual([`documents-original:${delegate.id}`]);
});
it("refuses the detail read once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
const doc = await seedDocument(owner.id, "owner-letter");
const { GET } = await import("@/app/api/documents/inbound/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect(
(
await call(GET as Handler, `/api/documents/inbound/${doc.id}`, {
id: doc.id,
})
).status,
).toBe(200);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/documents/inbound/${doc.id}`, {
id: doc.id,
}),
);
});
});
describe("GET /api/documents/inbound/usage", () => {
it("reports the record's vault, not the delegate's", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await enableDocuments(owner.id);
await seedDocument(owner.id, "owner-letter");
await seedDocument(delegate.id, "delegate-a");
await seedDocument(delegate.id, "delegate-b");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/documents/inbound/usage/route");
const response = await call(GET as Handler, "/api/documents/inbound/usage");
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
contentIndex: { totalCount: number };
};
// One, not two: the counts follow the record. Both accounts hold rows, so
// a zero here would be a different failure and this is not it.
expect(payload.contentIndex.totalCount).toBe(1);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await enableDocuments(plain.id);
await seedDocument(plain.id, "a");
await seedDocument(plain.id, "b");
await signIn(plain.id);
const { GET } = await import("@/app/api/documents/inbound/usage/route");
const response = await call(GET as Handler, "/api/documents/inbound/usage");
expect(response.status).toBe(200);
expect(
((await body(response)).data as { contentIndex: { totalCount: number } })
.contentIndex.totalCount,
).toBe(2);
});
});