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:
268
tests/security/ai_usage_cap.test.ts
Normal file
268
tests/security/ai_usage_cap.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Daily AI-Usage Cap (src/lib/ai/usage.ts) — rein logische Tests.
|
||||
*
|
||||
* Kein Netzwerk, keine Datenbank: geprüft wird ausschließlich der in-memory
|
||||
* 24h-Fenster-Limiter (Pre-Check + Record-nach-Extraktion). Die Scan-Route
|
||||
* selbst wird hier NICHT aufgerufen (CSRF-/DB-/Extraction-Abhängigkeit) — die
|
||||
* pure Limiter-Logik ist die vertragliche Einheit.
|
||||
*
|
||||
* Determinismus: Alle Fenster-Aussagen laufen über das injizierbare `now`,
|
||||
* echte Uhrzeit wird in keinem Test verwendet. Jeder Test nutzt eigene Keys,
|
||||
* damit die modul-globale Map keine Suite-übergreifenden Nebeneffekte hat.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import {
|
||||
DAILY_SCAN_LIMIT,
|
||||
DAILY_GUEST_SCAN_LIMIT,
|
||||
DAILY_PRO_SCAN_LIMIT,
|
||||
HOURLY_SCAN_LIMIT,
|
||||
checkDailyUsage,
|
||||
dailyScanLimitFor,
|
||||
recordUsage,
|
||||
usageKeyForUser,
|
||||
usageKeyForGuest,
|
||||
} from "../../src/lib/ai/usage";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/** Eindeutiger Key pro Testaufruf — isoliert Fenster über die Testgrenzen hinweg. */
|
||||
let keySeq = 0;
|
||||
const nextKey = (prefix = "usage") => `${prefix}_${Date.now()}_${keySeq++}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Pre-Check-Semantik (checkDailyUsage)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkDailyUsage — Pre-Check-Semantik", () => {
|
||||
test("C-1: ohne Verbrauch erlaubt, used=0, remaining=Limit", () => {
|
||||
const key = nextKey();
|
||||
const r = checkDailyUsage(key, DAILY_SCAN_LIMIT);
|
||||
expect(r.allowed).toBe(true);
|
||||
expect(r.used).toBe(0);
|
||||
expect(r.remaining).toBe(DAILY_SCAN_LIMIT);
|
||||
// resetsAt liegt in der Zukunft (neues Fenster würde erst beim Record starten)
|
||||
expect(r.resetsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test("C-2: erlaubt bis zum Limit; Limit erreicht → blocked (Limit+1-Einheit)", () => {
|
||||
const key = nextKey();
|
||||
for (let i = 1; i <= DAILY_SCAN_LIMIT; i++) {
|
||||
recordUsage(key, 1);
|
||||
}
|
||||
const atLimit = checkDailyUsage(key, DAILY_SCAN_LIMIT);
|
||||
expect(atLimit.used).toBe(DAILY_SCAN_LIMIT);
|
||||
expect(atLimit.remaining).toBe(0);
|
||||
expect(atLimit.allowed).toBe(false);
|
||||
// Und eine Einheit davor war noch erlaubt:
|
||||
const key2 = nextKey();
|
||||
for (let i = 1; i <= DAILY_SCAN_LIMIT - 1; i++) {
|
||||
recordUsage(key2, 1);
|
||||
}
|
||||
expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).allowed).toBe(true);
|
||||
expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).remaining).toBe(1);
|
||||
});
|
||||
|
||||
test("C-3: allowed hängt am explizit übergebenen Limit", () => {
|
||||
const key = nextKey();
|
||||
for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) {
|
||||
recordUsage(key, 1);
|
||||
}
|
||||
// Gegen das User-Limit (30) noch frei, gegen das Guest-Limit (10) blockiert:
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true);
|
||||
expect(checkDailyUsage(key, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("C-3b: Pro-Cap (150) erlaubt weiter, wo Free (30) schon blockiert", () => {
|
||||
const key = nextKey();
|
||||
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
|
||||
recordUsage(key, 1);
|
||||
}
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false);
|
||||
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(true);
|
||||
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).remaining).toBe(
|
||||
DAILY_PRO_SCAN_LIMIT - DAILY_SCAN_LIMIT
|
||||
);
|
||||
for (let i = DAILY_SCAN_LIMIT; i < DAILY_PRO_SCAN_LIMIT; i++) {
|
||||
recordUsage(key, 1);
|
||||
}
|
||||
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(false);
|
||||
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).used).toBe(DAILY_PRO_SCAN_LIMIT);
|
||||
});
|
||||
|
||||
test("C-4: ohne explizites Limit gilt der schlüssel-abgeleitete Default", () => {
|
||||
expect(checkDailyUsage(usageKeyForUser("u_a")).remaining).toBe(DAILY_SCAN_LIMIT);
|
||||
expect(checkDailyUsage(usageKeyForGuest("guest_a")).remaining).toBe(DAILY_GUEST_SCAN_LIMIT);
|
||||
});
|
||||
|
||||
test("C-5: leerer Key oder ungültiges Limit throttelt nie (Misconfiguration-Sicherheit)", () => {
|
||||
const r = checkDailyUsage("", DAILY_SCAN_LIMIT);
|
||||
expect(r.allowed).toBe(true);
|
||||
const r2 = checkDailyUsage("some_key", 0);
|
||||
expect(r2.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Record-Semantik (recordUsage)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("recordUsage — Zähler & Fensterstart", () => {
|
||||
test("R-1: erhöht den Zähler, used/remaining konsistent", () => {
|
||||
const key = nextKey();
|
||||
const r1 = recordUsage(key, 1);
|
||||
expect(r1.used).toBe(1);
|
||||
expect(r1.remaining).toBe(DAILY_SCAN_LIMIT - 1);
|
||||
const r2 = recordUsage(key, 5);
|
||||
expect(r2.used).toBe(6);
|
||||
expect(r2.remaining).toBe(DAILY_SCAN_LIMIT - 6);
|
||||
});
|
||||
|
||||
test("R-2: remaining wird bei 0 geklemmt", () => {
|
||||
const key = nextKey();
|
||||
const r = recordUsage(key, DAILY_SCAN_LIMIT + 50);
|
||||
expect(r.used).toBe(DAILY_SCAN_LIMIT + 50);
|
||||
expect(r.remaining).toBe(0);
|
||||
});
|
||||
|
||||
test("R-3: Multi-Unit-Record zählt Seiten (20-Seiten-PDF = 20 Einheiten)", () => {
|
||||
const key = nextKey();
|
||||
const r = recordUsage(key, 20);
|
||||
expect(r.used).toBe(20);
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).used).toBe(20);
|
||||
});
|
||||
|
||||
test("R-4: ungültige/negative/0 Einheiten werden defensiv zu 1 normalisiert", () => {
|
||||
const key = nextKey();
|
||||
expect(recordUsage(key, 2.5).used).toBe(3); // aufgerundet
|
||||
expect(recordUsage(key, -3).used).toBe(4); // negativ → 1
|
||||
expect(recordUsage(key, 0).used).toBe(5);
|
||||
expect(recordUsage(key, NaN).used).toBe(6);
|
||||
});
|
||||
|
||||
test("R-5: erster Record startet das 24h-Fenster (resetsAt = now + 24h)", () => {
|
||||
const key = nextKey();
|
||||
const t0 = new Date("2026-03-01T12:00:00.000Z");
|
||||
recordUsage(key, 1, t0);
|
||||
const check = checkDailyUsage(key, DAILY_SCAN_LIMIT, new Date(t0.getTime() + 1000));
|
||||
expect(check.resetsAt).toBe(t0.getTime() + DAY);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Fenster-Reset (injizierbares `now`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Fenster-Reset über injiziertes `now`", () => {
|
||||
test("W-1: nach 24h+1ms ist das Limit wieder frei", () => {
|
||||
const key = nextKey();
|
||||
const day1 = new Date("2026-01-01T00:00:00.000Z");
|
||||
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
|
||||
recordUsage(key, 1, day1);
|
||||
}
|
||||
// Am Tag 1 aufgebraucht:
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, day1).allowed).toBe(false);
|
||||
// Kurz vor Ablauf weiterhin blockiert:
|
||||
const almostDay2 = new Date(day1.getTime() + DAY - 1000);
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, almostDay2).allowed).toBe(false);
|
||||
// 24h + 1ms später: frisches Fenster:
|
||||
const day2 = new Date(day1.getTime() + DAY + 1);
|
||||
const fresh = checkDailyUsage(key, DAILY_SCAN_LIMIT, day2);
|
||||
expect(fresh.allowed).toBe(true);
|
||||
expect(fresh.used).toBe(0);
|
||||
expect(fresh.remaining).toBe(DAILY_SCAN_LIMIT);
|
||||
// Record im neuen Fenster startet es neu:
|
||||
const rec = recordUsage(key, 3, day2);
|
||||
expect(rec.used).toBe(3);
|
||||
expect(rec.remaining).toBe(DAILY_SCAN_LIMIT - 3);
|
||||
});
|
||||
|
||||
test("W-2: checkDailyUsage mutiert nicht — mehrere Reads liefern gleiche Werte", () => {
|
||||
const key = nextKey();
|
||||
const t = new Date("2026-02-02T08:00:00.000Z");
|
||||
recordUsage(key, 4, t);
|
||||
const a = checkDailyUsage(key, DAILY_SCAN_LIMIT, t);
|
||||
const b = checkDailyUsage(key, DAILY_SCAN_LIMIT, t);
|
||||
expect(b).toEqual(a);
|
||||
expect(a.used).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Key-Isolation & Key-Format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Key-Isolation & Key-Format", () => {
|
||||
test("K-1: User- und Guest-Key sind unabhängige Fenster", () => {
|
||||
const userKey = usageKeyForUser("user-42");
|
||||
const guestKey = usageKeyForGuest("guest_bucket_1");
|
||||
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
|
||||
recordUsage(userKey, 1);
|
||||
}
|
||||
expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).allowed).toBe(false);
|
||||
// Guest unberührt:
|
||||
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(true);
|
||||
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).used).toBe(0);
|
||||
// Und umgekehrt:
|
||||
for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) {
|
||||
recordUsage(guestKey, 1);
|
||||
}
|
||||
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false);
|
||||
expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).used).toBe(DAILY_SCAN_LIMIT);
|
||||
});
|
||||
|
||||
test("K-2: usageKeyForUser/usageKeyForGuest liefern die dokumentierten Formate", () => {
|
||||
expect(usageKeyForUser("u_42")).toBe("ai:user:u_42");
|
||||
expect(usageKeyForGuest("guest_abc")).toBe("ai:guest:guest_abc");
|
||||
// Kein Prefix-Collision-Risiko zwischen beiden Räumen:
|
||||
expect(usageKeyForUser("guest_x")).toBe("ai:user:guest_x");
|
||||
expect(usageKeyForGuest("u_1")).toBe("ai:guest:u_1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Overshoot-Caveat (Seiten = AI-Calls) & Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Overshoot & Konfiguration", () => {
|
||||
test("O-1: Pre-Check erlaubt, Multi-Seiten-Record sprengt das Budget — dokumentierter Overshoot", () => {
|
||||
const key = nextKey();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordUsage(key, 1);
|
||||
}
|
||||
// Pre-Check vor dem Upload: noch 25 frei → erlaubt.
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true);
|
||||
// 10-Seiten-PDF wird korrekt gebucht:
|
||||
expect(recordUsage(key, 10).used).toBe(15);
|
||||
// 20-Seiten-PDF: Pre-Check hätte erlaubt (15 frei), Record überschreitet das
|
||||
// Budget — das ist der dokumentierte, durch Konkurrenz begrenzte Overshoot.
|
||||
const overshoot = recordUsage(key, 20);
|
||||
expect(overshoot.used).toBe(35);
|
||||
expect(overshoot.remaining).toBe(0);
|
||||
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("KONF-1: Limits sind positiv; Guest < Free < Pro; HOURLY dokumentiert", () => {
|
||||
expect(DAILY_SCAN_LIMIT).toBe(30);
|
||||
expect(DAILY_GUEST_SCAN_LIMIT).toBe(10);
|
||||
expect(DAILY_PRO_SCAN_LIMIT).toBe(150);
|
||||
expect(DAILY_GUEST_SCAN_LIMIT).toBeLessThan(DAILY_SCAN_LIMIT);
|
||||
expect(DAILY_SCAN_LIMIT).toBeLessThan(DAILY_PRO_SCAN_LIMIT);
|
||||
expect(HOURLY_SCAN_LIMIT).toBeGreaterThanOrEqual(DAILY_SCAN_LIMIT);
|
||||
});
|
||||
|
||||
test("KONF-2: dailyScanLimitFor — Free hat kein Daily-Cap, Pro hat 150", () => {
|
||||
expect(dailyScanLimitFor({ isPro: false })).toBeNull();
|
||||
expect(dailyScanLimitFor({ isPro: true })).toBe(DAILY_PRO_SCAN_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
227
tests/security/password_reset_rate_limit.test.ts
Normal file
227
tests/security/password_reset_rate_limit.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Password-Reset Rate-Limit Security Suite
|
||||
*
|
||||
* Pure-logic tests for the auth flood control the password-reset endpoints run
|
||||
* on: the fixed-window limiter, the trusted client-IP extraction and the shared
|
||||
* 429 response helper. No database, no network — the route handlers themselves
|
||||
* are not imported because they call `requireDatabase()` first, which needs a
|
||||
* live Postgres. The primitives they call are exactly what is exercised here.
|
||||
*
|
||||
* Covered budgets (kept in sync with the routes):
|
||||
* forgot-password : forgot:ip:<ip> 5/h + forgot:email:<key> 3/h
|
||||
* reset-password : reset:burst:ip:<ip> 5/10min + reset:ip:<ip> 10/h
|
||||
* resend-verification: resend:ip:<ip> 5/h + resend:email:<key> 3/h
|
||||
* login : login:ip:<ip> 20/15min + login:email:<key> 10/15min
|
||||
* signup : signup:ip:<ip> 5/h + signup:email:<key> 3/h
|
||||
* change-password : change:ip:<ip> 10/15min
|
||||
* verify : verify:ip:<ip> 60/h
|
||||
*/
|
||||
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import {
|
||||
clientIp,
|
||||
rateLimit,
|
||||
resetRateLimits,
|
||||
} from "../../src/lib/security/rateLimit";
|
||||
|
||||
// sucrase-node runs plain CJS without Next.js's path-alias loader, so the
|
||||
// "@/..." imports inside the shared HTTP helper would not resolve. Teach Node's
|
||||
// resolver to map "@/x" onto <cwd>/src/x before loading that helper. This is
|
||||
// module plumbing only — no test logic and no database.
|
||||
const nodeModule = require("node:module") as typeof import("node:module") & {
|
||||
_resolveFilename: (request: string, ...args: unknown[]) => string;
|
||||
};
|
||||
const nodePath: typeof import("node:path") = require("node:path");
|
||||
const originalResolve = nodeModule._resolveFilename;
|
||||
nodeModule._resolveFilename = function (
|
||||
this: unknown,
|
||||
request: string,
|
||||
...args: unknown[]
|
||||
): string {
|
||||
if (request.startsWith("@/")) {
|
||||
return originalResolve.call(
|
||||
this,
|
||||
nodePath.join(process.cwd(), "src", request.slice(2)),
|
||||
...args
|
||||
);
|
||||
}
|
||||
return originalResolve.call(this, request, ...args);
|
||||
};
|
||||
|
||||
// Must be loaded after the shim above is installed.
|
||||
const { rateLimited } = require("../../src/lib/auth/http") as typeof import("../../src/lib/auth/http");
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
|
||||
describe("Rate limit — fixed-window semantics", () => {
|
||||
test("the first `limit` calls within a window are allowed", () => {
|
||||
resetRateLimits();
|
||||
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
|
||||
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
|
||||
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("the next call is denied with a positive retryAfter", () => {
|
||||
resetRateLimits();
|
||||
rateLimit("rl:b", 1, MINUTE);
|
||||
const denied = rateLimit("rl:b", 1, MINUTE);
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("denied calls never push the window back (fixed window)", () => {
|
||||
resetRateLimits();
|
||||
rateLimit("rl:c", 1, MINUTE);
|
||||
const first = rateLimit("rl:c", 1, MINUTE);
|
||||
const second = rateLimit("rl:c", 1, MINUTE);
|
||||
expect(first.allowed).toBe(false);
|
||||
expect(second.allowed).toBe(false);
|
||||
// retryAfter only ever shrinks as time passes; a larger value would mean the
|
||||
// window was extended on a denial, which must not happen.
|
||||
expect(second.retryAfter).toBeLessThanOrEqual(first.retryAfter);
|
||||
});
|
||||
|
||||
test("different keys are fully isolated", () => {
|
||||
resetRateLimits();
|
||||
rateLimit("rl:exhausted", 1, MINUTE);
|
||||
expect(rateLimit("rl:exhausted", 1, MINUTE).allowed).toBe(false);
|
||||
expect(rateLimit("rl:fresh", 1, MINUTE).allowed).toBe(true);
|
||||
expect(rateLimit("rl:other", 5, MINUTE).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("the window resets after it elapses", async () => {
|
||||
resetRateLimits();
|
||||
expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
|
||||
expect(rateLimit("rl:window", 1, 50).allowed).toBe(false);
|
||||
await sleep(60);
|
||||
expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("malformed input fails open instead of locking anyone out", () => {
|
||||
resetRateLimits();
|
||||
expect(rateLimit("", 5, MINUTE).allowed).toBe(true);
|
||||
expect(rateLimit("rl:zero-limit", 0, MINUTE).allowed).toBe(true);
|
||||
expect(rateLimit("rl:zero-window", 5, 0).allowed).toBe(true);
|
||||
expect(rateLimit("rl:nan", Number.NaN, MINUTE).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("resetRateLimits empties every bucket", () => {
|
||||
resetRateLimits();
|
||||
rateLimit("rl:drain", 1, MINUTE);
|
||||
expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(false);
|
||||
resetRateLimits();
|
||||
expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate limit — the password-reset budgets in production", () => {
|
||||
test("reset endpoint: the burst cap (5/10min) blocks a rapid scripted run", () => {
|
||||
resetRateLimits();
|
||||
const key = "reset:burst:ip:203.0.113.7";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(rateLimit(key, 5, 10 * MINUTE).allowed).toBe(true);
|
||||
}
|
||||
const denied = rateLimit(key, 5, 10 * MINUTE);
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("reset endpoint: the hourly budget (10/h) still applies on top", () => {
|
||||
resetRateLimits();
|
||||
const key = "reset:ip:203.0.113.7";
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(rateLimit(key, 10, HOUR).allowed).toBe(true);
|
||||
}
|
||||
expect(rateLimit(key, 10, HOUR).allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("verify endpoint: the generous per-IP budget (60/h) survives a normal click", () => {
|
||||
resetRateLimits();
|
||||
const key = "verify:ip:203.0.113.7";
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect(rateLimit(key, 60, HOUR).allowed).toBe(true);
|
||||
}
|
||||
expect(rateLimit(key, 60, HOUR).allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("forgot-password: IP 5/h and per-email 3/h are independent", () => {
|
||||
resetRateLimits();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(true);
|
||||
}
|
||||
expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(false);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(true);
|
||||
}
|
||||
expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate limit — the 429 response helper", () => {
|
||||
test("rateLimited() answers 429 with a Retry-After header", () => {
|
||||
const response = rateLimited(42);
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("retry-after")).toBe("42");
|
||||
});
|
||||
|
||||
test("rateLimited() carries the stable rate_limited error code", async () => {
|
||||
const response = rateLimited(9);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("rate_limited");
|
||||
expect(body.retryAfter).toBe(9);
|
||||
});
|
||||
|
||||
test("the limiter's retryAfter round-trips into the Retry-After header", () => {
|
||||
resetRateLimits();
|
||||
const key = "rl:roundtrip";
|
||||
rateLimit(key, 1, MINUTE);
|
||||
const denied = rateLimit(key, 1, MINUTE);
|
||||
const response = rateLimited(denied.retryAfter);
|
||||
expect(response.status).toBe(429);
|
||||
expect(Number(response.headers.get("retry-after"))).toBe(denied.retryAfter);
|
||||
expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate limit — client IP extraction", () => {
|
||||
const request = (headers: Record<string, string>): Request =>
|
||||
new Request("https://example.com/api/auth/reset-password", { headers });
|
||||
|
||||
test("x-forwarded-for: the right-most valid IP wins over a spoofed prefix", () => {
|
||||
const req = request({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" });
|
||||
expect(clientIp(req)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
test("x-forwarded-for: a three-hop chain resolves to the proxy-appended tail", () => {
|
||||
const req = request({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 198.51.100.9" });
|
||||
expect(clientIp(req)).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
test("x-forwarded-for: junk entries are skipped, the last valid one wins", () => {
|
||||
const req = request({ "x-forwarded-for": "not-an-ip, also-junk, 203.0.113.7" });
|
||||
expect(clientIp(req)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
test("x-real-ip is the fallback when x-forwarded-for is absent", () => {
|
||||
const req = request({ "x-real-ip": "198.51.100.9" });
|
||||
expect(clientIp(req)).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
test("x-real-ip also saves the day when x-forwarded-for has no valid IP", () => {
|
||||
const req = request({ "x-forwarded-for": "garbage", "x-real-ip": "198.51.100.9" });
|
||||
expect(clientIp(req)).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
test("an empty or invalid header chain resolves to \"unknown\"", () => {
|
||||
expect(clientIp(request({}))).toBe("unknown");
|
||||
expect(clientIp(request({ "x-forwarded-for": "nonsense" }))).toBe("unknown");
|
||||
expect(clientIp(request({ "x-real-ip": "nonsense" }))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
runAllTests().then((ok) => process.exit(ok ? 0 : 1));
|
||||
}
|
||||
340
tests/security/prompt_injection.test.ts
Normal file
340
tests/security/prompt_injection.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Prompt-Injection-Schutz der AI/LLM-Extraktion — rein logische Tests.
|
||||
*
|
||||
* Kein Netzwerk, keine Datenbank: Geprüft werden ausschließlich der komponierte
|
||||
* System-Prompt (Guard-Präsenz) und die Output-Sanitisierung
|
||||
* (sanitizeExtractionOutput) gegen ihre harten Grenzen.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import {
|
||||
INJECTION_GUARD,
|
||||
buildExtractionSystemPrompt,
|
||||
sanitizeExtractionOutput,
|
||||
} from "../../src/lib/ai/promptInjection";
|
||||
import { SYSTEM_PROMPT } from "../../src/lib/ai/extractor";
|
||||
import { ReceiptData } from "../../src/lib/schema/receipt";
|
||||
|
||||
/**
|
||||
* Minimales, vollständig gültiges ReceiptData-Fixture. Alle Schlüssel sind
|
||||
* gesetzt, damit der Round-Trip-Vergleich (Key-Gleichheit) eindeutig ist.
|
||||
*/
|
||||
function validReceipt(overrides: Partial<ReceiptData> = {}): ReceiptData {
|
||||
return {
|
||||
merchant: {
|
||||
name: "REWE City",
|
||||
address: "Friedrichstraße 190, 10117 Berlin",
|
||||
taxId: "DE811122334",
|
||||
confidence: 0.97,
|
||||
},
|
||||
date: { isoDate: "2026-08-12", time: "17:45", confidence: 0.96 },
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "RW-77821",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 31.8, confidence: 0.98 },
|
||||
netAmount: 26.72,
|
||||
tipAmount: null,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
|
||||
lineItems: [
|
||||
{ description: "Bio Milch 3.8%", quantity: 2, price: 3.18, unitPrice: 1.59, taxRate: 7 },
|
||||
],
|
||||
suggestedCategory: "Verpflegungsmehraufwand",
|
||||
paymentMethod: null,
|
||||
hospitality: { occasion: "Geschäftsessen", participants: "Herr Müller, Frau Schmidt" },
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. System-Prompt-Komposition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Prompt-Injection-Guard: System-Prompt-Komposition", () => {
|
||||
test("P-1: INJECTION_GUARD ist ein nicht-leerer deutschsprachiger Sicherheitstext mit Kernpunkten", () => {
|
||||
expect(typeof INJECTION_GUARD).toBe("string");
|
||||
expect(INJECTION_GUARD.length).toBeGreaterThan(200);
|
||||
expect(INJECTION_GUARD).toContain("UNVERTRAUTE DATEN");
|
||||
expect(INJECTION_GUARD).toContain("ignorier");
|
||||
expect(INJECTION_GUARD).toContain("Systemanweisungen");
|
||||
expect(INJECTION_GUARD).toContain("vorgegebene Schema");
|
||||
});
|
||||
|
||||
test("P-2: buildExtractionSystemPrompt hängt den Guard an die Basis an", () => {
|
||||
const base = "Du bist ein hochpräziser Beleg-Scanner.";
|
||||
const composed = buildExtractionSystemPrompt(base);
|
||||
expect(composed.startsWith(base)).toBe(true);
|
||||
expect(composed).toContain(INJECTION_GUARD);
|
||||
expect(composed.length).toBeGreaterThan(base.length);
|
||||
});
|
||||
|
||||
test("P-3: Der exportierte finale SYSTEM_PROMPT des Extractors enthält den Guard", () => {
|
||||
expect(SYSTEM_PROMPT).toContain(INJECTION_GUARD);
|
||||
expect(SYSTEM_PROMPT).toContain("UNVERTRAUTE DATEN");
|
||||
expect(SYSTEM_PROMPT).toContain("ignorier");
|
||||
expect(SYSTEM_PROMPT).toContain("Systemanweisungen");
|
||||
// Basis-Inhalt bleibt vollständig erhalten.
|
||||
expect(SYSTEM_PROMPT).toContain("KI-Beleg-Scanner");
|
||||
expect(SYSTEM_PROMPT).toContain("EXTRAKTIONS-REGELN");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Strings & Längengrenzen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeExtractionOutput: Strings & Längengrenzen", () => {
|
||||
test("S-1: Händlername > 160 Zeichen wird auf 160 gekappt", () => {
|
||||
const long = "X".repeat(201);
|
||||
const out = sanitizeExtractionOutput(validReceipt({ merchant: { ...validReceipt().merchant, name: long } }));
|
||||
expect(out.merchant.name).toHaveLength(160);
|
||||
expect(out.merchant.name).toBe(long.slice(0, 160));
|
||||
});
|
||||
|
||||
test("S-2: Kontrollzeichen werden entfernt, Whitespace-Runs kollabieren", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
merchant: { ...validReceipt().merchant, name: "REWE\u0000\u0007\u001B\t\tCity \n GmbH" },
|
||||
})
|
||||
);
|
||||
expect(out.merchant.name).toBe("REWE City GmbH");
|
||||
// Kein Kontrollzeichen und kein Doppel-Space darf übrig bleiben.
|
||||
expect(/[\u0000-\u001F\u007F-\u009F]/.test(out.merchant.name)).toBe(false);
|
||||
expect(out.merchant.name.includes(" ")).toBe(false);
|
||||
});
|
||||
|
||||
test("S-3: Adresse, Steuernummer und Belegnummer werden auf ihre Caps gekappt", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
merchant: { ...validReceipt().merchant, address: "A".repeat(500), taxId: "T".repeat(100) },
|
||||
receiptNumber: "N".repeat(200),
|
||||
})
|
||||
);
|
||||
expect(out.merchant.address).toHaveLength(300);
|
||||
expect(out.merchant.taxId).toHaveLength(64);
|
||||
expect(out.receiptNumber).toHaveLength(128);
|
||||
});
|
||||
|
||||
test("S-4: lineItems-Beschreibung wird auf 200 Zeichen gekappt", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
lineItems: [{ description: "D".repeat(250), quantity: 1, price: 1, unitPrice: null, taxRate: null }],
|
||||
})
|
||||
);
|
||||
expect(out.lineItems[0].description).toHaveLength(200);
|
||||
});
|
||||
|
||||
test("S-5: hospitality-Strings (occasion, participants) werden auf 200 Zeichen gekappt", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
hospitality: { occasion: "O".repeat(300), participants: "P".repeat(250) },
|
||||
})
|
||||
);
|
||||
expect(out.hospitality?.occasion).toHaveLength(200);
|
||||
expect(out.hospitality?.participants).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Zahlen-Grenzen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeExtractionOutput: Zahlen-Grenzen", () => {
|
||||
test("N-1: totalAmount.value -5e9 → 0 (negative Beträge werden auf 0 geklemmt)", () => {
|
||||
const out = sanitizeExtractionOutput(validReceipt({ totalAmount: { value: -5e9, confidence: 0.9 } }));
|
||||
expect(out.totalAmount.value).toBe(0);
|
||||
});
|
||||
|
||||
test("N-2: totalAmount.value 1e12 → 1e9 (Obergrenze)", () => {
|
||||
const out = sanitizeExtractionOutput(validReceipt({ totalAmount: { value: 1e12, confidence: 0.9 } }));
|
||||
expect(out.totalAmount.value).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
test("N-3: NaN/Infinity → 0 bzw. null", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
totalAmount: { value: NaN, confidence: 0.9 },
|
||||
netAmount: NaN,
|
||||
tipAmount: Infinity,
|
||||
})
|
||||
);
|
||||
expect(out.totalAmount.value).toBe(0);
|
||||
expect(out.netAmount).toBeNull();
|
||||
expect(out.tipAmount).toBeNull();
|
||||
});
|
||||
|
||||
test("N-4: taxAmount 1e12 → 1e9, ratePercent 150 → 100", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({ taxBreakdown: [{ ratePercent: 150, taxAmount: 1e12, netAmount: 26.72 }] })
|
||||
);
|
||||
expect(out.taxBreakdown[0].ratePercent).toBe(100);
|
||||
expect(out.taxBreakdown[0].taxAmount).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
test("N-5: quantity wird auf 0..1e6 geklemmt", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
lineItems: [
|
||||
{ description: "a", quantity: -3, price: 10, unitPrice: null, taxRate: 19 },
|
||||
{ description: "b", quantity: 5e7, price: 10, unitPrice: null, taxRate: 19 },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(out.lineItems[0].quantity).toBe(0);
|
||||
expect(out.lineItems[1].quantity).toBe(1_000_000);
|
||||
});
|
||||
|
||||
test("N-6: confidence wird auf 0..1 geklemmt, NaN → 0", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({
|
||||
merchant: { ...validReceipt().merchant, confidence: 2.5 },
|
||||
date: { ...validReceipt().date, confidence: -1 },
|
||||
totalAmount: { value: 10, confidence: NaN },
|
||||
})
|
||||
);
|
||||
expect(out.merchant.confidence).toBe(1);
|
||||
expect(out.date.confidence).toBe(0);
|
||||
expect(out.totalAmount.confidence).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Datum & Uhrzeit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeExtractionOutput: Datum & Uhrzeit", () => {
|
||||
test("D-1: Ungültiges Datum (2026-13-45) → leerer String", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "2026-13-45", time: "17:45", confidence: 0.9 } })
|
||||
);
|
||||
expect(out.date.isoDate).toBe("");
|
||||
});
|
||||
|
||||
test("D-2: Kein reales Kalenderdatum (2026-02-30) → leerer String", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "2026-02-30", time: "17:45", confidence: 0.9 } })
|
||||
);
|
||||
expect(out.date.isoDate).toBe("");
|
||||
});
|
||||
|
||||
test("D-3: DACH-Datum (12.08.2026) wird nach YYYY-MM-DD normalisiert", () => {
|
||||
const out = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "12.08.2026", time: "17:45", confidence: 0.9 } })
|
||||
);
|
||||
expect(out.date.isoDate).toBe("2026-08-12");
|
||||
});
|
||||
|
||||
test("D-4: Gültiges Datum bleibt erhalten", () => {
|
||||
const out = sanitizeExtractionOutput(validReceipt());
|
||||
expect(out.date.isoDate).toBe("2026-08-12");
|
||||
});
|
||||
|
||||
test("D-5: Uhrzeit nur als striktes HH:MM (24h)", () => {
|
||||
const outBadHour = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "2026-08-12", time: "25:99", confidence: 0.9 } })
|
||||
);
|
||||
expect(outBadHour.date.time).toBeNull();
|
||||
|
||||
const outBadPad = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "2026-08-12", time: "9:05", confidence: 0.9 } })
|
||||
);
|
||||
expect(outBadPad.date.time).toBeNull();
|
||||
|
||||
const outGood = sanitizeExtractionOutput(
|
||||
validReceipt({ date: { isoDate: "2026-08-12", time: "08:42", confidence: 0.9 } })
|
||||
);
|
||||
expect(outGood.date.time).toBe("08:42");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Enums & Währung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeExtractionOutput: Enums & Währung", () => {
|
||||
test("E-1: documentType außerhalb des Enums → SONSTIGES", () => {
|
||||
const out = sanitizeExtractionOutput(validReceipt({ documentType: "QUITTUNG" as any }));
|
||||
expect(out.documentType).toBe("SONSTIGES");
|
||||
|
||||
const outUndefined = sanitizeExtractionOutput(validReceipt({ documentType: undefined as any }));
|
||||
expect(outUndefined.documentType).toBe("SONSTIGES");
|
||||
});
|
||||
|
||||
test("E-2: suggestedCategory außerhalb des Enums → Sonstiges", () => {
|
||||
const out = sanitizeExtractionOutput(validReceipt({ suggestedCategory: "Hobby" as any }));
|
||||
expect(out.suggestedCategory).toBe("Sonstiges");
|
||||
});
|
||||
|
||||
test("E-3: currency nur A–Z, ≤ 8 Zeichen, sonst EUR", () => {
|
||||
expect(sanitizeExtractionOutput(validReceipt({ currency: "usd" })).currency).toBe("USD");
|
||||
expect(sanitizeExtractionOutput(validReceipt({ currency: " chf " })).currency).toBe("CHF");
|
||||
expect(sanitizeExtractionOutput(validReceipt({ currency: "€" })).currency).toBe("EUR");
|
||||
expect(sanitizeExtractionOutput(validReceipt({ currency: "SUPERLANGE_WAEHRUNG" })).currency).toBe("EUR");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Arrays & Integrität
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeExtractionOutput: Arrays & Integrität", () => {
|
||||
test("A-1: > 200 lineItems werden auf 200 gekappt", () => {
|
||||
const items = Array.from({ length: 250 }, (_, i) => ({
|
||||
description: `Artikel ${i}`,
|
||||
quantity: 1,
|
||||
price: 1,
|
||||
unitPrice: null,
|
||||
taxRate: null,
|
||||
}));
|
||||
const out = sanitizeExtractionOutput(validReceipt({ lineItems: items }));
|
||||
expect(out.lineItems).toHaveLength(200);
|
||||
expect(out.lineItems[199].description).toBe("Artikel 199");
|
||||
});
|
||||
|
||||
test("A-2: > 10 taxBreakdown-Einträge werden auf 10 gekappt", () => {
|
||||
const taxes = Array.from({ length: 15 }, (_, i) => ({ ratePercent: i, taxAmount: 1, netAmount: 10 }));
|
||||
const out = sanitizeExtractionOutput(validReceipt({ taxBreakdown: taxes }));
|
||||
expect(out.taxBreakdown).toHaveLength(10);
|
||||
});
|
||||
|
||||
test("A-3: Gültige Eingabe bleibt unverändert (Round-Trip)", () => {
|
||||
const input = validReceipt();
|
||||
const out = sanitizeExtractionOutput(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
test("A-4: Eingabe-Objekt wird NICHT mutiert (JSON-Vergleich)", () => {
|
||||
const input = validReceipt({
|
||||
merchant: { ...validReceipt().merchant, name: "REWE\u0000City" },
|
||||
totalAmount: { value: -5e9, confidence: 0.9 },
|
||||
});
|
||||
const before = JSON.stringify(input);
|
||||
sanitizeExtractionOutput(input);
|
||||
expect(JSON.stringify(input)).toBe(before);
|
||||
});
|
||||
|
||||
test("A-5: Nicht-modellierte Felder (id, imageHash, previewUrl, createdAt) bleiben unangetastet", () => {
|
||||
const withMeta = {
|
||||
...validReceipt(),
|
||||
id: "rec-1",
|
||||
imageHash: "abc123",
|
||||
previewUrl: "https://example.com/preview.jpg",
|
||||
createdAt: "2026-08-12T10:00:00.000Z",
|
||||
};
|
||||
const out = sanitizeExtractionOutput(withMeta);
|
||||
expect(out.id).toBe("rec-1");
|
||||
expect(out.imageHash).toBe("abc123");
|
||||
expect(out.previewUrl).toBe("https://example.com/preview.jpg");
|
||||
expect(out.createdAt).toBe("2026-08-12T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
runAllTests().then((ok) => process.exit(ok ? 0 : 1));
|
||||
}
|
||||
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