// @vitest-environment node import { unzipSync } from "fflate"; import { describe, expect, it } from "vitest"; import { buildSanitizationReport, createBatchArchive, createBatchReport, createPolicyEvidence, createSafeShareReport, genericOutputName, policyById, serializeReport, type ImageScanResult, type MetadataFinding, } from "../../src/privacy"; const sensitive: MetadataFinding = { id: "gps-1", category: "location", risk: "sensitive", source: "EXIF", label: "GPS Latitude", value: "48.1 N", }; describe("sanitization and batch reports", () => { it("marks a complete sensitive-free output verified and records removals", () => { const source = result({ findings: [sensitive] }); const output = result({ id: "clean", name: "clean.png", findings: [] }); const report = buildSanitizationReport( source, output, "clean.png", "image/png", 20, comparison(true), ); expect(report.status).toBe("verified"); expect(report.removed).toEqual([sensitive]); expect(report.preserved).toEqual([]); expect(report.orientationNormalized).toBe(true); expect(report.disclaimer).toMatch(/not an anonymity guarantee/iu); }); it("fails a partial or sensitive output re-scan", () => { const source = result({ findings: [sensitive] }); const output = result({ id: "clean", findings: [sensitive], coverage: { projectScanner: "partial", secondaryScanner: "complete", notes: [], }, }); const report = buildSanitizationReport( source, output, "clean.png", "image/png", 20, comparison(false), ); expect(report.status).toBe("failed"); expect(report.preserved).toEqual([sensitive]); expect(report.incomplete).not.toEqual([]); }); it("reports an unknown browser-generated output chunk as a warning", () => { const source = result(); const output = result({ id: "clean", findings: [ { id: "private-1", category: "unknown", risk: "context", source: "PNG", label: "Private chunk deBG", value: "16 bytes", }, ], blocks: [{ kind: "deBG", offset: 40, length: 16 }], }); const report = buildSanitizationReport( source, output, "clean.png", "image/png", 20, comparison(true), ); expect(report.status).toBe("warning"); expect(report.generated).toHaveLength(1); expect(report.incomplete.join(" ")).toContain("deBG"); }); it("fails a changed decoded sample for lossless PNG", () => { const report = buildSanitizationReport( result(), result({ id: "clean" }), "clean.png", "image/png", 20, comparison(false), ); expect(report.status).toBe("failed"); expect(report.incomplete.join(" ")).toMatch(/pixel sample/iu); }); it("produces deterministic, safe JSON and a stored batch ZIP", async () => { const source = result({ name: "../private.png", findings: [sensitive] }); const output = result({ id: "clean", name: "private.clean.png" }); const report = buildSanitizationReport( source, output, "../same.png", "image/png", 3, comparison(true), ); const assets = [ { blob: new Blob([Uint8Array.of(1, 2, 3)]), report }, { blob: new Blob([Uint8Array.of(4, 5, 6)]), report }, ]; const generatedAt = "2026-09-01T00:00:00.000Z"; const batch = createBatchReport([source], assets, generatedAt); const json = serializeReport(batch); expect(JSON.parse(json)).toMatchObject({ schemaVersion: 1, generatedAt }); expect(json).toContain("report may itself contain sensitive"); const archive = await createBatchArchive([source], assets, generatedAt); const entries = unzipSync(new Uint8Array(await archive.arrayBuffer())); expect(Object.keys(entries).sort()).toEqual([ "images/_same-2.png", "images/_same.png", "privacy-tools-report.json", ]); expect( JSON.parse( new TextDecoder().decode(entries["privacy-tools-report.json"]), ), ).toMatchObject({ schemaVersion: 1, generatedAt }); }); it("bounds archive entry count before reading output blobs", async () => { const source = result(); const output = result({ id: "clean" }); const report = buildSanitizationReport( source, output, "clean.png", "image/png", 1, comparison(true), ); const asset = { blob: new Blob([Uint8Array.of(0)]), report }; await expect( createBatchArchive( [source], Array.from({ length: 101 }, () => asset), ), ).rejects.toThrow(/Archive image count/iu); }); it("builds a pseudonymized safe-share report and generic names", () => { const source = result({ id: "private-id", name: "Alice-at-home.png", sha256: "secret-hash", findings: [sensitive], coverage: { projectScanner: "complete", secondaryScanner: "complete", notes: ["source metadata value"], }, }); const output = result({ id: "clean", name: "output.png" }); const report = buildSanitizationReport( source, output, "Alice-at-home.clean.png", "image/png", 20, comparison(true), ); const safe = serializeReport( createSafeShareReport( [source], [{ blob: new Blob(), report }], "2026-09-01T00:00:00.000Z", ), ); expect(safe).toContain('"profile": "safe-share"'); expect(safe).toContain("image-001.clean.png"); expect(safe).not.toContain("Alice"); expect(safe).not.toContain("secret-hash"); expect(safe).not.toContain("48.1 N"); expect(safe).not.toContain("source metadata value"); expect(genericOutputName(9, "jpeg")).toBe("image-010.clean.jpg"); }); it("evaluates reusable policies across cleanable and inspect-only formats", () => { const cleanable = result({ findings: [sensitive] }); const unsupported = result({ id: "pdf", name: "document.pdf", identity: { claimedType: "application/pdf", extension: "pdf", detectedKind: "pdf", detectedType: "application/pdf", typeMatch: "match", }, deepSupported: false, cleanable: false, findings: [sensitive], coverage: { projectScanner: "unsupported", secondaryScanner: "unsupported", notes: [], }, }); const strict = createPolicyEvidence( [cleanable, unsupported], [], policyById("safe-share"), "2026-09-01T00:00:00.000Z", ); expect(strict.files[0]).toMatchObject({ decision: "review", availableOperation: "reencode-available", findings: { remove: 1 }, }); expect(strict.files[1]).toMatchObject({ decision: "blocked", availableOperation: "inspect-only", }); expect(JSON.stringify(strict)).not.toContain("document.pdf"); const selective = createPolicyEvidence( [ result({ findings: [ sensitive, { ...sensitive, id: "author", category: "identity" }, ], }), ], [], policyById("location-only"), ); expect(selective.files[0]?.availableOperation).toBe( "policy-not-executable", ); }); }); function comparison(identical: boolean) { return { method: "oriented-256px-sample" as const, sourceDigest: "source-sample", outputDigest: identical ? "source-sample" : "output-sample", identical, note: "test comparison", }; } function result(overrides: Partial = {}): ImageScanResult { return { id: "source", name: "source.png", safeName: "source.png", size: 100, lastModified: 0, sha256: "a".repeat(64), identity: { claimedType: "image/png", extension: "png", detectedKind: "png", detectedType: "image/png", typeMatch: "match", }, width: 2, height: 1, orientation: 1, animated: false, multiImage: false, deepSupported: true, cleanable: true, findings: [], blocks: [], warnings: [], coverage: { projectScanner: "complete", secondaryScanner: "complete", notes: [], }, ...overrides, }; }