/** * Request / Upload Size Limits Suite * * Pure logic — no network, no database. Verifies that the backend refuses * oversized requests BEFORE any body is buffered: * * 1. `contentLengthExceeded` / `guardBodySize` read the Content-Length header * only, so a 2-GB upload never reaches `formData()` / `json()` / `text()`. * 2. `readJsonSized` re-measures the serialized body after parsing, which * catches chunked requests without a Content-Length header. * 3. The scan route's 413 must fire before any database code runs. */ import { describe, test, expect, runAllTests } from "../e2e/runner"; import { contentLengthExceeded, guardBodySize, readJsonSized, } from "../../src/lib/http/requestSize"; import { MAX_UPLOAD_BYTES, MAX_JSON_BODY_BYTES } from "../../src/lib/limits"; import { issueCsrfToken, CSRF_COOKIE, CSRF_HEADER } from "../../src/lib/auth/csrf"; import { POST as scanPOST } from "../../src/app/api/scan/route"; import type { NextRequest } from "next/server"; const MB = 1024 * 1024; /** Minimal Request stand-in carrying only the headers the guard reads. */ function headerOnlyRequest(headerName: string, value: string): Request { return { headers: new Headers({ [headerName]: value }) } as unknown as Request; } /** * Builds a scan request that clears the route's CSRF double-submit check so the * size guard is actually reached: matching `sr_csrf` cookie + `x-csrf-token` * header (origin is absent, which the allow-list accepts for non-browser * callers). The size guard runs AFTER the CSRF check and BEFORE any body * parsing, so the 413 assertion below exercises exactly that ordering. */ function scanRequest(contentLength: string): Request { const token = issueCsrfToken(); return new Request("http://localhost/api/scan", { method: "POST", headers: { "content-length": contentLength, cookie: `${CSRF_COOKIE}=${token}`, [CSRF_HEADER]: token, }, }); } describe("Content-Length guard — upload limit (10 MB)", () => { test("11 MB content-length is rejected with 413", () => { const req = headerOnlyRequest("content-length", String(11 * MB)); expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(true); const res = guardBodySize(req, MAX_UPLOAD_BYTES); expect(res).not.toBeNull(); expect(res!.status).toBe(413); }); test("5 MB content-length passes the guard", () => { const req = headerOnlyRequest("content-length", String(5 * MB)); expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false); expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull(); }); test("exactly 10 MB passes the guard (boundary is exclusive)", () => { const req = headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES)); expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false); expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull(); }); test("10 MB + 1 byte is rejected", () => { const req = headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES + 1)); expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(true); const res = guardBodySize(req, MAX_UPLOAD_BYTES); expect(res).not.toBeNull(); expect(res!.status).toBe(413); }); test("413 response carries the machine-readable error and limit", async () => { const res = guardBodySize( headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES + 1)), MAX_UPLOAD_BYTES )!; const payload = await res.json(); expect(payload.error).toBe("request_too_large"); expect(payload.maxBytes).toBe(MAX_UPLOAD_BYTES); }); test("malformed or negative content-length is not treated as exceeded", () => { for (const bad of ["abc", "-5", ""]) { const req = headerOnlyRequest("content-length", bad); expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false); expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull(); } }); test("missing content-length header (chunked) passes the header guard", () => { const req = { headers: new Headers() } as unknown as Request; expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false); expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull(); }); }); describe("readJsonSized — JSON body limit (1 MB)", () => { test("body larger than 1 MB (serialized length) is rejected with 413", async () => { const big = { data: "x".repeat(MAX_JSON_BODY_BYTES + 10) }; const req = new Request("http://localhost/api/test", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(big), }); const result = await readJsonSized(req, MAX_JSON_BODY_BYTES); expect(result.ok).toBe(false); if (!result.ok) { expect(result.response.status).toBe(413); const payload = await result.response.json(); expect(payload.error).toBe("request_too_large"); } }); test("content-length over 1 MB is rejected before parsing", async () => { const req = new Request("http://localhost/api/test", { method: "POST", headers: { "content-length": String(MAX_JSON_BODY_BYTES + 1) }, }); const result = await readJsonSized(req, MAX_JSON_BODY_BYTES); expect(result.ok).toBe(false); if (!result.ok) { expect(result.response.status).toBe(413); } }); test("valid small body returns ok with the parsed body", async () => { const req = new Request("http://localhost/api/test", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "timo@example.com", remember: true }), }); const result = await readJsonSized<{ email: string; remember: boolean }>( req, MAX_JSON_BODY_BYTES ); expect(result.ok).toBe(true); if (result.ok) { expect(result.body).toEqual({ email: "timo@example.com", remember: true }); } }); test("broken JSON returns 400 invalid_json", async () => { const req = new Request("http://localhost/api/test", { method: "POST", headers: { "content-type": "application/json" }, body: "{this is not json", }); const result = await readJsonSized(req, MAX_JSON_BODY_BYTES); expect(result.ok).toBe(false); if (!result.ok) { expect(result.response.status).toBe(400); const payload = await result.response.json(); expect(payload.error).toBe("invalid_json"); } }); }); describe("Scan route — oversized upload rejected before body/DB access", () => { test("POST /api/scan with content-length > 10 MB and no body returns 413", async () => { const req = scanRequest(String(MAX_UPLOAD_BYTES + 1)); // The guard runs before formData()/DB: a 413 here proves the route never // tried to buffer the (absent) body and never touched the database. const res = await scanPOST(req as unknown as NextRequest); expect(res.status).toBe(413); const payload = await res.json(); expect(payload.error).toBe("request_too_large"); }); test("POST /api/scan exactly at the 10 MB boundary is not rejected by the header guard", async () => { const req = scanRequest(String(MAX_UPLOAD_BYTES)); const res = await scanPOST(req as unknown as NextRequest); // With no multipart body the route must NOT answer 413 — it fails later, // inside the formData/validation path (500), which proves the guard did not // over-trigger at the exact boundary. expect(res.status).not.toBe(413); }); }); if (typeof require !== "undefined" && require.main === module) { runAllTests() .then((ok) => process.exit(ok ? 0 : 1)) .catch((err) => { console.error("Fatal runner crash:", err); process.exit(1); }); }