Files
HealthLog/tests/integration/encounter-scoped-delegate.test.ts
T
Marc-André Bombeck 339e815527 Keep an appointment reminder inside the visit that owns it
Review of the visit work found the appointment arm leaking in both
directions: rows the visit routes could strand, and a reminder surface
that would act on one as though it were a checkup.

An edit could orphan a live reminder. Pointing a still-booked visit at
the checkup it closed walked the foreign key off the visit's own
reminder, and the next reschedule then minted a second one, because the
re-anchor path asked the key what the visit owned instead of asking the
engine. An orphan fires: the cron reads the reminder table, not the
visit list, so it pushed for an appointment nobody could see, edit or
cancel, and deleting the visit did not reach it. The edit arm now
refuses the contradiction the create arm already refused, ownership is
resolved through the relation, deletion retires the row it owns rather
than the row it points at, and a sweep at the end of every visit write
retires anything left armed that no visit claims -- so the state is
unreachable rather than merely unlikely.

The by-id reminder routes never excluded appointments, and that was the
larger hole. Marking one done committed the satisfy and THEN failed on
the mapper, so every appointment push a person acknowledged answered 500
after the write had landed. An edit could give a spent one-shot a
cadence and turn it into a permanent nudge that appears in no list, or
switch a cancelled one back on. The delete is a hard delete, so somebody
holding only the measurements domain could destroy a row belonging to a
visit in the health background. All five refuse in the lookup now -- an
appointment is not addressable there at all -- and the chat buttons that
mark a reminder done or postpone it refuse it the same way. Nor is an
appointment satisfied by a lab result: the auto-satisfy matcher used to
resolve any free-text reminder from any panel, which would have marked
next month's cardiology appointment attended.

A visit is a seam between four domains, and names were crossing it. The
values never did, but the title of a discharge letter is itself the
sensitive part, and a grant that opened only the health background was
never consent for it. Link labels and dates now follow the target's own
domain; the link is still listed, so a delegate learns the visit
produced a lab result without learning which analyte. Closing a checkup
follows the same rule: it re-anchors a preventive-care cadence, which is
a measurements write, so a health-background delegate files the visit
and the closure is reported as skipped rather than performed in silence.

Three smaller repairs. A checkup now closes at the visit's own instant
rather than at the moment it was typed in, which anchors a backdated
entry correctly and makes correcting a mis-filed visit the no-op it
should always have been. A window ending before today no longer widens
back out to today. And the fallback in a push notification was the raw
enum constant, so a lock screen could read a schema value; it reads the
record owner's own language now, in all six.
2026-08-08 11:24:08 +02:00

352 lines
11 KiB
TypeScript

