From ff334ff10e0c77b51affa4e55287e54a39f5580f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 00:38:23 +0200 Subject: [PATCH] fix(meds): phase-config upsert builds the Prisma payload field-by-field Drop the parsed.data spread so a future schema extension cannot silently land on the Prisma write. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/phase-config/__tests__/route.test.ts | 61 +++++++++++++++++++ .../medications/[id]/phase-config/route.ts | 32 ++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts index 92010a61b..4e9046dd7 100644 --- a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts @@ -94,3 +94,64 @@ describe("PUT /api/medications/[id]/phase-config — F-1 H-3 multi-issue 422", ( } }); }); + +describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upsert", () => { + it("builds the create + update payloads field-by-field rather than spreading parsed.data", async () => { + const body = { + greenValue: 60, + greenMode: "MINUTES", + yellowValue: 30, + yellowMode: "MINUTES", + orangeValue: 0, + orangeMode: "MINUTES", + redValue: 240, + redMode: "MINUTES", + }; + await PUT(putReq(body), ROUTE_PARAMS); + + const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0]; + // create payload contains exactly the eight enumerated phase fields + // plus the `medicationId` route binding — nothing else. + expect(Object.keys(callArg.create).sort()).toEqual([ + "greenMode", + "greenValue", + "medicationId", + "orangeMode", + "orangeValue", + "redMode", + "redValue", + "yellowMode", + "yellowValue", + ]); + // update payload mirrors the eight phase fields (no `medicationId`). + expect(Object.keys(callArg.update).sort()).toEqual([ + "greenMode", + "greenValue", + "orangeMode", + "orangeValue", + "redMode", + "redValue", + "yellowMode", + "yellowValue", + ]); + }); + + it("ignores an unknown field smuggled in the body (no mass assignment)", async () => { + const body = { + greenValue: 60, + greenMode: "MINUTES", + yellowValue: 30, + yellowMode: "MINUTES", + orangeValue: 0, + orangeMode: "MINUTES", + redValue: 240, + redMode: "MINUTES", + // Unknown rogue field — should never reach the Prisma upsert. + maliciousField: "owned", + }; + await PUT(putReq(body), ROUTE_PARAMS); + const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0]; + expect((callArg.create as Record).maliciousField).toBeUndefined(); + expect((callArg.update as Record).maliciousField).toBeUndefined(); + }); +}); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index e64e87861..e4fbe243e 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -70,13 +70,35 @@ export const PUT = apiHandler( return returnAllZodIssues(parsed.error, 422); } + // v1.5.5 F-1 H-4 — build the Prisma payload field-by-field rather + // than spreading `parsed.data`. A future schema extension would + // otherwise land on the upsert silently; the explicit pick keeps + // the surface honest at the call site. + const { + greenValue, + greenMode, + yellowValue, + yellowMode, + orangeValue, + orangeMode, + redValue, + redMode, + } = parsed.data; + const phaseFields = { + greenValue, + greenMode, + yellowValue, + yellowMode, + orangeValue, + orangeMode, + redValue, + redMode, + }; + const config = await prisma.reminderPhaseConfig.upsert({ where: { medicationId: id }, - create: { - medicationId: id, - ...parsed.data, - }, - update: parsed.data, + create: { medicationId: id, ...phaseFields }, + update: phaseFields, }); annotate({