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