mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #708 from MBombeck/work/dw-d
A delegate can add to the record they were given
This commit is contained in:
19 files changed
+1508
-171
No files matched your search
@@ -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<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
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<string, CallSite[]>();
|
||||
|
||||
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 `<something>.auditLog.<verb>`,
|
||||
* 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<string> {
|
||||
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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -67,7 +67,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write, and the cleanest of them: the row is a plain
|
||||
// statement about the record's own body, and the caller appears in the audit
|
||||
// trail rather than in the row.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION = {
|
||||
@@ -53,6 +54,10 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.36.x — the create arm's validation breadcrumb goes through the
|
||||
// `auditLog` helper, the only writer that stamps `actorUserId`. The route
|
||||
// chains `.catch()` onto it, so the stub has to return a promise.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("biomarker create error contract", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -91,7 +91,11 @@ export const GET = apiHandler(async () => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
|
||||
|
||||
async function postBiomarker(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write: the marker joins the record's catalogue. The
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
@@ -107,17 +111,13 @@ async function postBiomarker(request: NextRequest) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.biomarker.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("labs.biomarker.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.biomarker.create.invalid",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -89,7 +89,11 @@ async function postCustomMetricEntry(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write. The ownership hop runs first and now resolves
|
||||
// against the record, so a delegate can only add a value to a metric the
|
||||
// OWNER defined; their own metric of the same name is unreachable from in
|
||||
// here and 404s.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
@@ -114,17 +118,13 @@ async function postCustomMetricEntry(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "custom-metric.entry.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, customMetricId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("custom-metric.entry.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, customMetricId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -62,7 +62,11 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write, and the one where third-party health data is
|
||||
// present by design: the row describes the record's relatives. It is stored
|
||||
// as the record's own, exactly as the delegable READ arm above already
|
||||
// serves it, and the audit trail names who entered it.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -74,7 +74,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEpisode));
|
||||
|
||||
async function postEpisode(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write. The module gate below follows the RECORD, not
|
||||
// the caller: whether illness tracking exists is a property of the record, so
|
||||
// a delegate who runs the module on their own account does not thereby gain
|
||||
// an episode surface inside somebody else's.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { PUT } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION = {
|
||||
@@ -93,6 +94,10 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.36.x — the create arm's validation breadcrumb goes through the
|
||||
// `auditLog` helper, the only writer that stamps `actorUserId`. The route
|
||||
// chains `.catch()` onto it, so the stub has to return a promise.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("lab result error contract", () => {
|
||||
|
||||
+15
-13
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -139,7 +139,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult));
|
||||
|
||||
async function postLabResult(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write. The free-text path may MINT a biomarker, and
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
@@ -157,17 +161,15 @@ async function postLabResult(request: NextRequest) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps
|
||||
// `actorUserId`. A bare `prisma.auditLog.create` files a delegate's
|
||||
// malformed payload as if the owner had typed it.
|
||||
void auditLog("labs.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.create.invalid",
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// v1.36.x — the create arms resolve a RECORD now. The stub returns the caller
|
||||
// as both the record and the actor, which is what the resolver itself returns
|
||||
// for anyone who has not switched; the substitution proper is proved against
|
||||
// real Postgres in `tests/integration/sharing-delegable-writes.test.ts`.
|
||||
const SELF = {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
})),
|
||||
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
|
||||
@@ -180,18 +180,17 @@ describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", (
|
||||
const res = await POST(postReq({ measuredAt: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.create.validation-failed");
|
||||
// v1.36.x — through the helper, same reason as the GET arm: the create
|
||||
// arms are delegable writes now, and only `auditLog` stamps the delegate.
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"measurements.create.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await POST(postReq({ measuredAt: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
@@ -259,20 +258,15 @@ describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", ()
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe(
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"measurements.create.batch.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -750,7 +750,12 @@ async function sleepListResponse(
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
|
||||
async function postMeasurement(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write. `user` is the record the reading belongs to,
|
||||
// which every `userId: user.id` below already meant; the caller is somebody
|
||||
// who may or may not be that person. Nothing else in this handler reads the
|
||||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||||
// recompute and the cache eviction are all statements about the record.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -777,17 +782,16 @@ async function postMeasurement(request: NextRequest) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.batch.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only thing that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a delegate's malformed batch would read as the owner's own.
|
||||
void auditLog("measurements.create.batch.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
@@ -934,17 +938,13 @@ async function postMeasurement(request: NextRequest) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — see the batch arm above: only `auditLog()` stamps the actor.
|
||||
void auditLog("measurements.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
|
||||
@@ -295,26 +295,21 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
|
||||
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
// v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the
|
||||
// only writer that stamps `actorUserId`); the POST's still writes the row
|
||||
// directly, because that arm cannot be reached under a switch.
|
||||
// v1.36.x — both breadcrumbs go through the `auditLog` helper, the only
|
||||
// writer that stamps `actorUserId`. The POST arm is a delegable write now,
|
||||
// so a row it files without the stamp would read as the owner's own.
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medications.intake.list.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||
const actions = vi
|
||||
.mocked(prisma.auditLog.create)
|
||||
.mock.calls.map(
|
||||
(c) => (c[0] as { data: { action: string } }).data.action,
|
||||
);
|
||||
expect(actions).toContain("medications.intake.create.validation-failed");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medications.intake.create.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
@@ -47,7 +48,10 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const { user, authMethod } = await requireAuth();
|
||||
// v1.36.x — a delegated write, the per-medication form of the same verb.
|
||||
// `user` is the record the dose belongs to; `actor` is whoever pressed the
|
||||
// button, and is used for nothing but the owner's notification below.
|
||||
const { user, actor, authMethod } = await requireRecordAuth("write");
|
||||
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
|
||||
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
|
||||
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
|
||||
@@ -92,20 +96,13 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.create.validation-failed",
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("medications.intake.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, medicationId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -533,6 +530,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// v1.36.x — "somebody else marked your dose". This route has no snooze arm,
|
||||
// so the state is whichever of the two markings the payload carried. The
|
||||
// helper refuses on self, so a person marking their own dose is unaffected.
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: user.id,
|
||||
actorId: actor.id,
|
||||
medicationId: id,
|
||||
status: skipped ? "skipped" : "taken",
|
||||
});
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,17 @@
|
||||
* occurredAt?, notes? }. Category is verified against the
|
||||
* authoritative entry → category mapping; mismatch → 422.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); the medication is verified
|
||||
* to belong to the caller before any read/write.
|
||||
* Rate-limit: 30/min/user on the POST path.
|
||||
* Auth: a session via requireRecordAuth(); the medication is verified to
|
||||
* belong to the RECORD before any read/write, so a delegate reaches the
|
||||
* owner's medications and none of their own.
|
||||
* Rate-limit: 30/min on the POST path, keyed on the ACTOR (see the call site).
|
||||
* Audit-log every mutation with the affected row id.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -97,17 +98,28 @@ export const GET = apiHandler(
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write. `user` is the record the entry lands under;
|
||||
// `actor` is whoever is typing, and the two differ only under a switch.
|
||||
const { user, actor } = await requireRecordAuth("write");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — 30/min comfortably absorbs a session
|
||||
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
|
||||
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
|
||||
// off automated abuse.
|
||||
//
|
||||
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
|
||||
// this is the one condition on which delegating 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 to
|
||||
// another record. On the actor key they burn their own, once, wherever
|
||||
// they are. `medications/compliance` set the same precedent on the read
|
||||
// side. `actor.id` equals `user.id` for everyone who has not switched, so
|
||||
// the bucket a self-writer lands in is byte-identical to before.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-side-effect:post:${user.id}`,
|
||||
`medication-side-effect:post:${actor.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -773,20 +773,18 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
const res = await POST(postReq({ status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe(
|
||||
// v1.36.x — through the helper, same reason as the GET arm above: the
|
||||
// POST is a delegable write and only `auditLog` stamps the delegate.
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medications.intake.update.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await POST(postReq({ status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "@/lib/validations/medication";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -217,7 +218,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write, the canonical slot form. Everything the
|
||||
// handler does downstream addresses the RECORD and stays correct: the
|
||||
// cross-device intake sync wakes the OWNER's iOS devices, the web-clear
|
||||
// closes the OWNER's pending dose reminder and rewrites the OWNER's badge,
|
||||
// and the compliance rollup recomputes the OWNER's day. None of them can
|
||||
// address the delegate, and none of them should — the delegate's feedback is
|
||||
// the response they are already awaiting. What the owner gets instead is the
|
||||
// notification at the end of this handler.
|
||||
const { user, actor } = await requireRecordAuth("write");
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -237,17 +246,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("medications.intake.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -551,5 +556,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
})();
|
||||
}
|
||||
|
||||
// v1.36.x — "somebody else marked your dose". The helper refuses on self and
|
||||
// on a snooze, resolves both names itself, and never throws; awaited because
|
||||
// the latency it adds lands on the caregiver's request and never on the
|
||||
// owner's own hot path.
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: user.id,
|
||||
actorId: actor.id,
|
||||
medicationId: existing.medicationId,
|
||||
status,
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -93,7 +93,12 @@ async function respondWithExistingMirror(
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a
|
||||
// medication with its whole nested schedule, and can never edit or delete
|
||||
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
|
||||
// on `requireAuth()` and refuse under a switch. Adding is help; rewriting
|
||||
// the owner's cabinet is not.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -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<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
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<string, string> = {},
|
||||
): Promise<Response> {
|
||||
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<C> {
|
||||
/** Rows the write depends on, created under the record it will land in. */
|
||||
prepare?: (recordOwnerId: string) => Promise<C>;
|
||||
/** Drive the shipped POST export against the current session. */
|
||||
call: (ctx: C) => Promise<Response>;
|
||||
/** 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<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<C>(name: string, c: WriteCase<C>) {
|
||||
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<string>("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<string>("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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user