diff --git a/app/api/orders/import/route.ts b/app/api/orders/import/route.ts index ae14dfc..2e9b7f6 100644 --- a/app/api/orders/import/route.ts +++ b/app/api/orders/import/route.ts @@ -5,6 +5,7 @@ import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mappin import { createAud0Client } from "@/src/clients/aud0-client"; import { validateRows, defineMapping, type RowError } from "@siax/contracts/import-export"; import { serviceById, type Flow } from "@/lib/services"; +import { IMPORT_DISPATCH_POLICY, evaluateImportDispatch } from "@/lib/import-dispatch-policy"; import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/lib/data"; // C8: CSV-import of orders — the symmetric counterpart of GET /api/orders/export @@ -26,8 +27,12 @@ import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/li // boundary (durable outbox when DATABASE_URL is configured). Emission failure // is logged, never blocks the import (the outbox pattern owns delivery). -export const MAX_IMPORT_ROWS = 500; -const MAX_CSV_CHARS = 1_000_000; +// DES1GN ADR D3 (§31): taken härstammar från ImportDispatchPolicy — ett objekt, +// en sanning. Inline-taket är oförändrat (500); rader > policy.maxRows (5000) +// är N0D-dispatch-signalen (descriptor-gate: svarar 413 + hint tills N0D-workern +// är verifierad — aldrig tyst trunkering, aldrig tyst dispatch). +export const MAX_IMPORT_ROWS = IMPORT_DISPATCH_POLICY.maxInlineRows; +const MAX_CSV_CHARS = IMPORT_DISPATCH_POLICY.maxBytes; const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const; const FLOWS: readonly Flow[] = ["catalog", "custom", "subscription"]; const MAX_VOLUME = 1_000_000; @@ -187,15 +192,31 @@ export async function POST(req: NextRequest) { if (typeof csv !== "string" || csv.trim() === "") { return NextResponse.json({ error: { code: "bad_csv", message: "csv saknas eller är tom" } }, { status: 400 }); } - if (csv.length > MAX_CSV_CHARS) { - return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} tecken` } }, { status: 413 }); + const csvBytes = Buffer.byteLength(csv, "utf8"); + if (evaluateImportDispatch({ format: "csv", bytes: csvBytes, rows: 0 }).verdict === "byte_cap_exceeded") { + return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} bytes` } }, { status: 413 }); } try { const { report, rows, headerError } = validateImport(csv); if (headerError) { return NextResponse.json({ error: { code: "bad_header", message: headerError }, report }, { status: 400 }); } - if (rows.length > MAX_IMPORT_ROWS) { + const dispatchVerdict = evaluateImportDispatch({ format: "csv", bytes: csvBytes, rows: rows.length }); + if (dispatchVerdict.verdict === "dispatch_n0d") { + // DES1GN D3-signalen: den här volymen ÄR ett N0D-jobb (0pt.import_execute_job). + // Descriptor-gaten är stängd (N0D-worker UNVERIFIED) -> fail-closed 413 med hint. + return NextResponse.json( + { + error: { + code: "n0d_dispatch_required", + message: `Import med ${rows.length} rader ska köras som N0D-jobb (0pt.import_execute_job); dispatch är ännu inte aktiverad`, + hint: { capability_id: "0pt.import_execute", job_type: "0pt.import_execute_job", status: "UNVERIFIED" }, + }, + }, + { status: 413 }, + ); + } + if (dispatchVerdict.verdict === "row_cap_exceeded") { return NextResponse.json({ error: { code: "row_cap_exceeded", message: `Max ${MAX_IMPORT_ROWS} rader per import` } }, { status: 413 }); } diff --git a/lib/import-dispatch-policy.ts b/lib/import-dispatch-policy.ts new file mode 100644 index 0000000..42050ef --- /dev/null +++ b/lib/import-dispatch-policy.ts @@ -0,0 +1,47 @@ +// DES1GN ADR D3 (§31): bulk-import dispatch-policy — ETT objekt, en sanning. +// +// Import-routen (/api/orders/import) är inline-exekvering med hårt tak. Rader +// över maxRows är DES1GN D3:s signal för N0D-dispatch (job_type +// 0pt.import_execute_job / capability 0pt.import_execute i n0d:s +// JOB_TYPE_REGISTRY). TILLS descriptor-gaten släpper (N0D-worker +// UNVERIFIED) svarar routen 413 med n0d_dispatch_required + hint — aldrig +// tyst trunkering, aldrig tyst dispatch. +export const IMPORT_DISPATCH_POLICY = { + /** Inline-tak i import-routen (oförändrat beteendekontrakt, 0pt#53). */ + maxInlineRows: 500, + /** N0D-dispatch-signal: rader över detta är ett N0D-jobb, inget HTTP-anrop. */ + maxRows: 5000, + /** Byte-tak för hela CSV-kroppen (UTF-8). */ + maxBytes: 1_000_000, + /** Format-allowlist — endast RFC 4180 CSV i dag. */ + allowedFormats: ["csv"] as readonly string[], +} as const; + +export type ImportDispatchPolicy = typeof IMPORT_DISPATCH_POLICY; + +export type ImportDispatchVerdict = + | { verdict: "inline" } + | { verdict: "unsupported_format"; format: string } + | { verdict: "byte_cap_exceeded"; bytes: number; maxBytes: number } + | { verdict: "row_cap_exceeded"; rows: number; maxInlineRows: number } + | { verdict: "dispatch_n0d"; rows: number; maxRows: number }; + +/** Ren funktion — inga I/O, fullt deterministisk, testad per kombination. */ +export function evaluateImportDispatch( + input: { format: string; bytes: number; rows: number }, + policy: ImportDispatchPolicy = IMPORT_DISPATCH_POLICY, +): ImportDispatchVerdict { + if (!policy.allowedFormats.includes(input.format)) { + return { verdict: "unsupported_format", format: input.format }; + } + if (input.bytes > policy.maxBytes) { + return { verdict: "byte_cap_exceeded", bytes: input.bytes, maxBytes: policy.maxBytes }; + } + if (input.rows > policy.maxRows) { + return { verdict: "dispatch_n0d", rows: input.rows, maxRows: policy.maxRows }; + } + if (input.rows > policy.maxInlineRows) { + return { verdict: "row_cap_exceeded", rows: input.rows, maxInlineRows: policy.maxInlineRows }; + } + return { verdict: "inline" }; +} diff --git a/tests/api/import-dispatch-policy.test.ts b/tests/api/import-dispatch-policy.test.ts new file mode 100644 index 0000000..b305c19 --- /dev/null +++ b/tests/api/import-dispatch-policy.test.ts @@ -0,0 +1,105 @@ +/** + * DES1GN ADR D3 (§31): ImportDispatchPolicy — ren policy-funktion, testad per + * kombination (format × bytes × rows, gränsvärden inkluderade), plus route- + * kopplingen: rows > policy.maxRows -> 413 n0d_dispatch_required med hint + * (descriptor-gate stängd), inline-taket oförändrat. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { IMPORT_DISPATCH_POLICY, evaluateImportDispatch } from "@/lib/import-dispatch-policy"; + +describe("evaluateImportDispatch — verdict per kombination", () => { + it("gränsvärde maxInlineRows: exakt 500 rader -> inline", () => { + expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxInlineRows })).toEqual({ verdict: "inline" }); + }); + + it("maxInlineRows < rows <= maxRows -> row_cap_exceeded (inline-taket, oförändrat)", () => { + expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxInlineRows + 1 })).toEqual({ + verdict: "row_cap_exceeded", + rows: 501, + maxInlineRows: 500, + }); + expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxRows }).verdict).toBe("row_cap_exceeded"); + }); + + it("rows > maxRows -> dispatch_n0d (D3-signalen)", () => { + expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxRows + 1 })).toEqual({ + verdict: "dispatch_n0d", + rows: 5001, + maxRows: 5000, + }); + }); + + it("bytes > maxBytes -> byte_cap_exceeded, oavsett rows", () => { + expect(evaluateImportDispatch({ format: "csv", bytes: IMPORT_DISPATCH_POLICY.maxBytes + 1, rows: 1 }).verdict).toBe("byte_cap_exceeded"); + expect(evaluateImportDispatch({ format: "csv", bytes: IMPORT_DISPATCH_POLICY.maxBytes, rows: 1 }).verdict).toBe("inline"); + }); + + it("format utanför allowlist -> unsupported_format", () => { + expect(evaluateImportDispatch({ format: "xlsx", bytes: 10, rows: 1 })).toEqual({ verdict: "unsupported_format", format: "xlsx" }); + }); + + it("precedens: format slår bytes, bytes slår rows", () => { + expect(evaluateImportDispatch({ format: "json", bytes: 9e9, rows: 9e9 }).verdict).toBe("unsupported_format"); + expect(evaluateImportDispatch({ format: "csv", bytes: 9e9, rows: 9e9 }).verdict).toBe("byte_cap_exceeded"); + }); +}); + +describe("route-koppling: POST /api/orders/import respekterar policyn", () => { + const { requireOrderOwnerMock, createOrderMock, ingestEventMock, mockOwner } = vi.hoisted(() => ({ + requireOrderOwnerMock: vi.fn(), + createOrderMock: vi.fn(), + ingestEventMock: vi.fn().mockResolvedValue({}), + mockOwner: { owner: { tenantId: "tenant-test", subject: "buyer-test" } }, + })); + const _unused = mockOwner; + vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: (...args: unknown[]) => requireOrderOwnerMock(...args), + })); + vi.mock("@/lib/orders-store", () => ({ createOrder: (...args: unknown[]) => createOrderMock(...args) })); + vi.mock("@/src/clients/aud0-client", () => ({ createAud0Client: () => ({ ingestEvent: ingestEventMock }) })); + + let POST: typeof import("@/app/api/orders/import/route").POST; + let MAX_IMPORT_ROWS: number; + + beforeEach(async () => { + vi.clearAllMocks(); + requireOrderOwnerMock.mockResolvedValue(mockOwner); + ({ POST, MAX_IMPORT_ROWS } = await import("@/app/api/orders/import/route")); + }); + + function postReq(csv: string): NextRequest { + return new NextRequest(new URL("/api/orders/import", "http://localhost"), { + method: "POST", + body: JSON.stringify({ mode: "validate", csv }), + headers: { "content-type": "application/json" }, + }); + } + + const line = "custom,icp,Bolag,10,Engångs,SE"; + + it("exakt maxInlineRows rader -> 200 (inline-beteende oförändrat)", async () => { + const csv = ["flow,service_type,type,volume,frequency,markets", ...Array.from({ length: MAX_IMPORT_ROWS }, () => line)].join("\n"); + const res = await POST(postReq(csv)); + expect(res.status).toBe(200); + }); + + it("maxRows < rows -> 413 n0d_dispatch_required med UNVERIFIED-hint, ingen skrivning", async () => { + const csv = ["flow,service_type,type,volume,frequency,markets", ...Array.from({ length: IMPORT_DISPATCH_POLICY.maxRows + 1 }, () => line)].join("\n"); + const res = await POST(postReq(csv)); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: { code: string; hint: { capability_id: string; job_type: string; status: string } } }; + expect(body.error.code).toBe("n0d_dispatch_required"); + expect(body.error.hint).toEqual({ capability_id: "0pt.import_execute", job_type: "0pt.import_execute_job", status: "UNVERIFIED" }); + expect(createOrderMock).not.toHaveBeenCalled(); + expect(ingestEventMock).not.toHaveBeenCalled(); + }); + + it("CSV > maxBytes -> 413 csv_too_large", async () => { + const csv = "flow,service_type,type,volume,frequency,markets\n" + "x".repeat(IMPORT_DISPATCH_POLICY.maxBytes); + const res = await POST(postReq(csv)); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("csv_too_large"); + }); +});