fix(dn): validate inbox 3 line edits

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 19:30:59 +02:00
parent bc138bcae7
commit 1514d514b3
3 changed files with 46 additions and 4 deletions

View file

@ -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");

View file

@ -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 } }]

View file

@ -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. */