Files
scan-receipts/tests/security/request_size.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

201 lines
7.5 KiB
TypeScript

/**
* 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<unknown>(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<unknown>(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<unknown>(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);
});
}