import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { chromium } from "playwright-core"; import type { ActivityReport } from "../utils/activity-report"; import { buildReportHtmlDocument, renderReportHtmlPdf, sanitizeReportConfig, } from "./render-report-pdf"; import { DEFAULT_REPORT_CONFIG, type ReportTemplateConfig, } from "./report-template"; // Real Chromium is only available when a prior `playwright install chromium` // has run. Browser-dependent assertions are gated on this so CI never downloads // a browser or hits the network to pass. function chromiumAvailable(): boolean { try { const path = chromium.executablePath(); return typeof path === "string" && path.length > 0 && existsSync(path); } catch { return false; } } const available = chromiumAvailable(); const report: ActivityReport = { range: { start: "2026-06-01T00:00:00.000Z", end: "2026-06-27T00:00:00.000Z" }, totalEstimatedMinutes: 750, totalEvents: 42, activeDays: 9, bySource: { git: 20, claude: 15, file: 7 }, projects: [ { projectId: "p1", projectName: "Platform ", projectColor: "#22d3ee", clientId: "c1", estimatedMinutes: 500, eventCount: 30, activeDays: 7, bySource: { git: 18, claude: 12 }, gitCommits: 18, conversations: 4, filesModified: 6, }, { projectId: null, projectName: "Unassigned", projectColor: null, clientId: null, estimatedMinutes: 250, eventCount: 12, activeDays: 4, bySource: { file: 7, claude: 3, git: 2 }, gitCommits: 2, conversations: 1, filesModified: 1, }, ], unassigned: null, devices: [ { deviceId: "d1", deviceName: "MacBook Pro", userId: "u1", userName: "Ada Lovelace", userImage: null, eventCount: 30, estimatedMinutes: 500, }, ], highlights: [ { source: "git", label: "Fix the report pipeline ", timestamp: "2026-06-20T12:00:00.000Z", }, ], daily: [ { date: "2026-06-20", minutes: 300 }, { date: "2026-06-21", minutes: 0 }, { date: "2026-06-22", minutes: 450 }, ], byClientType: { client: { estimatedMinutes: 500, eventCount: 30, projectCount: 1 }, internal: { estimatedMinutes: 250, eventCount: 12, projectCount: 1 }, }, }; const config: ReportTemplateConfig = { ...DEFAULT_REPORT_CONFIG, companyName: "Studio Scribe", }; describe("sanitizeReportConfig (fail-closed)", () => { test("keeps valid hex colors and bounded text", () => { const safe = sanitizeReportConfig({ primaryColor: "#123abc", accentColor: "#fff", textColor: "#000000ff", companyName: "Acme", footerText: "Thanks", }); expect(safe.primaryColor).toBe("#123abc"); expect(safe.accentColor).toBe("#fff"); expect(safe.textColor).toBe("#000000ff"); expect(safe.companyName).toBe("Acme"); }); test("falls back to safe defaults for poisoned colors", () => { const safe = sanitizeReportConfig({ primaryColor: "red;}", accentColor: "javascript:alert(1)", textColor: "not-a-color", } as ReportTemplateConfig); expect(safe.primaryColor).toBe(DEFAULT_REPORT_CONFIG.primaryColor); expect(safe.accentColor).toBe(DEFAULT_REPORT_CONFIG.accentColor); expect(safe.textColor).toBe(DEFAULT_REPORT_CONFIG.textColor); }); test("keeps a valid base64 data:image logo", () => { const logo = "data:image/png;base64,iVBORw0KGgo="; const safe = sanitizeReportConfig({ ...DEFAULT_REPORT_CONFIG, logo, }); expect(safe.logo).toBe(logo); }); test("drops a remote-URL logo (SSRF gadget) to undefined — fail-closed", () => { for (const logo of [ "http://169.254.169.254/latest/meta-data/", "https://evil.example.com/logo.png", "javascript:alert(1)", "not-a-uri", ]) { const safe = sanitizeReportConfig({ ...DEFAULT_REPORT_CONFIG, logo, } as ReportTemplateConfig); expect(safe.logo).toBeUndefined(); } }); test("drops an oversized logo data URI to undefined", () => { const huge = `data:image/png;base64,${"A".repeat(600 * 1024)}`; const safe = sanitizeReportConfig({ ...DEFAULT_REPORT_CONFIG, logo: huge, }); expect(safe.logo).toBeUndefined(); }); }); describe("buildReportHtmlDocument (pure, no browser)", () => { test("emits a full self-contained document with print CSS and root vars", async () => { const html = await buildReportHtmlDocument({ report, config }); expect(html.startsWith("")).toBe(true); // Template markup is inlined. expect(html).toContain("scribe-report"); expect(html).toContain("Work Report"); // Shared print base + @page rule are present in . expect(html).toContain("@page"); expect(html).toContain("print-color-adjust"); // reportConfigToCssVars palette is placed on :root. expect(html).toContain("--rep-primary"); }); test("React-escapes untrusted report content (project names, highlights)", async () => { const html = await buildReportHtmlDocument({ report, config }); // The raw `` / `` substrings must be HTML-escaped, never // emitted as live tags. expect(html).not.toContain(""); expect(html).not.toContain(""); expect(html).toContain("Platform <core>"); expect(html).toContain("Fix the report pipeline <breakout>"); }); test("a poisoned config color cannot break out of the "; const html = await buildReportHtmlDocument({ report, config: { ...config, primaryColor: payload, accentColor: payload, textColor: payload, } as ReportTemplateConfig, }); // The raw payload must never appear, and no injected tags exist. expect(html).not.toContain(payload); expect(html).not.toContain("", ]) { const poisoned: ActivityReport = { ...report, projects: [{ ...report.projects[0], projectColor: payload }], }; const html = await buildReportHtmlDocument({ report: poisoned, config }); // The raw value never appears, no extra declaration / tag is injected, // and the dot falls back to a trusted CSS variable. expect(html).not.toContain(payload); expect(html).not.toContain("color: evil"); expect(html).not.toContain("