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>
511 lines
19 KiB
JavaScript
511 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Verification for the "Sanitize before storing" hardening:
|
|
* - src/lib/ingest/sanitize.ts (string/number sanitizers + stored-receipt zod schema)
|
|
* - src/lib/export/csvGenerator.ts (CSV formula-injection neutralizer)
|
|
* - excel / pdf generators (values stay plain strings / literal text)
|
|
*
|
|
* Runs under plain Node ≥ 23.6 (native TypeScript type-stripping):
|
|
* node scripts/verify_sanitize.mjs
|
|
*
|
|
* Exit code 0 = all assertions passed, 1 = at least one failed.
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import ExcelJS from "exceljs";
|
|
|
|
import {
|
|
SANITIZE_LIMITS,
|
|
sanitizeText,
|
|
sanitizeMultilineText,
|
|
sanitizeUrl,
|
|
sanitizeCurrency,
|
|
sanitizeReceiptBatch,
|
|
sanitizeReceipt,
|
|
StoredReceiptSchema,
|
|
} from "../src/lib/ingest/sanitize.ts";
|
|
import {
|
|
generateAccountingCsv,
|
|
neutralizeFormulaPrefix,
|
|
} from "../src/lib/export/csvGenerator.ts";
|
|
import { generateDualSheetExcel } from "../src/lib/export/excelGenerator.ts";
|
|
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tiny test runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const failures = [];
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
passed++;
|
|
console.log(` PASS ${name}`);
|
|
} catch (err) {
|
|
failed++;
|
|
failures.push({ name, err });
|
|
console.error(` FAIL ${name}\n ${err && err.message ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function validReceipt(overrides = {}) {
|
|
return {
|
|
id: "rcpt_test_123",
|
|
imageHash: "abc123def456",
|
|
merchant: {
|
|
name: "REWE",
|
|
address: "Hauptstr 1, 10115 Berlin",
|
|
taxId: "DE123456789",
|
|
confidence: 0.98,
|
|
},
|
|
date: { isoDate: "2026-08-15", time: "10:30", confidence: 0.97 },
|
|
documentType: "KASSENBON",
|
|
receiptNumber: "2026-0815-001",
|
|
currency: "EUR",
|
|
totalAmount: { value: 12.34, confidence: 0.99 },
|
|
netAmount: 10.36,
|
|
tipAmount: null,
|
|
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
|
|
lineItems: [
|
|
{ description: "Apfel", quantity: 2, price: 12.34, unitPrice: 6.17, taxRate: 19 },
|
|
],
|
|
suggestedCategory: "Sonstiges",
|
|
validation: {
|
|
isMathValid: true,
|
|
isDuplicateSuspected: false,
|
|
needsUserReview: false,
|
|
reviewField: "none",
|
|
reviewReason: null,
|
|
issues: null,
|
|
userConfirmed: null,
|
|
},
|
|
rawText: "REWE\nApfel 2x 6,17\nSUMME 12,34",
|
|
paymentMethod: "EC_KARTE",
|
|
previewUrl: "https://storage.example/rcpt_test_123.jpg",
|
|
originalFileName: "bon.jpg",
|
|
fileSizeBytes: 12345,
|
|
status: "ready",
|
|
createdAt: "2026-08-15T10:31:00.000Z",
|
|
updatedAt: "2026-08-15T10:31:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function maliciousReceipt() {
|
|
return validReceipt({
|
|
merchant: {
|
|
name: "<script>alert(1)</script>",
|
|
address: "<img src=x onerror=alert(2)>",
|
|
taxId: "javascript:alert(3)",
|
|
confidence: 1,
|
|
},
|
|
receiptNumber: '=HYPERLINK("http://evil.example","x")',
|
|
suggestedCategory: "@SUM(1+1)",
|
|
rawText: '<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007',
|
|
lineItems: [
|
|
{ description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 },
|
|
{ description: "<svg onload=alert(5)>", quantity: 2, price: 0.5, taxRate: null },
|
|
],
|
|
validation: {
|
|
isMathValid: true,
|
|
isDuplicateSuspected: false,
|
|
needsUserReview: true,
|
|
reviewField: "merchant",
|
|
reviewReason: "<script>alert(6)</script>",
|
|
issues: [{ field: "merchant", severity: "warning", message: "<img onerror=alert(7)>" }],
|
|
userConfirmed: null,
|
|
},
|
|
paymentMethod: "EC_KARTE",
|
|
});
|
|
}
|
|
|
|
/** Splits one quoted CSV line (; delimiter) into unquoted cells. */
|
|
function splitCsvLine(line) {
|
|
const cells = [];
|
|
let cur = "";
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (line[i + 1] === '"') {
|
|
cur += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = false;
|
|
}
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ";") {
|
|
cells.push(cur);
|
|
cur = "";
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
}
|
|
cells.push(cur);
|
|
return cells;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. String sanitizer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[1] String sanitizer");
|
|
|
|
test("strips script tags from merchant name", () => {
|
|
assert.equal(sanitizeText("<script>alert(1)</script>", 200), "alert(1)");
|
|
});
|
|
|
|
test("strips img/svg onerror markup entirely (tag content included)", () => {
|
|
// The whole tag — including payload text inside it — is removed.
|
|
assert.equal(sanitizeText('<img src=x onerror=alert(1)>', 200), "");
|
|
assert.equal(sanitizeText("<svg onload=alert(5)>", 200), "");
|
|
});
|
|
|
|
test("strips comments / doctype / CDATA / processing instructions", () => {
|
|
assert.equal(sanitizeText("a<!-- x -->b", 200), "ab");
|
|
assert.equal(sanitizeText("a<!DOCTYPE html>b", 200), "ab");
|
|
assert.equal(sanitizeText("a<![CDATA[ x ]]>b", 200), "ab");
|
|
assert.equal(sanitizeText("a<?php echo 1 ?>b", 200), "ab");
|
|
});
|
|
|
|
test("malformed double-angle markup cannot survive", () => {
|
|
assert.equal(sanitizeText("<<script>alert(1)", 200), "alert(1)");
|
|
});
|
|
|
|
test("strips control chars (NUL, BEL, ESC …) and collapses whitespace", () => {
|
|
assert.equal(sanitizeText(" Hello\u0000World\u0007!\t\t", 200), "HelloWorld!");
|
|
assert.equal(sanitizeText("a\u0000\nb", 200), "a b");
|
|
});
|
|
|
|
test("trims and caps length", () => {
|
|
assert.equal(sanitizeText(" x ", 200), "x");
|
|
assert.equal(sanitizeText("x".repeat(5000), 200).length, 200);
|
|
assert.equal(sanitizeText(12345, 200), ""); // non-string → ""
|
|
});
|
|
|
|
test("multiline OCR sanitizer keeps newlines, strips markup and NUL", () => {
|
|
const out = sanitizeMultilineText("<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007", 1000);
|
|
assert.equal(out, "Zeile 2\nZeile 3");
|
|
assert.ok(!out.includes("<"));
|
|
});
|
|
|
|
test("multiline OCR caps at 50k", () => {
|
|
assert.equal(sanitizeMultilineText("y".repeat(100000), 50000).length, 50000);
|
|
});
|
|
|
|
test("sanitizeUrl drops dangerous schemes and caps length", () => {
|
|
assert.equal(sanitizeUrl("javascript:alert(1)"), undefined);
|
|
assert.equal(sanitizeUrl("vbscript:msgbox(1)"), undefined);
|
|
assert.equal(sanitizeUrl("data:text/html,<script>alert(1)</script>"), undefined);
|
|
assert.equal(sanitizeUrl(" https://example.com/a.jpg "), "https://example.com/a.jpg");
|
|
assert.equal(sanitizeUrl("x".repeat(5000)).length, SANITIZE_LIMITS.previewUrl);
|
|
});
|
|
|
|
test("sanitizeCurrency normalizes and falls back", () => {
|
|
assert.equal(sanitizeCurrency(" chf "), "CHF");
|
|
assert.equal(sanitizeCurrency("EUR"), "EUR");
|
|
assert.equal(sanitizeCurrency(""), "EUR");
|
|
assert.equal(sanitizeCurrency(undefined), "EUR");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Formula-injection neutralizer (CSV export boundary)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[2] CSV formula-injection neutralizer");
|
|
|
|
test("prefixes = + - @ tab CR payloads with a single quote", () => {
|
|
assert.equal(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")'), "'=HYPERLINK(\"http://evil\",\"x\")");
|
|
assert.equal(neutralizeFormulaPrefix("+cmd|' /C calc'!A0"), "'+cmd|' /C calc'!A0");
|
|
assert.equal(neutralizeFormulaPrefix("@SUM(1+1)"), "'@SUM(1+1)");
|
|
assert.equal(neutralizeFormulaPrefix("-cmd|'/C calc'!A0"), "'-cmd|'/C calc'!A0");
|
|
assert.equal(neutralizeFormulaPrefix("\t=1+1"), "'\t=1+1");
|
|
assert.equal(neutralizeFormulaPrefix("\r=1+1"), "'\r=1+1");
|
|
});
|
|
|
|
test("leaves plain numbers (incl. negative credit notes) untouched", () => {
|
|
assert.equal(neutralizeFormulaPrefix("-5,00"), "-5,00");
|
|
assert.equal(neutralizeFormulaPrefix("+1,25"), "+1,25");
|
|
assert.equal(neutralizeFormulaPrefix("123"), "123");
|
|
assert.equal(neutralizeFormulaPrefix(""), "");
|
|
assert.equal(neutralizeFormulaPrefix("'=already-safe"), "'=already-safe");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Stored-receipt schema: malicious input → sanitized safe output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[3] Stored-receipt schema (sanitize before store)");
|
|
|
|
test("malicious receipt is accepted and fully sanitized (no active markup)", () => {
|
|
const result = sanitizeReceiptBatch({ receipts: [maliciousReceipt()] });
|
|
assert.ok(result.ok, `expected ok, got: ${result.error}`);
|
|
const r = result.receipts[0];
|
|
|
|
assert.equal(r.merchant.name, "alert(1)");
|
|
assert.equal(r.merchant.address, null); // whole <img …> tag removed → empty → null
|
|
assert.equal(r.receiptNumber, '=HYPERLINK("http://evil.example","x")'); // inert text at rest
|
|
assert.equal(r.suggestedCategory, "@SUM(1+1)"); // inert text at rest
|
|
assert.equal(r.rawText, "Zeile 2\nZeile 3");
|
|
assert.equal(r.lineItems[0].description, "+cmd|' /C calc'!A0");
|
|
assert.equal(r.lineItems[1].description, ""); // <svg …> tag removed entirely
|
|
assert.equal(r.validation.reviewReason, "alert(6)");
|
|
assert.equal(r.validation.issues[0].message, ""); // <img …> tag removed entirely
|
|
assert.equal(r.validation.issues[0].severity, "warning");
|
|
|
|
const serialized = JSON.stringify(r);
|
|
assert.ok(!serialized.includes("<"), "no angle bracket may survive in stored data");
|
|
assert.ok(!serialized.includes("\u0000"), "no NUL may survive in stored data");
|
|
});
|
|
|
|
test("markup inside date / receiptNumber is stripped, not rejected", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({ date: { isoDate: "2026-<b>08</b>-15", time: null, confidence: 0.9 } })
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].date.isoDate, "2026-08-15");
|
|
});
|
|
|
|
test("legacy German date format is preserved (sanitized), not rejected", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } })
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].date.isoDate, "15.08.2026");
|
|
});
|
|
|
|
test("oversized strings are truncated per policy", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({ merchant: { name: "X".repeat(5000), address: null, taxId: null, confidence: 1 } })
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].merchant.name.length, SANITIZE_LIMITS.merchantName);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Numbers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[4] Numbers");
|
|
|
|
test("negative amounts are kept (credit notes are a domain case)", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40 })
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].totalAmount.value, -50);
|
|
assert.equal(result.receipts[0].netAmount, -40);
|
|
});
|
|
|
|
test("huge amounts are clamped to ±1e10 (fits numeric(12,2))", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({ totalAmount: { value: 1e12, confidence: 1 } })
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].totalAmount.value, SANITIZE_LIMITS.maxAmountAbs);
|
|
});
|
|
|
|
test("tax rate percent is clamped to 0..100", () => {
|
|
const result = sanitizeReceiptBatch(
|
|
validReceipt({
|
|
taxBreakdown: [{ ratePercent: 150, taxAmount: 10, netAmount: 100 }],
|
|
})
|
|
);
|
|
assert.ok(result.ok);
|
|
assert.equal(result.receipts[0].taxBreakdown[0].ratePercent, 100);
|
|
});
|
|
|
|
test("NaN / Infinity amounts are REJECTED (structurally invalid)", () => {
|
|
const nan = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: NaN, confidence: 1 } }));
|
|
assert.ok(!nan.ok);
|
|
assert.ok(nan.error.includes("totalAmount.value"), nan.error);
|
|
|
|
const inf = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: Infinity, confidence: 1 } }));
|
|
assert.ok(!inf.ok);
|
|
});
|
|
|
|
test("non-number in a number field is rejected", () => {
|
|
const result = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: "12.5", confidence: 1 } }));
|
|
assert.ok(!result.ok);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Currency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[5] Currency");
|
|
|
|
test("3-letter ISO currency passes, lowercase is normalized", () => {
|
|
const ok = sanitizeReceiptBatch(validReceipt({ currency: "chf" }));
|
|
assert.ok(ok.ok);
|
|
assert.equal(ok.receipts[0].currency, "CHF");
|
|
});
|
|
|
|
test("missing currency defaults to EUR", () => {
|
|
const r = validReceipt();
|
|
delete r.currency;
|
|
const ok = sanitizeReceiptBatch(r);
|
|
assert.ok(ok.ok);
|
|
assert.equal(ok.receipts[0].currency, "EUR");
|
|
});
|
|
|
|
test("bad currency (symbol / 2-letter / number) is rejected with 400-style error", () => {
|
|
for (const bad of ["€", "US", "euro", 123]) {
|
|
const result = sanitizeReceiptBatch(validReceipt({ currency: bad }));
|
|
assert.ok(!result.ok, `currency ${JSON.stringify(bad)} should be rejected`);
|
|
assert.ok(result.error.includes("currency"), result.error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Batch shape handling (mirrors POST /api/receipts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[6] Batch shapes & limits");
|
|
|
|
test("accepts array, {receipts}, {receipt} and single-receipt bodies", () => {
|
|
const base = validReceipt();
|
|
assert.ok(sanitizeReceiptBatch([base]).ok);
|
|
assert.ok(sanitizeReceiptBatch({ receipts: [base] }).ok);
|
|
assert.ok(sanitizeReceiptBatch({ receipt: base }).ok);
|
|
assert.ok(sanitizeReceiptBatch(base).ok);
|
|
});
|
|
|
|
test("rejects empty payloads and >500 receipts with the historic 400 messages", () => {
|
|
assert.equal(sanitizeReceiptBatch({}).error, "No receipts provided in payload.");
|
|
assert.equal(sanitizeReceiptBatch([]).error, "No receipts provided in payload.");
|
|
assert.equal(sanitizeReceiptBatch(Array(501).fill(validReceipt())).error, "Too many receipts in payload.");
|
|
});
|
|
|
|
test("rejects structurally invalid receipts with a clear indexed error", () => {
|
|
const broken = validReceipt({ merchant: undefined });
|
|
const result = sanitizeReceiptBatch({ receipts: [validReceipt(), broken] });
|
|
assert.ok(!result.ok);
|
|
assert.ok(result.error.includes("index 1"), result.error);
|
|
assert.ok(result.error.includes("merchant"), result.error);
|
|
});
|
|
|
|
test("rejects missing / oversized receipt ids", () => {
|
|
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "" })).ok);
|
|
assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "x".repeat(100) })).ok);
|
|
});
|
|
|
|
test("sanitizeReceipt single variant returns null on failure, sanitized data on success", () => {
|
|
assert.equal(sanitizeReceipt(validReceipt({ currency: "€" })), null);
|
|
const r = sanitizeReceipt(validReceipt({ merchant: { name: "<b>REWE</b>", address: null, taxId: null, confidence: 1 } }));
|
|
assert.ok(r !== null);
|
|
assert.equal(r.merchant.name, "REWE");
|
|
});
|
|
|
|
test("StoredReceiptSchema keeps passthrough fields (boundingBoxes etc.)", () => {
|
|
const r = validReceipt({ boundingBoxes: { merchant: { x: 1, y: 2, width: 3, height: 4 } } });
|
|
const parsed = StoredReceiptSchema.safeParse(r);
|
|
assert.ok(parsed.success);
|
|
assert.deepEqual(parsed.data.boundingBoxes, r.boundingBoxes);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. CSV generator: no cell may start with a formula character
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[7] CSV generator output");
|
|
|
|
function evilCsvReceipt() {
|
|
return validReceipt({
|
|
merchant: { name: '=HYPERLINK("http://evil.example","x")', address: null, taxId: null, confidence: 1 },
|
|
receiptNumber: "+cmd|' /C calc'!A0",
|
|
suggestedCategory: "@SUM(1+1)",
|
|
rawText: "<img src=x onerror=alert(1)>\n<b>bold</b>",
|
|
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
|
|
});
|
|
}
|
|
|
|
test("malicious merchant/receiptNumber/category cells are neutralized in CSV", () => {
|
|
const csv = generateAccountingCsv([evilCsvReceipt()], { locale: "de" });
|
|
|
|
assert.ok(!csv.includes('"=HYPERLINK'), "raw formula must not appear quoted");
|
|
assert.ok(csv.includes("'=HYPERLINK"), "neutralized cell must be prefixed with a quote");
|
|
assert.ok(csv.includes("'+cmd|"), "DDE payload must be neutralized");
|
|
|
|
const lines = csv.replace(/^\uFEFF/, "").split("\r\n").filter((l) => l.length > 0);
|
|
assert.ok(lines.length >= 2, "header + at least one data row");
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
for (const cell of splitCsvLine(lines[i])) {
|
|
assert.ok(
|
|
!/^[=+\-@\t\r]/.test(cell),
|
|
`CSV cell on line ${i + 1} starts with a formula char: ${JSON.stringify(cell)}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("negative credit-note amounts still export as plain numbers", () => {
|
|
const csv = generateAccountingCsv(
|
|
[validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40, currency: "EUR" })],
|
|
{ locale: "de" }
|
|
);
|
|
assert.ok(csv.includes('"-50,00"'), "negative amount stays a number cell");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. Excel generator: user values are written as plain strings, never formulas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[8] Excel generator round-trip");
|
|
|
|
test("merchant '=HYPERLINK(...)' is written as a plain STRING cell, not a formula", async () => {
|
|
const buffer = await generateDualSheetExcel([evilCsvReceipt()], { locale: "de" });
|
|
const wb = new ExcelJS.Workbook();
|
|
await wb.xlsx.load(buffer);
|
|
|
|
const sheet = wb.getWorksheet("Belegübersicht");
|
|
assert.ok(sheet, "overview sheet exists");
|
|
const merchantCell = sheet.getCell(2, 3); // col C = merchant
|
|
assert.equal(typeof merchantCell.value, "string");
|
|
assert.equal(merchantCell.value, '=HYPERLINK("http://evil.example","x")');
|
|
assert.equal(merchantCell.formula, undefined, "cell must not carry a formula");
|
|
|
|
const itemsSheet = wb.getWorksheet("Einzelpositionen Detail");
|
|
assert.ok(itemsSheet, "line-items sheet exists");
|
|
const descCell = itemsSheet.getCell(2, 4); // col D = description
|
|
assert.equal(typeof descCell.value, "string");
|
|
assert.equal(descCell.formula, undefined, "description cell must not carry a formula");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. PDF generator: renders literal text, no formula/HTML interpretation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log("\n[9] PDF generator");
|
|
|
|
test("PDF with malicious strings still generates a valid PDF document", async () => {
|
|
const pdf = await generateReceiptPdf([evilCsvReceipt()], { locale: "de" });
|
|
assert.ok(pdf instanceof Uint8Array && pdf.length > 100);
|
|
const header = Buffer.from(pdf.slice(0, 5)).toString("latin1");
|
|
assert.equal(header, "%PDF-");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Summary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) {
|
|
console.error("\nFailed tests:");
|
|
for (const f of failures) console.error(` - ${f.name}: ${f.err.message}`);
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|