Files
scan-receipts/tests/security/prompt_injection.test.ts
Timo 84b9987c49 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>
2026-08-19 20:59:04 +02:00

341 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
}