Merge pull request 'feat(import): ImportDispatchPolicy (DES1GN D3 §31) — policy-objekt + route-koppling' (#148) from feat/import-dispatch-policy into main
Deploy to Coolify / deploy (push) Successful in 4m0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 4m2s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 7s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m10s
CI (SIAX Cloud) / integration (pull_request) Failing after 1m34s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m35s
CI (SIAX Cloud) / security (pull_request) Successful in 1m11s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (push) Successful in 1m49s
Deploy to Coolify / deploy (push) Successful in 4m0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 4m2s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 7s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m10s
CI (SIAX Cloud) / integration (pull_request) Failing after 1m34s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m35s
CI (SIAX Cloud) / security (pull_request) Successful in 1m11s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (push) Successful in 1m49s
merge PR #148
This commit was merged in pull request #148.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user