/** * Invoice rendering tests — DC-058. * * Pure functions. No live network, no SMTP, no Stripe SDK. Covers: * - HTML escaping for every user-controlled field * - CRLF/control-char neutralization (SMTP header injection defense) * - Plain-text fallback has the same content * - PDF is a valid PDF (magic bytes + loadable by pdf-parse) * - Invoice number derived from event id (deterministic) * - Catalog integration: missing productId still produces valid output * * Pairs with stripe-license-bridge.test.js (which covers the SMTP wiring * on top of these primitives). */ const path = require('path'); const fs = require('fs'); const invoice = require('../../src/billing/invoice'); const catalog = require('../../src/billing/catalog'); // pdf-parse is the canonical tool to extract text from a PDF buffer for // verification. We keep it as a soft dependency — if it's not available, // the text-content tests skip rather than fail. let pdfParse = null; try { pdfParse = require('pdf-parse'); } catch (_) { pdfParse = null; } const BASE = { email: 'alice@example.com', customerName: 'Alice Johnson', code: 'DC-PRO-30D-AB12CD34', durationDays: 30, productLabel: '1 month', productId: 'pro-30d', amountCents: 2000, currency: 'USD', eventId: 'evt_4f2c9b3a8b1d', sessionId: 'cs_test_a1b2c3d4e5', supportUrl: 'https://dashcaddy.net', }; describe('billing/invoice', () => { describe('generateInvoiceNumber', () => { test('strips evt_ prefix and produces INV-{8 hex chars}', () => { expect(invoice.generateInvoiceNumber('evt_4f2c9b3a8b1d')).toBe('INV-4F2C9B3A'); }); test('uppercases mixed-case event ids', () => { expect(invoice.generateInvoiceNumber('evt_AbCdEf1234')).toBe('INV-ABCDEF12'); }); test('falls back to NOEVENT for empty/missing input', () => { expect(invoice.generateInvoiceNumber('')).toBe('INV-NOEVENT'); expect(invoice.generateInvoiceNumber(null)).toBe('INV-NOEVENT'); expect(invoice.generateInvoiceNumber(undefined)).toBe('INV-NOEVENT'); }); test('handles event id without prefix', () => { expect(invoice.generateInvoiceNumber('4f2c9b3a8b1d')).toBe('INV-4F2C9B3A'); }); }); describe('stripControlChars', () => { test('replaces CRLF with single space (prevents SMTP header injection)', () => { const input = 'alice@example.com\r\nBcc: attacker@evil.com'; const output = invoice.stripControlChars(input); expect(output).toBe('alice@example.com Bcc: attacker@evil.com'); expect(output).not.toContain('\r'); expect(output).not.toContain('\n'); }); test('collapses whitespace runs', () => { expect(invoice.stripControlChars(' alice example ')).toBe('alice example'); }); test('handles null/undefined gracefully', () => { expect(invoice.stripControlChars(null)).toBe(''); expect(invoice.stripControlChars(undefined)).toBe(''); }); test('preserves printable unicode (accents, emoji)', () => { expect(invoice.stripControlChars('Sami Ahmed 🚀')).toBe('Sami Ahmed 🚀'); }); }); describe('escapeHtml', () => { test('escapes all HTML metacharacters', () => { expect(invoice.escapeHtml('')) .toBe('<script>alert(1)</script>'); expect(invoice.escapeHtml(`"O'Brien & Sons"`)) .toBe('"O'Brien & Sons"'); }); test('handles null/undefined', () => { expect(invoice.escapeHtml(null)).toBe(''); expect(invoice.escapeHtml(undefined)).toBe(''); }); }); describe('renderLicenseEmailHtml', () => { test('renders branded HTML with license code, invoice number, and price', () => { const { subject, html } = invoice.renderLicenseEmailHtml(BASE); expect(subject).toContain('DashCaddy Pro'); expect(subject).toContain('30 days'); expect(html).toContain('DC-PRO-30D-AB12CD34'); expect(html).toContain('INV-4F2C9B3A'); expect(html).toContain('$20.00'); expect(html).toContain('Alice'); // first name from customerName expect(html).toContain('alice@example.com'); // Brand colors must match the rest of DashCaddy expect(html).toContain('#09111f'); // bg expect(html).toContain('#7cf2c0'); // pro accent expect(html).toContain('#68a4ff'); // accent }); test('uses a friendly greeting when customerName is missing', () => { const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '' }); expect(html).toContain('Hi there,'); expect(html).not.toContain('Hi ,'); }); test('does NOT include any CR or LF in user-controlled regions (CRLF injection defense)', () => { // Use NO-SPACE-after-colon payloads so that if `stripControlChars` // were deleted, the rendered output would contain "Bcc:attacker" // (header-injection survivors, no spaces between the colon and value). // The earlier version used "Bcc: attacker" (with space) which the // rendered output also had — the regex /Bcc:[^\s<]/ could not match // either way, so the test passed vacuously regardless of whether // sanitization actually ran. const malicious = { ...BASE, email: 'alice@example.com\r\nBcc:attacker@evil.com', customerName: 'Eve\r\nBcc:eve@evil.com', code: 'X\r\nY', eventId: 'evt_\r\nfakeHeader:1', }; const { html } = invoice.renderLicenseEmailHtml(malicious); // CRITICAL: no \r anywhere (template source has no \r). expect(html).not.toMatch(/\r/); // Extract each user-controlled region and assert no \n AND no // unbroken "Bcc:" header-injection survivors. Each region // comes from the email/customerName/code/eventId values; if any // contains a \n OR a "Bcc:" without a space-after-colon, the test // fails. This is the strongest possible assertion: deleting // stripControlChars would break it immediately. const patterns = [ { name: 'email', re: /Email[^<]*]+>([^<]+)<\/a>/ }, { name: 'name', re: /(?:Thanks for your purchase, |Hi )([^]*word-break[^>]*>([^<]+)<\/div>/ }, { name: 'eventId', re: /Stripe event[^<]*]+>([^<]+)<\/a>/ }, ]; for (const { name, re } of patterns) { const m = html.match(re); if (m) { expect(m[1]).not.toMatch(/\n/); expect(m[1]).not.toMatch(/Bcc:[^\s<]/); // header-injection survivor expect(m[1]).not.toMatch(/fakeHeader:[^\s<]/); } } }); test('escapes HTML in customer name (XSS defense)', () => { const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '', }); expect(html).not.toContain('', 'file:///etc/passwd', 'vbscript:msgbox(1)', 'ftp://example.com', ]) { const { html } = invoice.renderLicenseEmailHtml({ ...BASE, supportUrl: badUrl }); expect(html).not.toContain('javascript:'); expect(html).not.toContain('data:text/html'); expect(html).not.toContain('file:///'); expect(html).not.toContain('vbscript:'); // Falls back to the canonical https URL. expect(html).toContain('https://dashcaddy.net'); } }); test('long license code (>24 chars) wraps instead of overflowing PDF', async () => { // 50-char code would overflow the 484px Courier-Bold box at 13pt. const longCode = 'DC-PRO-30D-' + 'X'.repeat(40); const buf = await invoice.renderInvoicePdf({ ...BASE, code: longCode }); expect(buf.length).toBeGreaterThan(1000); // PDFKit handles lineBreak:true by wrapping inside the box; we just // need to verify the PDF is structurally valid (parsed by pdf-parse). const pdfParse = require('pdf-parse'); const { text } = await pdfParse(buf); // The key body should be in there somewhere — even if wrapped across // lines, at least part of the code is extractable. expect(text).toMatch(/DC-PRO-30D/); }); test('PDF Info Subject is constant (does NOT echo customer name or email)', async () => { // A customer-influenceable string in PDF metadata (visible in every // PDF reader's Properties panel) is a phishing-recon signal even // though it's not XSS-executable. The Subject field MUST be a // constant; the customer-identifying info lives in the visible body. const buf = await invoice.renderInvoicePdf({ ...BASE, customerName: '', email: 'evil@attacker.com', }); const pdfParse = require('pdf-parse'); // Pass version option to extract metadata (some pdf-parse versions // require explicit hint to parse Info dictionary). const { metadata, text } = await pdfParse(buf, { version: 'default' }); // If pdf-parse still doesn't extract metadata, fall back to scanning // the binary for the Subject string. Either way, the assertion holds. if (metadata) { expect(metadata.Subject).toBe('DashCaddy Pro invoice'); } else { // The Subject is stored as an indirect object reference in the PDF; // it might not parse cleanly. Look for the constant in the binary // string form (PDFKit may encode it as UTF-16BE or octal escapes). const bin = buf.toString('binary'); // The escaped form of "DashCaddy Pro invoice" in PDF literal strings // is the literal text wrapped in parentheses, possibly octal-escaped. // We just verify the email/HTML-payload is NOT in the metadata object // references — search for the literal Subject string body. const subjectObj = bin.match(/\/Subject\s*\(([^)]+)\)/); if (subjectObj) { expect(subjectObj[1]).not.toContain('evil@attacker.com'); expect(subjectObj[1]).not.toContain(''); }); test('rejects non-numeric amountCents (string "2000" would silently render $0.00)', () => { // STRING amount used to silently fall through to $0.00 because // Number.isFinite('2000') is false. Now we throw, surfacing the bug // at the bridge instead of shipping a $0 invoice to a paying customer. // We strip productId so the catalog fallback doesn't rescue the bad input. const { productId, ...baseNoProduct } = BASE; expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: '2000' })) .toThrow(/amountCents must be a positive integer/); }); test('rejects NaN, Infinity, negative, and zero amountCents', () => { const { productId, ...baseNoProduct } = BASE; for (const bad of [NaN, Infinity, -Infinity, -100, 0]) { expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: bad })) .toThrow(/amountCents must be a positive integer/); } }); test('falls back to catalog amount when amountCents is null AND productId resolves', () => { // Bridge contract: if amountCents is missing from the Stripe session // (older sessions, expand failure), we use the catalog's canonical // price rather than throwing. This is the recovery path. const html = invoice.renderLicenseEmailHtml({ ...BASE, productId: 'pro-30d', amountCents: null, }).html; // catalog says pro-30d = $20.00 (2000 cents) expect(html).toContain('$20.00'); }); test('fractional cents are floored (no silent $0.01 from $0.005 rounding)', () => { // 2000.7 cents should render as $20.00 (floored). The bridge should // never send fractional cents in practice, but defense-in-depth. const html = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 2000.7 }).html; expect(html).toContain('$20.00'); expect(html).not.toContain('$20.01'); }); test('uses embedded SVG logo (works offline, no remote fetch)', () => { const { html } = invoice.renderLicenseEmailHtml(BASE); expect(html).toMatch(/src="data:image\/svg\+xml/); expect(html).not.toMatch(/src="https?:\/\//); }); }); describe('renderLicenseEmailText', () => { test('includes license code, invoice #, and amount', () => { const text = invoice.renderLicenseEmailText(BASE); expect(text).toContain('DC-PRO-30D-AB12CD34'); expect(text).toContain('INV-4F2C9B3A'); expect(text).toContain('$20.00'); expect(text).toContain('Stripe event'); expect(text).toContain('evt_4f2c9b3a8b1d'); }); test('uses first name from customerName when present', () => { const text = invoice.renderLicenseEmailText({ ...BASE, customerName: 'Alice Johnson', }); expect(text.split('\n')[0]).toBe('Hi Alice,'); }); test('falls back to "Hi there," when customerName missing', () => { const text = invoice.renderLicenseEmailText({ ...BASE, customerName: '' }); expect(text.split('\n')[0]).toBe('Hi there,'); }); }); describe('renderInvoicePdf', () => { test('produces a valid PDF (magic bytes + non-trivial size)', async () => { const buf = await invoice.renderInvoicePdf(BASE); expect(buf.length).toBeGreaterThan(1000); expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF'); // PDF must end with %%EOF (or trailing newline + %%EOF) const tail = buf.slice(-32).toString('ascii'); expect(tail).toContain('%%EOF'); }); test('PDF contains the license code (visible text)', async () => { if (typeof pdfParse !== 'function') return; // soft skip if pdf-parse unavailable const buf = await invoice.renderInvoicePdf(BASE); const { text } = await pdfParse(buf); expect(text).toContain('DC-PRO-30D-AB12CD34'); }); test('PDF contains the invoice number and amount', async () => { if (typeof pdfParse !== 'function') return; const buf = await invoice.renderInvoicePdf(BASE); const { text } = await pdfParse(buf); expect(text).toContain('INV-4F2C9B3A'); expect(text).toContain('20.00'); }); test('PDF includes customer name and email in bill-to', async () => { if (typeof pdfParse !== 'function') return; const buf = await invoice.renderInvoicePdf(BASE); const { text } = await pdfParse(buf); expect(text).toContain('Alice Johnson'); expect(text).toContain('alice@example.com'); }); test('rejects when code is missing', () => { // The invoice builder now returns a rejected promise for invalid input // (validated synchronously, surfaced via Promise.reject before any PDFKit // allocation). Use .rejects for the async side and the sync-style // expect().toThrow for the inline check. return expect(invoice.renderInvoicePdf({ ...BASE, code: '' })) .rejects.toThrow('code is required'); }); }); describe('catalog integration', () => { test('all 4 catalog products render without throwing', async () => { const products = catalog.listProducts(); for (const product of products) { const input = { ...BASE, productId: product.id, productLabel: product.label, durationDays: product.durationDays, amountCents: product.amountCents, }; const { subject, html } = invoice.renderLicenseEmailHtml(input); expect(subject).toContain(`${product.durationDays} days`); expect(html).toContain(`$${(product.amountCents / 100).toFixed(2)}`); const pdf = await invoice.renderInvoicePdf(input); expect(pdf.slice(0, 4).toString('ascii')).toBe('%PDF'); if (typeof pdfParse === 'function') { const { text } = await pdfParse(pdf); expect(text).toContain(product.label); } } }); }); describe('security: XSS via customer-controlled fields', () => { // These should all escape, not execute. We don't render the email // anywhere — this is just defense-in-depth at the template layer. test.each([ ['customerName', ''], ['email', '">'], ['code', '">'], ['eventId', '">'], ['sessionId', '">'], ])('field %s XSS payload is escaped', async (field, payload) => { const { html } = invoice.renderLicenseEmailHtml({ ...BASE, [field]: payload }); // The exact attack strings must not appear unescaped. expect(html).not.toContain(payload); // Escaped versions should be present (defense-in-depth visible). expect(html).toContain('<'); }); test('img tag with onerror handler is fully escaped', () => { const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '', }); // The payload is HTML-escaped: < and > become < / > expect(html).toContain('<img src=x onerror=alert(1)>'); // The dangerous literal pattern must not appear. expect(html).not.toMatch(/]+onerror/i); }); }); });