diff --git a/app/admin/page.tsx b/app/admin/page.tsx index df88526..e82ae33 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -113,7 +113,13 @@ export default function AdminPage() { ) : null}
-

{t({ en: "Order queue", sv: "Orderkö" })}

+
+

{t({ en: "Order queue", sv: "Orderkö" })}

+ {/* DoD §65: real anchor, not a JS-handler button — the download is a plain GET of the CSV endpoint. */} + + {t({ en: "Export CSV", sv: "Exportera CSV" })} + +
{orders.length === 0 ? (
) : ( diff --git a/app/api/orders/export/route.ts b/app/api/orders/export/route.ts new file mode 100644 index 0000000..ac552d2 --- /dev/null +++ b/app/api/orders/export/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { listOwnedOrders } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; +import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping"; + +/** + * GET /api/orders/export (våg 3.4, DoD §56) — CSV-export of the authenticated + * owner's orders. Same fail-closed auth as GET/POST /api/orders (this path is + * outside middleware.ts's /api/0pt matcher, so the Zitadel verifier runs here). + * + * RFC 4180: CRLF line endings, fields containing comma, quote or newline are + * quoted with doubled quotes, header row always present. + * + * Locale note: deposit_ore / total_ore are exported as RAW ÖRE — öre is the + * canonical unit in the order contract; formatting (SEK, thousand separators) + * happens in the UI layer, never in the export. + * + * Cap note (DoD §56): NO row truncation/limiting — an export must be complete. + */ + +function csvEscape(value: string): string { + if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; +} + +export async function GET(req: NextRequest) { + const auth = await requireOrderOwner(req); + if (auth.response) return auth.response; + try { + const orders = await listOwnedOrders(auth.owner.tenantId); + const header = ["order_id", "flow", "status", "deposit_ore", "total_ore"]; + const rows = orders.map((o) => + [o.order_id, o.flow, o.status, String(o.deposit_ore), String(o.total_ore)].map(csvEscape).join(","), + ); + const csv = [header.join(","), ...rows].join("\r\n") + "\r\n"; + const date = new Date().toISOString().slice(0, 10); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="orders-${date}.csv"`, + }, + }); + } catch (err: unknown) { + if (isSiaxError(err)) return siaxErrorResponseForLegacy(err); + throw err; + } +} diff --git a/tests/api/orders-export.test.ts b/tests/api/orders-export.test.ts new file mode 100644 index 0000000..6cb54c2 --- /dev/null +++ b/tests/api/orders-export.test.ts @@ -0,0 +1,71 @@ +/** + * Våg 3.4 (DoD §56): unit tests for GET /api/orders/export — the first + * adoption of the export kernel. Follows the suite pattern of calling the + * route handler directly with a hand-built NextRequest and mocked auth/store. + */ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }), +})); +vi.mock("@/lib/orders-store", () => ({ + listOwnedOrders: vi.fn().mockResolvedValue([ + { order_id: "ord_1", flow: "custom", status: "draft", deposit_ore: 5000, total_ore: 10000 }, + { order_id: 'ord,"2"', flow: "platform", status: "delivered", deposit_ore: 0, total_ore: 12345 }, + ]), +})); + +import { GET as exportGET } from "@/app/api/orders/export/route"; + +function getReq(): NextRequest { + return new NextRequest(new URL("/api/orders/export", "http://localhost"), { method: "GET" }); +} + +describe("GET /api/orders/export — CSV (våg 3.4, DoD §56)", () => { + it("emits the documented header row and CRLF line endings", async () => { + const r = await exportGET(getReq()); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("text/csv"); + expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="orders-\d{4}-\d{2}-\d{2}\.csv"$/); + const body = await r.text(); + const lines = body.split("\r\n"); + expect(lines[0]).toBe("order_id,flow,status,deposit_ore,total_ore"); + expect(lines[1]).toBe("ord_1,custom,draft,5000,10000"); + }); + + it("RFC4180-quotes fields containing comma/quote (doubled quotes)", async () => { + const r = await exportGET(getReq()); + const body = await r.text(); + expect(body).toContain('"ord,""2"""'); + }); + + it("exports RAW öre — no locale formatting in the export (öre is canonical)", async () => { + const r = await exportGET(getReq()); + const body = await r.text(); + expect(body).toContain("ord_1,custom,draft,5000,10000"); + expect(body).not.toContain("100,00"); + expect(body).not.toMatch(/\u00A0| kr/); + }); + + it("no row truncation: export is complete per DoD §56 (cap documented)", async () => { + const r = await exportGET(getReq()); + const body = await r.text(); + const rows = body.trimEnd().split("\r\n").length - 1; + expect(rows).toBe(2); + }); + + it("auth is fail-closed: the route delegates to requireOrderOwner (401 contract)", async () => { + // The mocked requireOrderOwner resolves with an owner; the 401 path is the + // auth.response passthrough asserted in tests/api/anonymous-routes-auth. + // This test pins the wiring: the handler must return auth.response + // verbatim when present, before any store access. + const { requireOrderOwner } = await import("@/src/auth/require-order-owner"); + const mocked = requireOrderOwner as ReturnType; + mocked.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ error: { code: "unauthorized" } }), { status: 401 }), + }); + const r = await exportGET(getReq()); + expect(r.status).toBe(401); + }); +});