mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The per-family withholding of a link's label and date from a scoped delegate was proven on the detail read and not on the list read. The two resolve links through different functions — the list through the batched loadEncounterLinksForMany, added when links moved onto the list — and a batched read that dropped the redaction would leak a lab analyte and its date to a delegate the single read withholds them from, with no assertion on the detail path to notice. Three legs, mirroring the detail coverage against the list route: an entire-record delegate sees every name, a profile-only delegate sees the shape of all three links and the name of none, and a profile-plus-labs delegate sees the lab name and neither of the other two. The entire-record leg is the control that stops the redaction assertion passing against a read that redacts unconditionally.
437 lines
15 KiB
TypeScript
437 lines
15 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;
|
|
}
|
|
|
|
/**
|
|
* The SAME visit, read off the LIST rather than the detail.
|
|
*
|
|
* The list resolves links through `loadEncounterLinksForMany` — the batched
|
|
* arm of the same service, a distinct code path from the detail's
|
|
* `loadEncounterLinks`. It applies the same per-family redaction from the same
|
|
* predicate, and it has to be proven separately: a batched read that dropped
|
|
* the redaction would leak "Ferritin, 30 June" to a delegate the single read
|
|
* withholds it from, and no assertion on the detail path would notice.
|
|
*/
|
|
async function readListLinks(encounterId: string): Promise<Links> {
|
|
const { GET } = await import("@/app/api/encounters/route");
|
|
const res = await GET(
|
|
new Request("http://localhost/api/encounters") as never,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const parsed = (await res.json()) as {
|
|
data: {
|
|
upcoming: Array<{ id: string; links?: Links }>;
|
|
past: Array<{ id: string; links?: Links }>;
|
|
};
|
|
};
|
|
const row = [...parsed.data.upcoming, ...parsed.data.past].find(
|
|
(entry) => entry.id === encounterId,
|
|
);
|
|
expect(row, "the visit is in the list response").toBeDefined();
|
|
expect(
|
|
row!.links,
|
|
"the list carries the links, not only the detail",
|
|
).toBeDefined();
|
|
return row!.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("the list path redacts the same seam as the detail path", () => {
|
|
it("gives an entire-record delegate every name on the list", async () => {
|
|
await switchInto("READ", null);
|
|
const links = await readListLinks(seeded.encounter.id);
|
|
|
|
// The ordinary sharing case: the batched read must not withhold from a
|
|
// grant that covers everything. Without this leg, the redaction assertion
|
|
// below could pass against a batched read that redacts unconditionally.
|
|
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("withholds all three names from a profile-only delegate on the list", async () => {
|
|
await switchInto("READ", ["profile"]);
|
|
const links = await readListLinks(seeded.encounter.id);
|
|
|
|
// The shape survives — the card counts what the visit produced — and the
|
|
// names do not. This is the property the batched read has to hold on its
|
|
// own, because the card and the edit sheet both read links off the list.
|
|
expect(links.documents).toHaveLength(1);
|
|
expect(links.labResults).toHaveLength(1);
|
|
expect(links.conditions).toHaveLength(1);
|
|
|
|
for (const link of [
|
|
links.documents[0]!,
|
|
links.labResults[0]!,
|
|
links.conditions[0]!,
|
|
]) {
|
|
expect(link.label).toBeNull();
|
|
expect(link.date).toBeNull();
|
|
expect(link.redacted).toBe(true);
|
|
// The id is the caller's own link row and stays.
|
|
expect(link.id).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("reveals only the held domain on the list", async () => {
|
|
await switchInto("READ", ["profile", "labs"]);
|
|
const links = await readListLinks(seeded.encounter.id);
|
|
|
|
// Per-family on the batched path too: labs is held, the other two are not.
|
|
expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin");
|
|
expect(links.labResults[0]!.redacted).toBe(false);
|
|
expect(links.documents[0]!.label).toBeNull();
|
|
expect(links.documents[0]!.redacted).toBe(true);
|
|
expect(links.conditions[0]!.label).toBeNull();
|
|
expect(links.conditions[0]!.redacted).toBe(true);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|