diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 7681bcfa3..532eb962a 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -210,6 +210,159 @@ function modulesNaming(files: readonly string[], symbol: string): string[] { return files.filter((rel) => namesSymbol(rel, symbol)); } +/* -------------------------------------------------------------------------- */ +/* The call matcher: which symbol is called, and with what */ +/* -------------------------------------------------------------------------- */ + +/** + * One call site, with its callee resolved back to the ORIGINAL exported name + * and its first argument, when that argument is a plain string literal. + * + * The membership matcher above answers "does this module name the symbol", + * which is the right question for an allowlist and the wrong one for the write + * leg: `requireRecordAuth("read")` and `requireRecordAuth("write")` name the + * same identifier and mean opposite things. So this walks call expressions and + * resolves the callee through the module's own import table — an aliased + * import (`requireRecordAuth as resolveRecord`) and a namespace-qualified call + * (`auth.requireRecordAuth`) both come back as `requireRecordAuth`, which is + * the property that makes "the write set equals this literal" hold against a + * module that renames its import. + */ +interface CallSite { + /** The original exported name, after alias resolution. */ + name: string; + /** The first argument when it is a string literal; null otherwise. */ + firstArg: string | null; +} + +function callSitesIn(source: string, fileName: string): CallSite[] { + const parsed = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + // local name -> original exported name, and the set of namespace imports. + const aliases = new Map(); + const namespaces = new Set(); + parsed.forEachChild((node) => { + if (!ts.isImportDeclaration(node)) return; + const bindings = node.importClause?.namedBindings; + if (!bindings) return; + if (ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text); + return; + } + for (const spec of bindings.elements) { + aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text); + } + }); + + const calls: CallSite[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + let name: string | null = null; + if (ts.isIdentifier(callee)) { + name = aliases.get(callee.text) ?? callee.text; + } else if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + namespaces.has(callee.expression.text) + ) { + name = callee.name.text; + } + if (name !== null) { + const first = node.arguments[0]; + calls.push({ + name, + firstArg: first && ts.isStringLiteralLike(first) ? first.text : null, + }); + } + } + node.forEachChild(walk); + }; + parsed.forEachChild(walk); + return calls; +} + +const callCache = new Map(); + +function callSites(rel: string): CallSite[] { + const cached = callCache.get(rel); + if (cached !== undefined) return cached; + const found = callSitesIn(read(rel), rel); + callCache.set(rel, found); + return found; +} + +/** + * Every call in this module that reaches the audit table through the Prisma + * client instead of through `auditLog()`. + * + * Matched structurally: a call whose callee is `.auditLog.`, + * read off the property-access chain rather than off the file's text, so a + * comment or a string discussing the old shape cannot produce a finding and a + * call broken across lines cannot hide one. The delegated actor column is + * stamped in exactly one place, so any client-level write against this table + * is a row filed with no actor. + */ +const AUDIT_WRITE_VERBS = new Set([ + "create", + "createMany", + "createManyAndReturn", + "upsert", + "update", + "updateMany", +]); + +function directAuditWritesIn(source: string, fileName: string): string[] { + const parsed = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const found: string[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + ts.isPropertyAccessExpression(callee) && + AUDIT_WRITE_VERBS.has(callee.name.text) && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === "auditLog" + ) { + found.push( + `${callee.expression.expression.getText()}.auditLog.${callee.name.text}`, + ); + } + } + node.forEachChild(walk); + }; + parsed.forEachChild(walk); + return found; +} + +/** Does this module call `symbol` anywhere, under any import name? */ +function callsSymbol(rel: string, symbol: string): boolean { + if (!read(rel).includes(symbol)) return false; + return callSites(rel).some((c) => c.name === symbol); +} + +/** The `GrantNeed` literals this module passes to the record resolver. */ +function recordNeeds(rel: string): Set { + if (!read(rel).includes(RECORD_RESOLVER)) return new Set(); + return new Set( + callSites(rel) + .filter((c) => c.name === RECORD_RESOLVER && c.firstArg !== null) + .map((c) => c.firstArg as string), + ); +} + /** Names of the top-level functions a module exports. */ function exportedFunctionNames(rel: string): string[] { const parsed = ts.createSourceFile( @@ -244,15 +397,18 @@ function exportedFunctionNames(rel: string): string[] { * route's description — the argument for why a delegate reading or writing * through it cannot extend their own reach. * - * Note what a member means: the MODULE reaches the resolver, and in every case - * below that is the GET arm alone. The write arms in these same files still - * resolve through `requireAuth()`, which refuses outright while a switch is - * active, so admitting a file here admits a read and nothing more. A delegable - * write is a separate decision that would have to be made at each of them. + * Note what a member means: the MODULE reaches the resolver. Until v1.36.x + * that was the GET arm alone in every case; eleven of these files now also + * reach it from a POST arm, and those eleven are frozen a second time in + * {@link DELEGABLE_WRITE_ROUTES} with their own leg below. Membership here is + * therefore necessary and not sufficient for a delegable write: a file can sit + * in this list and still refuse every mutation, which is what the other + * thirty-one do. Any write arm not named in the write literal is a diff two + * lists have to agree on. */ const DELEGABLE_ROUTES: Record = { "app/api/measurements/route.ts": - "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id` and offers no control affordance; the POST arms in the same file keep `requireAuth()` and so refuse under a switch.", + "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.", "app/api/measurements/[id]/route.ts": "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", "app/api/measurements/series/route.ts": @@ -264,11 +420,11 @@ const DELEGABLE_ROUTES: Record = { "app/api/measurement-reminders/[id]/route.ts": "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", "app/api/labs/route.ts": - "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.", + "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result is a delegable write; editing and deleting one are not.", "app/api/labs/[id]/route.ts": "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", "app/api/biomarkers/route.ts": - "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.", + "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", "app/api/biomarkers/[id]/route.ts": "One biomarker of the record, fetch-then-guard against the resolved user.", "app/api/allergies/route.ts": @@ -292,7 +448,7 @@ const DELEGABLE_ROUTES: Record = { "app/api/custom-metrics/[id]/route.ts": "One custom metric of the record, scoped by the resolved user id in the query itself.", "app/api/custom-metrics/[id]/entries/route.ts": - "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.", + "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query, on the read arm and on the delegable create arm alike.", "app/api/personal-records/route.ts": "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", "app/api/sleep/night/route.ts": @@ -304,7 +460,7 @@ const DELEGABLE_ROUTES: Record = { "app/api/nutrients/daily/route.ts": "One nutrient's daily series over the record, scoped by the resolved user id.", "app/api/illness/episodes/route.ts": - "The record's illness episodes. Module-gated on the record; the create arm keeps `requireAuth()`.", + "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.", "app/api/illness/episodes/[id]/route.ts": "One episode of the record, fetch-then-guard against the resolved user.", "app/api/illness/episodes/[id]/day-logs/route.ts": @@ -328,19 +484,68 @@ const DELEGABLE_ROUTES: Record = { "app/api/medications/[id]/schedule-revisions/route.ts": "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", "app/api/medications/[id]/side-effects/route.ts": - "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm, which is a delegable write and keys its bucket on the ACTOR for exactly that reason.", "app/api/medications/[id]/inventory/route.ts": "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", "app/api/medications/[id]/phase-config/route.ts": "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", "app/api/medications/[id]/intake/route.ts": - "One medication's intake history, behind the same ownership guard. The taking of a dose is a write and stays on `requireAuth()`.", + "One medication's intake history, behind the same ownership guard. Marking a dose is a delegable write, and the one that notifies the owner rather than only filing an audit row.", "app/api/medications/intake/route.ts": - "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to.", + "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to, and the canonical slot write on the POST arm is delegable.", "app/api/medications/compliance/route.ts": "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", }; +/** + * Route modules permitted to WRITE into a record that may not be the caller's. + * + * A strict subset of {@link DELEGABLE_ROUTES}, and the shorter list on purpose: + * a delegate adds to a record and never rewrites it. No delete, no edit, no + * restore, no import, no sync ingest sits here, and `PUT /api/medications/[id]` + * is deliberately absent — adding a medication is in, correcting one is not, + * including the one the delegate created ten seconds ago with a typo in the + * dose. Every member is a create. + * + * Membership is decided by the ARGUMENT, not by the identifier: a module joins + * this list when it passes `"write"` to `requireRecordAuth`. That is what the + * thirty-one read-only delegable modules do not do, and it is the difference + * the identifier matcher above cannot see. + * + * Every member also has to call `auditLog`, asserted below. That is not a + * stylistic preference. The decision not to add a `writtenBy` column to eleven + * tables rests entirely on the audit trail carrying the actor, and `auditLog` + * is the only writer that stamps `actorUserId` — a member that filed its rows + * through a bare `prisma.auditLog.create`, or filed none at all, would make a + * delegate's contribution indistinguishable from the owner's own. Asserting it + * here is what turns that decision from a thing nobody has forgotten yet into + * a property of the list. + */ +const DELEGABLE_WRITE_ROUTES: Record = { + "app/api/measurements/route.ts": + "Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution.", + "app/api/labs/route.ts": + "Entering a lab result. The free-text path may mint a biomarker, and mints it into the RECORD's catalogue — a result added to somebody's record that left the marker on the helper's account would be worse than useless to the owner.", + "app/api/biomarkers/route.ts": + "Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.", + "app/api/allergies/route.ts": + "Adding an allergy. The cleanest admission in the set: a plain statement about the record's own body, and the single most useful thing a caregiver can contribute.", + "app/api/family-history/route.ts": + "Adding a family-history entry. Third-party health data by design — the row describes the record's relatives — and it is stored as the record's own, which is exactly how the delegable read arm already serves it.", + "app/api/illness/episodes/route.ts": + "Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.", + "app/api/custom-metrics/[id]/entries/route.ts": + "Adding a value to one of the record's own metrics. The metric resolves under the record first, so a delegate can only feed a definition the owner made.", + "app/api/medications/[id]/side-effects/route.ts": + "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", + "app/api/medications/route.ts": + "Adding a medication with its nested schedule. The schedule is the point — a medication nobody scheduled reminds nobody of anything. The edit and delete verbs on the sibling route stay refused.", + "app/api/medications/intake/route.ts": + "Marking a dose, canonical slot form. The cross-device sync, the web-push dose-clear and the badge recount stay addressed to the OWNER, which is correct: it is their dose and their devices. The owner also gets told who marked it.", + "app/api/medications/[id]/intake/route.ts": + "Marking a dose, per-medication form. Same family, same ownership guard against the record, same notification to the owner; this arm has no snooze, so the state is always one of the two markings.", +}; + /** * Route modules that deliberately resolve the ACTOR and keep working under a * switch, because they serve the caller's own account and nothing of the @@ -360,11 +565,11 @@ const ACTOR_ROUTES: Record = { }; /** - * Entries across both lists. Pinned so that the reason-loop below cannot stay - * a formality by accident: every addition has to be counted here as well as - * listed above, which is one more place a careless admission has to pass. + * Entries across all three lists. Pinned so that the reason-loop below cannot + * stay a formality by accident: every addition has to be counted here as well + * as listed above, which is one more place a careless admission has to pass. */ -const FROZEN_ENTRY_COUNT = 46; +const FROZEN_ENTRY_COUNT = 57; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -477,6 +682,131 @@ describe("the matchers see what they look for", () => { expect(PROSE_ONLY_MODULE.includes("requireAdmin")).toBe(true); }); + it("reads the need argument through a direct, aliased and namespaced call", () => { + // The write leg turns on this and nothing else. `requireRecordAuth("read")` + // and `requireRecordAuth("write")` are the same identifier, so the + // identifier matcher above cannot tell a delegable read from a delegable + // write; if this matcher silently returned an empty set, the write leg + // would compare an empty set to an empty literal and agree with itself. + expect([...callSitesIn(DELEGABLE_MODULE, "d.ts")]).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "read", + }); + + const WRITE_MODULE = ` + import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; + + export const POST = apiHandler(async () => { + const { user } = await resolveRecord("write"); + await auditLog("thing.create", { userId: user.id }); + return apiSuccess({ id: user.id }); + }); + `; + const aliased = callSitesIn(WRITE_MODULE, "w.ts"); + // Resolved back through the import table: the call site itself reads + // `resolveRecord(`, and a matcher keyed on the written name would report + // this module as neither a read nor a write. + expect(aliased).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "write", + }); + expect(aliased.some((c) => c.name === "auditLog")).toBe(true); + + const NAMESPACED_WRITE = ` + import * as auth from "@/lib/api-handler"; + + export const POST = auth.apiHandler(async () => { + const { user } = await auth.requireRecordAuth("write"); + return apiSuccess({ id: user.id }); + }); + `; + expect(callSitesIn(NAMESPACED_WRITE, "nw.ts")).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "write", + }); + }); + + it("does not read a need out of prose or an unrelated string", () => { + // The word "write" appears twice as raw text here and neither occurrence + // is an argument to the resolver. A grep-shaped matcher enrols this file. + const NOT_A_WRITE = ` + /** This route is delegable for reads. It must never write. */ + import { requireRecordAuth } from "@/lib/api-handler"; + + const MODE = "write"; + + export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read"); + return apiSuccess({ id: user.id, mode: MODE }); + }); + `; + const sites = callSitesIn(NOT_A_WRITE, "nr.ts"); + // Non-zero: the parse produced call sites, so the negative below is a + // measurement rather than an empty walk. + expect(sites.length).toBeGreaterThan(0); + expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "read" }); + expect(sites).not.toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "write", + }); + expect(NOT_A_WRITE.includes('"write"')).toBe(true); + }); + + it("does not count an imported-but-uncalled auditLog", () => { + // Leg (e)'s audit assertion asks whether the module CALLS the helper. An + // import the module never reaches would satisfy an identifier matcher and + // stamp no actor on anything. + const IMPORTED_ONLY = ` + import { auditLog } from "@/lib/auth/audit"; + import { requireRecordAuth } from "@/lib/api-handler"; + + export const POST = apiHandler(async () => { + const { user } = await requireRecordAuth("write"); + return apiSuccess({ id: user.id, helper: auditLog.name }); + }); + `; + const sites = callSitesIn(IMPORTED_ONLY, "io.ts"); + expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "write" }); + expect(sites.some((c) => c.name === "auditLog")).toBe(false); + // …while the identifier matcher, which is the one this would have fooled, + // says yes. + expect(identifiersIn(IMPORTED_ONLY, "io.ts").has("auditLog")).toBe(true); + }); + + it("sees a client-level audit write, and not a comment about one", () => { + const OFFENDER = ` + import { prisma } from "@/lib/db"; + import { requireRecordAuth } from "@/lib/api-handler"; + + export const POST = apiHandler(async () => { + const { user } = await requireRecordAuth("write"); + prisma.auditLog + .create({ data: { userId: user.id, action: "thing.failed" } }) + .catch(() => {}); + return apiSuccess({ id: user.id }); + }); + `; + // Split across lines exactly as the shipped call sites were. A matcher + // demanding a literal `prisma.auditLog.create(` on one line finds nothing + // here — the class of bug this repository has already shipped twice. + expect(directAuditWritesIn(OFFENDER, "off.ts")).toEqual([ + "prisma.auditLog.create", + ]); + + const PROSE = ` + import { auditLog } from "@/lib/auth/audit"; + + export const POST = apiHandler(async () => { + // Through auditLog() rather than a bare prisma.auditLog.create, + // because only the helper stamps the actor. + await auditLog("thing.created", {}); + return apiSuccess({}); + }); + `; + expect(directAuditWritesIn(PROSE, "prose.ts")).toEqual([]); + expect(PROSE.includes("prisma.auditLog.create")).toBe(true); + }); + it("the co-occurrence rule fires on a module that does both", () => { // Leg (c) finds nothing on today's tree because no delegable module runs a // role check. This is the shape it exists to catch, proved against the @@ -597,6 +927,7 @@ describe("every frozen entry carries a reason", () => { it("names why the route may say what it says", () => { const entries = [ ...Object.entries(DELEGABLE_ROUTES), + ...Object.entries(DELEGABLE_WRITE_ROUTES), ...Object.entries(ACTOR_ROUTES), ]; @@ -680,6 +1011,91 @@ describe("(d) the out-of-band Bearer surfaces stay out of the switch", () => { ); }); +/* -------------------------------------------------------------------------- */ +/* (e) the delegable WRITE set */ +/* -------------------------------------------------------------------------- */ + +describe("(e) the delegable write set is frozen", () => { + it("equals the frozen write list", () => { + const scanned = routeModules(); + expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); + + const writing = scanned.filter((rel) => recordNeeds(rel).has("write")); + expect(writing).toEqual(Object.keys(DELEGABLE_WRITE_ROUTES).sort()); + }); + + it("is a strict subset of the delegable set", () => { + // A module cannot write into a record it may not read. The read list is + // where the argument for each file lives, so a write entry with no read + // entry would be an admission with no recorded reason. + for (const rel of Object.keys(DELEGABLE_WRITE_ROUTES)) { + expect(DELEGABLE_ROUTES, rel).toHaveProperty([rel]); + } + expect(Object.keys(DELEGABLE_WRITE_ROUTES).length).toBeLessThan( + Object.keys(DELEGABLE_ROUTES).length, + ); + }); + + it("leaves the rest of the delegable set reading only", () => { + // The non-zero half of the leg above, and the one that makes an empty + // matcher fail: the read-only remainder must be found, must be large, and + // must be found by the SAME matcher that decided the write set. If + // `recordNeeds` stopped returning anything, this drops to zero and fails + // rather than agreeing that nothing writes. + const readOnly = Object.keys(DELEGABLE_ROUTES).filter( + (rel) => !(rel in DELEGABLE_WRITE_ROUTES), + ); + expect(readOnly.length).toBeGreaterThan(20); + for (const rel of readOnly) { + const needs = recordNeeds(rel); + expect(needs.has("read"), `${rel} does not resolve a record read`).toBe( + true, + ); + expect(needs.has("write"), `${rel} writes without being listed`).toBe( + false, + ); + } + }); + + it("every write module files its rows through auditLog", () => { + // The condition of admission, enforced. `auditLog()` is the only writer + // that stamps `actorUserId`; a delegated write recorded without it is + // indistinguishable from the owner having done it themselves, and the + // decision NOT to add a provenance column to eleven tables was taken on + // the strength of this trail existing. + const modules = Object.keys(DELEGABLE_WRITE_ROUTES); + expect(modules.length).toBeGreaterThan(0); + + for (const rel of modules) { + expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( + true, + ); + } + }); + + it("no write module reaches the audit table without the helper", () => { + // The other half of the same property, and the failure this repository + // actually shipped: the helper is imported and used on the happy path + // while a validation-failure breadcrumb goes straight to the table, where + // it lands with `actorUserId` NULL — a delegate's malformed payload filed + // as if the owner had typed it. Eleven such calls existed across these + // files before the write migration. + // + // AST, not text, per this file's own doctrine: these modules explain in + // prose why they no longer call the client directly, and a comment saying + // so must not be the thing that trips the guard. + const modules = Object.keys(DELEGABLE_WRITE_ROUTES); + expect(modules.length).toBeGreaterThan(0); + + for (const rel of modules) { + expect( + directAuditWritesIn(read(rel), rel), + `${rel} writes an audit row without the helper`, + ).toEqual([]); + } + }); +}); + /* -------------------------------------------------------------------------- */ /* The counter-test: quiet on the innocent tree */ /* -------------------------------------------------------------------------- */ diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts new file mode 100644 index 000000000..ffd64238e --- /dev/null +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -0,0 +1,867 @@ +/** + * The eleven delegated writes, driven as routes, against real Postgres. + * + * Same construction as `sharing-delegable-routes.test.ts` and for the same + * reason: every case imports the shipped `POST` export and calls it, so the + * real `apiHandler`, the real `requireRecordAuth`, the real grant table and + * the real Prisma writes are what answer. A test that rebuilt a handler would + * be testing its own copy of a substitution that happens above the handler + * body, and would keep passing after the route stopped performing it. + * + * The grant is minted through `inviteGrant` + `acceptGrant` rather than + * hand-planted, because "a WRITE grant exists" and "a WRITE grant can be + * created by the invitation flow this release ships" are different claims and + * the second is the one a caregiver depends on. + * + * Five questions per verb: + * + * 1. a delegate with a live WRITE grant writes into the OWNER's record — + * the row lands under the owner and NOT under the delegate; + * 2. the same delegate under a READ grant is refused + * `sharing.access.denied`, and no row is written; + * 3. a caller who was never granted anything is refused the same way; + * 4. after revocation the very NEXT request is refused — no re-login, no + * cache window; + * 5. a caller who has not switched gets exactly what they got before. + * + * (5) is the regression that matters most. Eleven verbs changed; a person who + * shares nothing must not be able to tell. + * + * Beyond the five, three properties that are specific rather than structural: + * the audit row carries `userId = owner` AND `actorUserId = delegate` (the + * pipe, not just the ends — `actingActorFor` reads the wide-event context, so + * a stamped row proves the context was set); the side-effect rate bucket keys + * on the ACTOR; and both intake routes hand the OWNER and the ACTOR to the + * notification helper rather than one id twice. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** + * The one collaborator this file stubs, and only at the seam it is testing. + * + * What the helper DOES — the cascade, the preference row, the ledger, the + * self-refusal, the snooze refusal — is proved end to end against the real + * dispatcher in `delegated-intake-notification.test.ts`. What that file cannot + * see is whether the two intake routes hand it the owner and the actor the + * right way round, because the helper's own self-refusal makes an + * `ownerId === actorId` mix-up look exactly like a self-write: silent. So this + * file asserts the arguments the routes pass, which is the half of the pipe + * the other file leaves open. + */ +const notifyDelegatedIntake = vi.fn(async (_input: unknown) => {}); +vi.mock("@/lib/notifications/delegated-intake", () => ({ + notifyDelegatedIntake: (input: unknown) => notifyDelegatedIntake(input), +})); + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `dw-${suffix}`, + email: `dw-${suffix}@example.test`, + displayName: `DW ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** + * A live grant at the named level, accepted, with the delegate's session + * already inside the owner's record. + * + * Through `inviteGrant` / `acceptGrant` — the shipped transitions — so a + * release that could not actually mint a WRITE grant would fail here rather + * than pass against a row this file wrote itself. + */ +async function switchInto( + ownerId: string, + delegateId: string, + access: "READ" | "WRITE", +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: delegateId, + }); + expect(grant.access).toBe(access); + expect(grant.acceptedAt).not.toBeNull(); + + const session = await signIn(delegateId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); + return { grant, session }; +} + +/** A session inside a record nobody ever shared with this caller. */ +async function claimWithoutGrant(ownerId: string, callerId: string) { + const session = await signIn(callerId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); +} + +async function revoke(grantId: string, ownerId: string) { + const { revokeGrant } = await import("@/lib/sharing/grants"); + await revokeGrant({ grantId, grantorId: ownerId }); +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +function postRequest(url: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function post( + handler: Handler, + url: string, + body: unknown, + params: Record = {}, +): Promise { + return handler(postRequest(url, body), { params: Promise.resolve(params) }); +} + +async function payload(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 payload(response)).meta?.errorCode).toBe( + "sharing.access.denied", + ); +} + +const ISO = "2026-07-15T08:00:00.000Z"; + +/* -------------------------------------------------------------------------- */ +/* The shape of a delegated-write case */ +/* -------------------------------------------------------------------------- */ + +interface WriteCase { + /** Rows the write depends on, created under the record it will land in. */ + prepare?: (recordOwnerId: string) => Promise; + /** Drive the shipped POST export against the current session. */ + call: (ctx: C) => Promise; + /** The status a successful write answers with. */ + ok: number; + /** The action the happy path files through `auditLog`. */ + auditAction: string; + /** How many rows this verb has written under a given account. */ + count: (userId: string) => Promise; +} + +/** + * The five legs, run against one verb. + * + * `prepare` runs against whichever account the write is aimed at, so the + * delegated legs build their prerequisites under the OWNER — a delegate + * pointing at a metric or a medication of their own is a different test, and + * the read suite already pins that it 404s. + */ +function writeContract(name: string, c: WriteCase) { + const prepare = c.prepare ?? (async () => undefined as C); + + describe(name, () => { + it("writes into the owner's record under a live WRITE grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ctx = await prepare(owner.id); + + await switchInto(owner.id, delegate.id, "WRITE"); + const response = await c.call(ctx); + + expect(response.status).toBe(c.ok); + // Under the owner, and under nobody else. Asserting the owner's count + // alone would pass on a handler that wrote a row to each account. + expect(await c.count(owner.id)).toBe(1); + expect(await c.count(delegate.id)).toBe(0); + }); + + it("files the audit row under the owner and names the delegate as actor", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ctx = await prepare(owner.id); + + await switchInto(owner.id, delegate.id, "WRITE"); + expect((await c.call(ctx)).status).toBe(c.ok); + + const row = await getPrismaClient().auditLog.findFirst({ + where: { action: c.auditAction }, + orderBy: { createdAt: "desc" }, + }); + // The row exists at all — the audit assertion below is worthless + // against a null, and `toBe(undefined)` would pass on one. + expect(row, `no ${c.auditAction} row was written`).not.toBeNull(); + expect(row?.userId).toBe(owner.id); + expect(row?.actorUserId).toBe(delegate.id); + }); + + it("refuses the same delegate under a READ grant, and writes nothing", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ctx = await prepare(owner.id); + + await switchInto(owner.id, delegate.id, "READ"); + await expectDenied(await c.call(ctx)); + + expect(await c.count(owner.id)).toBe(0); + expect(await c.count(delegate.id)).toBe(0); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + const ctx = await prepare(owner.id); + + await claimWithoutGrant(owner.id, stranger.id); + await expectDenied(await c.call(ctx)); + + expect(await c.count(owner.id)).toBe(0); + expect(await c.count(stranger.id)).toBe(0); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ctx = await prepare(owner.id); + + const { grant } = await switchInto(owner.id, delegate.id, "WRITE"); + expect((await c.call(ctx)).status).toBe(c.ok); + + // No sign-out, no new session, no cache flush. The same browser, the + // next request. The refusal comes from the resolver, above the handler + // body, so replaying the identical request is the sharpest form of the + // question: the ONLY thing that changed is the grant. + await revoke(grant.id, owner.id); + await expectDenied(await c.call(ctx)); + expect(await c.count(owner.id)).toBe(1); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + const ctx = await prepare(plain.id); + await signIn(plain.id); + + const response = await c.call(ctx); + expect(response.status).toBe(c.ok); + expect(await c.count(plain.id)).toBe(1); + + // And the row is filed as their own act: `actorUserId` stays null, which + // is what every row written before the column existed already means. + const row = await getPrismaClient().auditLog.findFirst({ + where: { action: c.auditAction }, + orderBy: { createdAt: "desc" }, + }); + expect(row?.userId).toBe(plain.id); + expect(row?.actorUserId).toBeNull(); + }); + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + notifyDelegatedIntake.mockClear(); +}); + +/* -------------------------------------------------------------------------- */ +/* 1 — measurements, single and batch */ +/* -------------------------------------------------------------------------- */ + +const countMeasurements = (userId: string) => + getPrismaClient().measurement.count({ where: { userId } }); + +writeContract("POST /api/measurements — single", { + call: async () => { + const { POST } = await import("@/app/api/measurements/route"); + return post(POST as Handler, "/api/measurements", { + type: "WEIGHT", + value: 81, + measuredAt: ISO, + }); + }, + ok: 201, + auditAction: "measurement.create", + count: countMeasurements, +}); + +writeContract("POST /api/measurements — batch", { + call: async () => { + const { POST } = await import("@/app/api/measurements/route"); + return post(POST as Handler, "/api/measurements", [ + { type: "WEIGHT", value: 81, measuredAt: ISO }, + ]); + }, + ok: 201, + auditAction: "measurement.create.batch", + count: countMeasurements, +}); + +/* -------------------------------------------------------------------------- */ +/* 2 — labs, and the biomarker the free-text path mints */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/labs", { + call: async () => { + const { POST } = await import("@/app/api/labs/route"); + return post(POST as Handler, "/api/labs", { + analyte: "HbA1c", + value: 5.2, + unit: "mmol/L", + takenAt: ISO, + }); + }, + ok: 201, + auditAction: "labResult.create", + count: (userId) => getPrismaClient().labResult.count({ where: { userId } }), +}); + +describe("POST /api/labs — the marker the free-text path mints", () => { + it("joins the OWNER's catalogue, not the delegate's", async () => { + // A result added to somebody's record whose marker landed on the helper's + // account would leave the owner with a reading they cannot chart. The + // mint is a second write, under a second table, on the same request. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, "WRITE"); + + const { POST } = await import("@/app/api/labs/route"); + const response = await post(POST as Handler, "/api/labs", { + analyte: "Ferritin", + value: 120, + unit: "µg/L", + takenAt: ISO, + }); + expect(response.status).toBe(201); + + const markers = await getPrismaClient().biomarker.findMany({ + select: { userId: true, name: true }, + }); + expect(markers).toEqual([{ userId: owner.id, name: "Ferritin" }]); + + const result = await getPrismaClient().labResult.findFirstOrThrow({}); + expect(result.userId).toBe(owner.id); + expect(result.biomarkerId).not.toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 3 — biomarkers */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/biomarkers", { + call: async () => { + const { POST } = await import("@/app/api/biomarkers/route"); + return post(POST as Handler, "/api/biomarkers", { + name: "Ferritin", + unit: "µg/L", + }); + }, + ok: 201, + auditAction: "biomarker.create", + count: (userId) => getPrismaClient().biomarker.count({ where: { userId } }), +}); + +/* -------------------------------------------------------------------------- */ +/* 4 — allergies */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/allergies", { + call: async () => { + const { POST } = await import("@/app/api/allergies/route"); + return post(POST as Handler, "/api/allergies", { + substance: "Penicillin", + category: "MEDICATION", + severity: "SEVERE", + }); + }, + ok: 201, + auditAction: "allergy.create", + count: (userId) => getPrismaClient().allergy.count({ where: { userId } }), +}); + +/* -------------------------------------------------------------------------- */ +/* 5 — family history */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/family-history", { + call: async () => { + const { POST } = await import("@/app/api/family-history/route"); + return post(POST as Handler, "/api/family-history", { + relationship: "MOTHER", + condition: "Type 2 diabetes", + }); + }, + ok: 201, + auditAction: "family-history.create", + count: (userId) => + getPrismaClient().familyHistoryEntry.count({ where: { userId } }), +}); + +/* -------------------------------------------------------------------------- */ +/* 6 — illness episodes, and the module gate that follows the record */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/illness/episodes", { + call: async () => { + const { POST } = await import("@/app/api/illness/episodes/route"); + return post(POST as Handler, "/api/illness/episodes", { + label: "Influenza", + type: "INFECTION", + }); + }, + ok: 201, + auditAction: "illness.episode.create", + count: (userId) => + getPrismaClient().illnessEpisode.count({ where: { userId } }), +}); + +describe("POST /api/illness/episodes — the module gate", () => { + it("refuses when the OWNER switched illness tracking off, whatever the delegate set", async () => { + // The recorded decision: the module map governs which parts of a RECORD + // exist, so it is read off the record. A delegate who runs the module on + // their own account does not thereby gain an episode surface inside + // somebody else's. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { illness: false } }, + }); + await getPrismaClient().user.update({ + where: { id: delegate.id }, + data: { modulePreferencesJson: { illness: true } }, + }); + + await switchInto(owner.id, delegate.id, "WRITE"); + const { POST } = await import("@/app/api/illness/episodes/route"); + const response = await post(POST as Handler, "/api/illness/episodes", { + label: "Influenza", + type: "INFECTION", + }); + + expect(response.status).toBe(403); + expect((await payload(response)).meta?.errorCode).toBe("illness.disabled"); + expect( + await getPrismaClient().illnessEpisode.count({ + where: { userId: owner.id }, + }), + ).toBe(0); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 7 — custom-metric entries */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/custom-metrics/[id]/entries", { + prepare: async (recordOwnerId) => { + const metric = await getPrismaClient().customMetric.create({ + data: { userId: recordOwnerId, name: "Water", unit: "ml" }, + }); + return metric.id; + }, + call: async (metricId) => { + const { POST } = + await import("@/app/api/custom-metrics/[id]/entries/route"); + return post( + POST as Handler, + `/api/custom-metrics/${metricId}/entries`, + { value: 250, measuredAt: ISO }, + { id: metricId }, + ); + }, + ok: 201, + auditAction: "customMetricEntry.create", + count: (userId) => + getPrismaClient().customMetricEntry.count({ where: { userId } }), +}); + +/* -------------------------------------------------------------------------- */ +/* 8 — medication side effects, and the actor-keyed bucket */ +/* -------------------------------------------------------------------------- */ + +async function seedMedication(userId: string, name = "Statin") { + return getPrismaClient().medication.create({ + data: { userId, name, dose: "10mg" }, + }); +} + +writeContract("POST /api/medications/[id]/side-effects", { + prepare: async (recordOwnerId) => (await seedMedication(recordOwnerId)).id, + call: async (medicationId) => { + const { POST } = + await import("@/app/api/medications/[id]/side-effects/route"); + return post( + POST as Handler, + `/api/medications/${medicationId}/side-effects`, + { entry: "NAUSEA", severity: 3 }, + { id: medicationId }, + ); + }, + ok: 201, + auditAction: "medication.sideEffect.create", + count: (userId) => + getPrismaClient().medicationSideEffect.count({ where: { userId } }), +}); + +describe("POST /api/medications/[id]/side-effects — the bucket follows the actor", () => { + it("spends the delegate's allowance in ONE bucket across two records", async () => { + // The single condition on which this verb was admitted. On the record key + // a delegate could exhaust the owner's allowance and lock them out of + // their own log, and could collect a fresh one by switching records. The + // `rate_limits` rows are read from the table rather than inferred from a + // 429, because a key that is merely wrong still answers 201. + const first = await makeUser("owner"); + const second = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const firstMed = await seedMedication(first.id, "First"); + const secondMed = await seedMedication(second.id, "Second"); + + const { POST } = + await import("@/app/api/medications/[id]/side-effects/route"); + + await switchInto(first.id, delegate.id, "WRITE"); + expect( + ( + await post( + POST as Handler, + `/api/medications/${firstMed.id}/side-effects`, + { entry: "NAUSEA", severity: 3 }, + { id: firstMed.id }, + ) + ).status, + ).toBe(201); + + await switchInto(second.id, delegate.id, "WRITE"); + expect( + ( + await post( + POST as Handler, + `/api/medications/${secondMed.id}/side-effects`, + { entry: "NAUSEA", severity: 2 }, + { id: secondMed.id }, + ) + ).status, + ).toBe(201); + + const rows = await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-side-effect:post:" } }, + }); + // One bucket, the delegate's, holding both requests. Two buckets — one per + // record — is the failure this asserts against, and it looks identical + // from the response side. + expect(rows.map((r) => r.key)).toEqual([ + `medication-side-effect:post:${delegate.id}`, + ]); + expect(rows[0]?.count).toBe(2); + }); + + it("still keys on the caller when nobody has switched", async () => { + const plain = await makeUser("plain"); + const med = await seedMedication(plain.id); + await signIn(plain.id); + + const { POST } = + await import("@/app/api/medications/[id]/side-effects/route"); + expect( + ( + await post( + POST as Handler, + `/api/medications/${med.id}/side-effects`, + { entry: "NAUSEA", severity: 1 }, + { id: med.id }, + ) + ).status, + ).toBe(201); + + const rows = await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-side-effect:post:" } }, + }); + expect(rows.map((r) => r.key)).toEqual([ + `medication-side-effect:post:${plain.id}`, + ]); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 9 — adding a medication, with its nested schedule */ +/* -------------------------------------------------------------------------- */ + +writeContract("POST /api/medications", { + call: async () => { + const { POST } = await import("@/app/api/medications/route"); + return post(POST as Handler, "/api/medications", { + name: "Metformin", + dose: "500mg", + schedules: [{ windowStart: "08:00", windowEnd: "10:00" }], + }); + }, + ok: 201, + auditAction: "medication.create", + count: (userId) => getPrismaClient().medication.count({ where: { userId } }), +}); + +describe("POST /api/medications — the nested schedule", () => { + it("lands under the owner, because a medication nobody scheduled reminds nobody", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, "WRITE"); + + const { POST } = await import("@/app/api/medications/route"); + const response = await post(POST as Handler, "/api/medications", { + name: "Metformin", + dose: "500mg", + schedules: [{ windowStart: "08:00", windowEnd: "10:00" }], + }); + expect(response.status).toBe(201); + + const medication = await getPrismaClient().medication.findFirstOrThrow({ + include: { schedules: true }, + }); + expect(medication.userId).toBe(owner.id); + expect(medication.schedules).toHaveLength(1); + expect(medication.schedules[0]?.windowStart).toBe("08:00"); + // The schedule hangs off the medication rather than off a user id, so the + // only way it could reach the wrong account is through the medication — + // asserted above — but the count under the delegate is the cheap proof + // that nothing was mirrored. + expect( + await getPrismaClient().medication.count({ + where: { userId: delegate.id }, + }), + ).toBe(0); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 10 / 11 — marking a dose */ +/* -------------------------------------------------------------------------- */ + +/** A medication of `userId` with one pending slot on it. */ +async function seedPendingDose(userId: string) { + const medication = await seedMedication(userId, "Insulin"); + const event = await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date(), + }, + }); + return { medicationId: medication.id, intakeId: event.id }; +} + +writeContract<{ medicationId: string; intakeId: string }>( + "POST /api/medications/intake", + { + prepare: seedPendingDose, + call: async ({ intakeId }) => { + const { POST } = await import("@/app/api/medications/intake/route"); + return post(POST as Handler, "/api/medications/intake", { + intakeId, + status: "taken", + }); + }, + ok: 200, + auditAction: "medications.intake.update", + // The slot row is seeded, so what this counts is the marking: the number + // of the account's intake events that now carry a `takenAt`. + count: (userId) => + getPrismaClient().medicationIntakeEvent.count({ + where: { userId, takenAt: { not: null } }, + }), + }, +); + +writeContract<{ medicationId: string; intakeId: string }>( + "POST /api/medications/[id]/intake", + { + prepare: async (recordOwnerId) => ({ + medicationId: (await seedMedication(recordOwnerId, "Insulin")).id, + intakeId: "", + }), + call: async ({ medicationId }) => { + const { POST } = await import("@/app/api/medications/[id]/intake/route"); + return post( + POST as Handler, + `/api/medications/${medicationId}/intake`, + { takenAt: new Date().toISOString() }, + { id: medicationId }, + ); + }, + ok: 201, + auditAction: "medication.intake", + count: (userId) => + getPrismaClient().medicationIntakeEvent.count({ + where: { userId, takenAt: { not: null } }, + }), + }, +); + +describe("marking a dose — what the owner is told, and what the rollup recomputed", () => { + it("hands the OWNER and the ACTOR to the notification, not one id twice", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { medicationId, intakeId } = await seedPendingDose(owner.id); + await switchInto(owner.id, delegate.id, "WRITE"); + + const { POST } = await import("@/app/api/medications/intake/route"); + expect( + ( + await post(POST as Handler, "/api/medications/intake", { + intakeId, + status: "taken", + }) + ).status, + ).toBe(200); + + expect(notifyDelegatedIntake).toHaveBeenCalledTimes(1); + expect(notifyDelegatedIntake).toHaveBeenCalledWith({ + ownerId: owner.id, + actorId: delegate.id, + medicationId, + status: "taken", + }); + }); + + it("carries the skip through the per-medication arm", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const medication = await seedMedication(owner.id, "Insulin"); + await switchInto(owner.id, delegate.id, "WRITE"); + + const { POST } = await import("@/app/api/medications/[id]/intake/route"); + expect( + ( + await post( + POST as Handler, + `/api/medications/${medication.id}/intake`, + { skipped: true, scheduledFor: new Date().toISOString() }, + { id: medication.id }, + ) + ).status, + ).toBe(201); + + expect(notifyDelegatedIntake).toHaveBeenCalledWith({ + ownerId: owner.id, + actorId: delegate.id, + medicationId: medication.id, + status: "skipped", + }); + }); + + it("still calls it with one id when nobody has switched", async () => { + // The helper refuses on self, so this call is a no-op — but it has to be + // MADE with `actorId === ownerId`, or the refusal is being reached for the + // wrong reason. + const plain = await makeUser("plain"); + const { medicationId, intakeId } = await seedPendingDose(plain.id); + await signIn(plain.id); + + const { POST } = await import("@/app/api/medications/intake/route"); + expect( + ( + await post(POST as Handler, "/api/medications/intake", { + intakeId, + status: "taken", + }) + ).status, + ).toBe(200); + + expect(notifyDelegatedIntake).toHaveBeenCalledWith({ + ownerId: plain.id, + actorId: plain.id, + medicationId, + status: "taken", + }); + }); + + it("recomputes the compliance rollup under the OWNER's id", async () => { + // The dose landed in the owner's record; a rollup row keyed on the + // delegate would leave the owner's compliance reading stale and give the + // delegate a row about a medication they do not have. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { intakeId } = await seedPendingDose(owner.id); + await switchInto(owner.id, delegate.id, "WRITE"); + + const { POST } = await import("@/app/api/medications/intake/route"); + expect( + ( + await post(POST as Handler, "/api/medications/intake", { + intakeId, + status: "taken", + }) + ).status, + ).toBe(200); + + const rollups = await getPrismaClient().medicationComplianceRollup.findMany( + { select: { userId: true } }, + ); + expect(rollups.length).toBeGreaterThan(0); + expect([...new Set(rollups.map((r) => r.userId))]).toEqual([owner.id]); + }); +});