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:
Timo
2026-08-19 20:59:04 +02:00
parent 650a74da97
commit 84b9987c49
415 changed files with 96619 additions and 0 deletions

View 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);
});
}

View 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));
}

View 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 AZ, ≤ 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));
}

View 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);
});
}