From 1514d514b3e276202dea8a8fbc398caf1bf4fe2b Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 19:30:59 +0200 Subject: [PATCH] fix(dn): validate inbox 3 line edits Co-Authored-By: MiniMax (Pinchy) --- .../notes/[noteId]/lines/route.test.ts | 14 +++++++++++ .../[batchId]/notes/[noteId]/lines/route.ts | 13 +++++++---- src/lib/inbox-three/line-rows.ts | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts index 6f59bce..2e73f98 100644 --- a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts +++ b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts @@ -74,6 +74,20 @@ it("books a quantity over the order only when the office ticks it", async () => expect(accepted.status).toBe(200); }); +it("refuses edits it cannot read", async () => { + const res = await POST(req({ edits: [{ ...edit, lines: [] }], snapshotHash: p.snapshotHash, decisionHash: decisionHash(p) }), params); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_edits"); + expect(m.batch).not.toHaveBeenCalled(); +}); + +it("refuses an edit that carries something it does not understand", async () => { + const res = await POST(req({ edits: [{ ...edit, post_now: true }], snapshotHash: p.snapshotHash, decisionHash: decisionHash(p) }), params); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_edits"); + expect(m.batch).not.toHaveBeenCalled(); +}); + it("never reaches for the posting function", async () => { const source = await import("node:fs/promises").then((fs) => fs.readFile(new URL("./route.ts", import.meta.url), "utf8")); expect(source).not.toContain("postLineWithinLease"); diff --git a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts index 891fed1..2dc7eb7 100644 --- a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts +++ b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts @@ -5,7 +5,7 @@ import { canSeeInboxThree } from "@/lib/auth/access"; import { withBatchLease } from "@/lib/dn-pipeline/lock"; import { loadPreviewContext } from "@/lib/dn-pipeline/ai-preview-context"; import { decisionHash } from "@/lib/dn-pipeline/decision-hash"; -import { buildLineRows } from "@/lib/inbox-three/line-rows"; +import { buildLineRows, LineEditsSchema } from "@/lib/inbox-three/line-rows"; import { saveLineEdits } from "@/lib/inbox-three/save-lines"; export const dynamic = "force-dynamic"; @@ -29,8 +29,13 @@ export async function POST( if (![batchId, noteId].every((value) => /^[a-z0-9]{15}$/i.test(value))) return NextResponse.json({ error: "invalid_id" }, { status: 400 }); const body = await request.json().catch(() => null); - const edits = body && typeof body === "object" && Array.isArray((body as { edits?: unknown }).edits) ? (body as { edits: unknown[] }).edits : null; - if (!edits) return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + const rawEdits = body && typeof body === "object" && Array.isArray((body as { edits?: unknown }).edits) ? (body as { edits: unknown[] }).edits : null; + if (!rawEdits) return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + // An edit the server cannot read is an edit nobody asked for. Refuse it + // rather than guess what the office meant. + const parsed = LineEditsSchema.safeParse(rawEdits); + if (!parsed.success) return NextResponse.json({ error: "invalid_edits" }, { status: 400 }); + const edits = parsed.data; const pb = await pbAdmin(); const [batch, note] = await Promise.all([ @@ -51,7 +56,7 @@ export async function POST( throw Error("Matching result changed; refresh before continuing"); // Every edit is applied to a copy: a batch that fails halfway leaves // the saved match exactly as it was. - const { preview, remembered, readyNow } = saveLineEdits(context, stored, edits as never, user.id); + const { preview, remembered, readyNow } = saveLineEdits(context, stored, edits, user.id); await lease.batch([ ...(remembered ? [{ collection: "suppliers", method: "update" as const, id: String(context.supplier.id), data: { reconciliation_rules: remembered } }] diff --git a/src/lib/inbox-three/line-rows.ts b/src/lib/inbox-three/line-rows.ts index dece48e..6746313 100644 --- a/src/lib/inbox-three/line-rows.ts +++ b/src/lib/inbox-three/line-rows.ts @@ -14,6 +14,29 @@ import { finiteNumber, type PreviewAllocation, type PreviewContext, type Preview import type { LineConfirmation } from "@/lib/dn-pipeline/line-booking"; import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults"; import { guardAgrees, lineReady, priceFlagged } from "@/lib/inbox-three/readiness"; +import { z } from "zod"; + +/** + * What the office may send as an edit. + * + * Strict on purpose: an edit that carries a field nobody expects — a + * "post it now" flag, an acceptance nobody ticked — is refused rather than + * half-understood. + */ +export const LineEditSchema = z.object({ + lines: z.array(z.number().int().min(0).max(199)).min(1), + poItemId: z.string().nullable(), + slipQty: z.number().finite().min(0), + slipUnit: z.string().trim().min(1).max(40), + factor: z.number().finite().positive().max(10000).nullable(), + price: z.number().finite().min(0).nullable(), + usePoRate: z.boolean(), + acceptExcess: z.boolean().optional(), + reason: z.string().trim().max(2000), +}).strict(); + +/** A batch of edits, as one save. */ +export const LineEditsSchema = z.array(LineEditSchema).max(200); export interface LineEdit { /** 0-based indexes into the note's saved lines. */