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>
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|