From 17c4e7616513c0cfc8257b2cc5a7828f46e7af86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 12:10:37 +0200 Subject: [PATCH] Let a delegate enter a reading into a shared record Both POST arms of /api/measurements resolve the record rather than the caller. Every `userId: user.id` in the handler already meant "the record", so the bodies are untouched: the safety-floor check, the reminder satisfaction, the rollup recompute and the cache eviction are statements about the record and stay addressed to it. The two validation breadcrumbs move from a bare client create to auditLog(). That helper is the only writer that stamps the acting account, and a delegate's malformed payload filed without it reads as the owner's own typing. --- .../__tests__/arrival-wiring.test.ts | 20 ++++---- .../__tests__/zod-multi-issue.test.ts | 30 +++++------- src/app/api/measurements/route.ts | 48 +++++++++---------- 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/src/app/api/measurements/__tests__/arrival-wiring.test.ts b/src/app/api/measurements/__tests__/arrival-wiring.test.ts index 695b07d5b..524019570 100644 --- a/src/app/api/measurements/__tests__/arrival-wiring.test.ts +++ b/src/app/api/measurements/__tests__/arrival-wiring.test.ts @@ -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", () => ({ diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index 41c4a589a..10cca436b 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -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" }, diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 24eda8253..63b686c71 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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", });