diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts index c832344..d1d3638 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts @@ -34,7 +34,7 @@ vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({ if (name === "dn_review_events") return { getFullList: vi.fn(async () => state.events), create: vi.fn(async (event: Record) => { - if (state.auditCollision) { state.events.push({ id: "winner", event_key: event.event_key, batch: BATCH, note: NOTE, action: "post" }); throw new Error("unique event key"); } + if (state.auditCollision) { state.events.push({ id: "winner", event_key: event.event_key, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: DELIVERY } }); throw new Error("unique event key"); } if (state.auditFails) throw new Error("audit unavailable"); state.events.push(event); return { id: "event" }; }), }; @@ -137,6 +137,14 @@ describe("post delivery note route", () => { expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" }); }); + it("does not accept a same-note audit event pointing at another delivery note", async () => { + state.events = [{ id: "wrong-dn", event_key: `dn-post:${NOTE}`, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: "dn0000000000002" } }]; + const { POST } = await import("./route"); + const response = await POST(request(), context()); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" }); + }); + it("fails closed when a posted delivery note no longer matches its PO/site association", async () => { state.note = { ...state.note, status: "promoted", stage: "posted", delivery_note: DELIVERY }; state.delivery = { id: DELIVERY, batch_note: NOTE, batch: BATCH, site: "site00000000002", purchase_order: PO }; diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts index 13fbee4..b7ea7f6 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts @@ -195,9 +195,10 @@ type AuditResult = { ok: true } | { ok: false; error: "audit_unavailable" | "aud async function ensurePostedAudit(pb: Awaited>, batchId: string, noteId: string, deliveryNoteId: string): Promise { const eventKey = `dn-post:${noteId}`; - const identity = (event: Record) => relationId(event.batch) === batchId && relationId(event.note) === noteId && event.action === "post"; + const identity = (event: Record) => relationId(event.batch) === batchId && relationId(event.note) === noteId && event.action === "post" && + object(event.after) && relationId(event.after.delivery_note) === deliveryNoteId; try { - const events = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action" }) as Record[]; + const events = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action,after" }) as Record[]; if (events.length > 0) return identity(events[0]) ? { ok: true } : { ok: false, error: "audit_key_collision" }; try { await pb.collection("dn_review_events").create({ event_key: eventKey, batch: batchId, note: noteId, action: "post", before: { stage: "ready_to_post" }, after: { stage: "posted", delivery_note: deliveryNoteId }, reason: "candidate posted", actor: "web" }); @@ -205,7 +206,7 @@ async function ensurePostedAudit(pb: Awaited>, batchI } catch { // A concurrent writer may have won the deterministic key. Re-read and // only confirm success when the winner belongs to this batch/note. - const winner = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action" }) as Record[]; + const winner = await pb.collection("dn_review_events").getFullList({ filter: filter(pb, "event_key = {:eventKey}", { eventKey }), fields: "id,event_key,batch,note,action,after" }) as Record[]; if (winner.length > 0) return identity(winner[0]) ? { ok: true } : { ok: false, error: "audit_key_collision" }; return { ok: false, error: "audit_unavailable" }; }