fix(dn): validate inbox 3 line edits
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
bc138bcae7
commit
1514d514b3
3 changed files with 46 additions and 4 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 } }]
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue