import { describe, expect, it } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { type ComponentType, createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { HTML_INVOICE_TEMPLATE_META, HTML_INVOICE_TEMPLATES, selectHtmlInvoiceTemplate, } from "./index"; import { MandalaInvoiceTemplate } from "./mandala"; import { MinimalInvoiceTemplate } from "./minimal"; import { configToCssVars, DEFAULT_HTML_TEMPLATE_CONFIG, formatInvoiceCurrency, formatInvoiceDate, formatLineQuantity, lineQuantityHeader, minutesToHours, } from "./shared"; import type { InvoiceTemplateConfig, InvoiceTemplateData, InvoiceTemplateProps, } from "./types"; describe("templates-html shared helpers", () => { it("maps config colors to --inv-* CSS variables", () => { const config: InvoiceTemplateConfig = { ...DEFAULT_HTML_TEMPLATE_CONFIG, primaryColor: "#112233", accentColor: "#445566", textColor: "#778899", }; const vars = configToCssVars(config) as Record; expect(vars["--inv-primary"]).toBe("#112233"); expect(vars["--inv-accent"]).toBe("#445566"); expect(vars["--inv-text"]).toBe("#778899"); // Neutral paper palette has a single source of truth here. expect(vars["--inv-paper"]).toBe("#ffffff"); }); it("formats currency from cents", () => { expect(formatInvoiceCurrency(12345, "USD")).toBe("$123.45"); expect(formatInvoiceCurrency(0, "USD")).toBe("$0.00"); }); it("formats dates from strings and Date objects", () => { expect(formatInvoiceDate("2026-01-15T00:00:00.000Z")).toContain("2026"); expect(formatInvoiceDate(new Date("2026-01-15T00:00:00.000Z"))).toContain( "Jan", ); }); it("converts tracked minutes to display hours", () => { expect(minutesToHours(90)).toBe("1.5"); expect(minutesToHours(0)).toBe("0.0"); }); it("formats quantities according to their storage contract", () => { expect(formatLineQuantity({ quantity: 90, quantityType: "hours" })).toBe( "1.5", ); expect(formatLineQuantity({ quantity: 4, quantityType: "units" })).toBe( "4", ); expect(formatLineQuantity({ quantity: 90 })).toBe("1.5"); }); it("uses Hours only when every line is hours-type or unset", () => { expect(lineQuantityHeader([{ quantityType: "hours" }, {}])).toBe("Hours"); expect(lineQuantityHeader([{ quantityType: "units" }])).toBe("Qty"); }); }); describe("templates-html registry", () => { it("exposes both built-in templates", () => { expect(HTML_INVOICE_TEMPLATES.minimal).toBe(MinimalInvoiceTemplate); expect(HTML_INVOICE_TEMPLATES.mandala).toBe(MandalaInvoiceTemplate); }); it("marks only mandala as full bleed", () => { expect(HTML_INVOICE_TEMPLATE_META.minimal.fullBleed).toBe(false); expect(HTML_INVOICE_TEMPLATE_META.mandala.fullBleed).toBe(true); }); it("resolves templates by name and falls back to minimal", () => { expect(selectHtmlInvoiceTemplate("mandala")).toBe(MandalaInvoiceTemplate); expect(selectHtmlInvoiceTemplate("minimal")).toBe(MinimalInvoiceTemplate); expect(selectHtmlInvoiceTemplate("does-not-exist")).toBe( MinimalInvoiceTemplate, ); expect(selectHtmlInvoiceTemplate(null)).toBe(MinimalInvoiceTemplate); expect(selectHtmlInvoiceTemplate(undefined)).toBe(MinimalInvoiceTemplate); }); }); describe("minimal template print layout", () => { it("tightens the bottom stack for print and keeps the footer together", () => { const source = readFileSync(join(import.meta.dir, "minimal.tsx"), "utf8"); const printCss = source.slice(source.indexOf("@media print")); expect(printCss).toContain(".scribe-invoice.t-minimal .si-notes"); expect(printCss).toContain(".scribe-invoice.t-minimal .si-pay-methods"); expect(printCss).toContain(".scribe-invoice.t-minimal .si-footer"); expect(printCss).toContain("break-inside: avoid"); }); }); describe("templates-html quantity and client rendering", () => { const config: InvoiceTemplateConfig = { ...DEFAULT_HTML_TEMPLATE_CONFIG, companyName: "Studio Scribe", }; const invoice: InvoiceTemplateData = { invoiceNumber: "INV-015", issueDate: "2026-07-10T00:00:00.000Z", clientName: "Acme Corp", clientEmail: "billing@acme.example", clientAddress: "123 Main St\nDetroit, MI", subtotal: 4000, taxRate: 0, taxAmount: 0, total: 4000, currency: "USD", lineItems: [ { description: "Travel expense", quantity: 4, quantityType: "units", rate: 1000, amount: 4000, }, ], }; const templates: Array<[string, ComponentType]> = [ ["minimal", MinimalInvoiceTemplate], ["mandala", MandalaInvoiceTemplate], ]; for (const [name, Template] of templates) { it(`renders raw unit quantities and a Qty header in ${name}`, () => { const html = renderToStaticMarkup( createElement(Template, { invoice, config }), ); expect(html).toContain(">Qty<"); expect(html).toContain(">4<"); expect(html).not.toContain(">0.1<"); }); it(`renders decimal hours and an Hours header in ${name}`, () => { const hoursInvoice: InvoiceTemplateData = { ...invoice, lineItems: [ { ...invoice.lineItems[0], quantity: 90, quantityType: "hours", }, ], }; const html = renderToStaticMarkup( createElement(Template, { invoice: hoursInvoice, config }), ); expect(html).toContain(">Hours<"); expect(html).toContain(">1.5<"); }); } it("renders client email and address in mandala", () => { const html = renderToStaticMarkup( createElement(MandalaInvoiceTemplate, { invoice, config }), ); expect(html).toContain("billing@acme.example"); expect(html).toContain("123 Main St\nDetroit, MI"); }); }); describe("templates-html XSS defense", () => { // Adversarial, attacker-controlled strings. The PDF pipeline (OPL-2601) will // run this same markup through a headless browser, so any unescaped payload // would be a live XSS / template-injection sink. const SCRIPT = ""; const IMG = '">'; const BOLD = '">pwn'; const config: InvoiceTemplateConfig = { ...DEFAULT_HTML_TEMPLATE_CONFIG, companyName: SCRIPT, companyAddress: IMG, footerText: BOLD, paymentInstructions: SCRIPT, showProjectBreakdown: true, paymentMethods: [{ type: "wire", label: IMG, details: { Bank: SCRIPT } }], }; const invoice: InvoiceTemplateData = { invoiceNumber: SCRIPT, issueDate: "2026-01-15T00:00:00.000Z", dueDate: "2026-02-15T00:00:00.000Z", periodStart: "2026-01-01T00:00:00.000Z", periodEnd: "2026-01-31T00:00:00.000Z", clientName: IMG, clientEmail: BOLD, clientAddress: SCRIPT, subtotal: 10000, taxRate: 850, taxAmount: 850, total: 10850, currency: "USD", notes: SCRIPT, paymentNotes: IMG, lineItems: [ { id: "1", description: SCRIPT, quantity: 90, rate: 10000, amount: 10000, projectName: IMG, projectColor: "#112233", }, ], workBreakdown: [{ project: SCRIPT, commits: 3, highlights: IMG }], }; const templates: Array<[string, ComponentType]> = [ ["minimal", MinimalInvoiceTemplate], ["mandala", MandalaInvoiceTemplate], ]; for (const [name, Template] of templates) { it(`escapes attacker-controlled fields in the ${name} template`, () => { const html = renderToStaticMarkup( createElement(Template, { invoice, config }), ); // The literal attack payloads must never appear unescaped in the output. expect(html).not.toContain(""); expect(html).not.toContain(""); expect(html).not.toContain("pwn"); // …but the data is present, escaped (React's default text encoding). expect(html).toContain("<script>"); expect(html).toContain("<img src=x onerror=alert(1)>"); }); } it("paints the mandala field from a fixed layer, not the content container", () => { // Regression: with the gradient stack set as the container's own // background, Chromium paints it only across the box each page fragment // occupies. An invoice that overflowed by a couple of lines rendered a // trailing page that was raw white below the fragment. A fixed layer is // repeated on every printed page instead. const html = renderToStaticMarkup( createElement(MandalaInvoiceTemplate, { invoice, config: DEFAULT_HTML_TEMPLATE_CONFIG, }), ); expect(html).toContain('class="si-mandala-field"'); const field = html.slice(html.indexOf(".si-mandala-field")); expect(field).toContain("position: fixed"); // The gradient stack must live on the layer alone; a container copy would // reintroduce the per-fragment paint. const container = html.slice( html.indexOf(".scribe-invoice.t-mandala {"), html.indexOf(".si-mandala-field"), ); expect(container).not.toContain("linear-gradient"); expect(container).toContain("background: none"); }); it("lets the mandala line-items panel split across pages", () => { // Regression: `.si-panel { break-inside: avoid }` applied to the one panel // with unbounded height, so a long item list was pushed whole to the next // page and stranded most of a sheet. Rows keep their own break protection. const html = renderToStaticMarkup( createElement(MandalaInvoiceTemplate, { invoice, config: DEFAULT_HTML_TEMPLATE_CONFIG, }), ); expect(html).toContain('class="si-panel si-panel-flow"'); const flow = html.slice(html.indexOf(".si-panel-flow {")); expect(flow.slice(0, 80)).toContain("break-inside: auto"); // The row rule is the later of the two `.si-item {` blocks (the first is // the grid layout shared with the header row). const itemRule = html.slice( html.lastIndexOf(".scribe-invoice.t-mandala .si-item {"), ); expect(itemRule.slice(0, 160)).toContain("break-inside: avoid"); }); it("never uses dangerouslySetInnerHTML in any template source", () => { const dir = dirname(fileURLToPath(import.meta.url)); const sources = readdirSync(dir).filter( (f) => f.endsWith(".tsx") || (f.endsWith(".ts") && !f.endsWith(".test.ts")), ); expect(sources.length).toBeGreaterThan(0); for (const file of sources) { const contents = readFileSync(join(dir, file), "utf8") // Strip comments so a reference in prose (e.g. "avoids X") doesn't // count — we only care about actual JSX/object usage. .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/\/\/[^\n]*/g, ""); expect(contents).not.toContain("dangerouslySetInnerHTML"); } }); });