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:
340
tests/security/prompt_injection.test.ts
Normal file
340
tests/security/prompt_injection.test.ts
Normal 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 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));
|
||||
}
|
||||
Reference in New Issue
Block a user