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