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>
This commit is contained in:
200
tests/security/request_size.test.ts
Normal file
200
tests/security/request_size.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user