208 lines
5.5 KiB
TypeScript
208 lines
5.5 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { unzipSync } from "fflate";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildSanitizationReport,
|
|
createBatchArchive,
|
|
createBatchReport,
|
|
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);
|
|
});
|
|
});
|
|
|
|
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> = {}): 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,
|
|
};
|
|
}
|