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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:10:37 +02:00
1 parent 9e3cd7c430
commit 17c4e76165
3 files changed
+48 -50

No files matched your search

@@ -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" },
+24 -24
View File
@@ -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",
});