/**
* What a NARROWLY scoped delegate may learn, and may do, through a visit.
*
* Visits sit in the health-background domain and link out to three others: the
* document vault, the lab results and the illness journal. That makes a visit a
* seam between domains, and a seam is where a scoped grant leaks.
*
* Two properties, both about somebody holding `profile` and nothing else:
*
* 1. **Names do not flow through the seam.** The values never did — the link
* list carries an id, a label and a date, not the document body or the
* analyte's result. But "Ferritin, 30 June" and "Oncology discharge
* letter" are themselves the sensitive part, and a grant that opened only
* the health background was never consent for them. A delegate without
* the target's domain gets the shape of the link and not its name.
* 2. **Closing a checkup needs the checkup's domain.** Satisfying one
* re-anchors a preventive-care cadence, which is a measurements-domain
* write. A profile-only delegate filing a visit still files it; the
* closure is skipped and SAID, rather than performed silently or refused
* with the whole request.
*
* The owner is checked in every case too, because a fix that reads "hide it
* from everyone" would satisfy half of these and break the product.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import type { ShareDomain } from "@/lib/sharing/scope";
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),
}));
const OWNER_ID = "scoped-visit-owner";
const DELEGATE_ID = "scoped-visit-delegate";
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: daysFromNow(1) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
/** Sign in as the delegate and switch into the owner's record. */
async function switchInto(
access: "READ" | "WRITE" | "MANAGE",
scope: ShareDomain[] | null,
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access,
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await signIn(DELEGATE_ID);
await switchSessionTo(session.id, OWNER_ID);
}
async function seedRecord() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `scoped-visit-${name}`,
email: `scoped-visit-${name}@example.test`,
timezone: "Europe/Berlin",
},
});
}
const document = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "LAB_RESULT",
title: "Oncology discharge letter",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: daysFromNow(-3),
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "Ferritin",
panel: "Iron panel",
value: 91,
unit: "ng/mL",
takenAt: daysFromNow(-3),
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Hashimoto thyroiditis",
type: "AUTOIMMUNE",
onsetAt: daysFromNow(-200),
},
});
const encounter = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-2),
status: "DONE",
kind: "SPECIALIST",
},
});
await prisma.encounterDocumentLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
documentId: document.id,
},
});
await prisma.encounterLabLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
labResultId: labResult.id,
},
});
await prisma.encounterConditionLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
episodeId: episode.id,
},
});
return { document, labResult, episode, encounter };
}
type Link = {
id: string;
label: string | null;
date: string | null;
redacted: boolean;
};
type Links = {
documents: Link[];
labResults: Link[];
conditions: Link[];
};
async function readLinks(encounterId: string): Promise<Links> {
const { GET } = await import("@/app/api/encounters/[id]/route");
const res = await GET(
new Request(`http://localhost/api/encounters/${encounterId}`) as never,
{ params: Promise.resolve({ id: encounterId }) } as never,
);
expect(res.status).toBe(200);
const parsed = (await res.json()) as { data: { links: Links } };
return parsed.data.links;
}
let seeded: Awaited<ReturnType<typeof seedRecord>>;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
seeded = await seedRecord();
});
describe("link labels follow the target's own domain", () => {
it("gives the owner every name", async () => {
await signIn(OWNER_ID);
const links = await readLinks(seeded.encounter.id);
expect(links.documents[0]!.label).toBe("Oncology discharge letter");
expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin");
expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis");
expect(links.documents[0]!.redacted).toBe(false);
});
it("gives an entire-record delegate every name", async () => {
await switchInto("READ", null);
const links = await readLinks(seeded.encounter.id);
// The ordinary sharing case must not change.
expect(links.documents[0]!.label).toBe("Oncology discharge letter");
expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin");
expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis");
});
it("withholds all three names from a profile-only delegate", async () => {
await switchInto("READ", ["profile"]);
const links = await readLinks(seeded.encounter.id);
// The links still exist — the visit produced three things and saying so is
// within the grant. What they are called is not.
expect(links.documents).toHaveLength(1);
expect(links.labResults).toHaveLength(1);
expect(links.conditions).toHaveLength(1);
// Null rather than an invented placeholder: the server is not telling
// this caller what the thing is called, and inventing a name here would
// make an absence look like a value. The array the entry sits in is the
// kind, so the client still renders "a linked lab result" from its own
// bundle without the server shipping English down the wire.
for (const link of [
links.documents[0]!,
links.labResults[0]!,
links.conditions[0]!,
]) {
expect(link.label).toBeNull();
expect(link.redacted).toBe(true);
// The date is the other half of the identification and goes with the name.
expect(link.date).toBeNull();
// The id stays: it is already the caller's own link row.
expect(link.id).toBeTruthy();
}
});
it("reveals only the domain the delegate actually holds", async () => {
await switchInto("READ", ["profile", "labs"]);
const links = await readLinks(seeded.encounter.id);
// The point of doing this per target rather than all-or-nothing.
expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin");
expect(links.labResults[0]!.redacted).toBe(false);
expect(links.documents[0]!.label).toBeNull();
expect(links.conditions[0]!.label).toBeNull();
});
});
describe("closing a checkup needs the checkup's own domain", () => {
async function seedCheckup() {
return getPrismaClient().measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Annual blood panel",
intervalDays: 365,
anchorDate: daysFromNow(-370),
notifyHour: 9,
nextDueAt: daysFromNow(-5),
},
});
}
async function fileVisit(reminderId: string) {
const { POST } = await import("@/app/api/encounters/route");
return POST(
new Request("http://localhost/api/encounters", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
occurredAt: daysFromNow(-1).toISOString(),
status: "DONE",
reminderId,
}),
}) as never,
);
}
it("closes it for the owner", async () => {
const checkup = await seedCheckup();
await signIn(OWNER_ID);
expect((await fileVisit(checkup.id)).status).toBe(201);
expect(
(
await getPrismaClient().measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
})
).lastSatisfiedAt,
).not.toBeNull();
});
it("files the visit but skips the closure for a profile-only delegate", async () => {
const checkup = await seedCheckup();
await switchInto("WRITE", ["profile"]);
const res = await fileVisit(checkup.id);
// The visit is theirs to file; refusing the whole request over a side
// effect they cannot perform would lose the record for no gain.
expect(res.status).toBe(201);
const parsed = (await res.json()) as {
data: {
skipped: { links: number; catalogueKeys: Array<{ key: string }> };
};
};
// Skipped, and SAID. Silence here is the failure mode: the person would
// believe a checkup was closed that is still due.
expect(parsed.data.skipped.links).toBe(1);
expect(parsed.data.skipped.catalogueKeys[0]!.key).toBe(checkup.id);
expect(
(
await getPrismaClient().measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
})
).lastSatisfiedAt,
).toBeNull();
expect(await getPrismaClient().encounter.count()).toBe(2);
});
it("closes it for a delegate who holds measurements too", async () => {
const checkup = await seedCheckup();
await switchInto("WRITE", ["profile", "measurements"]);
const res = await fileVisit(checkup.id);
expect(res.status).toBe(201);
const parsed = (await res.json()) as {
data: { skipped: { links: number } };
};
expect(parsed.data.skipped.links).toBe(0);
expect(
(
await getPrismaClient().measurementReminder.findUniqueOrThrow({
where: { id: checkup.id },
})
).lastSatisfiedAt,
).not.toBeNull();
});
});