import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import sharp from "sharp";
import { createPortraitClearanceTaskRequestSchema, portraitClearanceSettingsSchema, riskFromFaceSimilarity } from "../src/portrait-clearance/contracts.js";
import { deduplicatePortraitCandidates } from "../src/portrait-clearance/dedup.js";
import { computePHash, cosineSimilarity, hammingDistance, imageQuality, structuralSimilarity } from "../src/portrait-clearance/image-metrics.js";
import { buildPortraitReports } from "../src/portrait-clearance/reports.js";
import { downloadPortraitCandidate, validatePublicUrl } from "../src/portrait-clearance/safe-image-download.js";
import { PortraitTaskStore } from "../src/portrait-clearance/task-store.js";
import { installPortraitModels } from "../src/portrait-clearance/model-store.js";
test("portrait risk thresholds and cosine similarity are deterministic", () => {
assert.equal(riskFromFaceSimilarity(undefined), "unable_to_determine");
assert.equal(riskFromFaceSimilarity(0.65), "high");
assert.equal(riskFromFaceSimilarity(0.5), "medium");
assert.equal(riskFromFaceSimilarity(0.35), "low_to_medium");
assert.equal(riskFromFaceSimilarity(0.34), "low");
assert.equal(cosineSimilarity([1, 0], [1, 0]), 1);
assert.equal(cosineSimilarity([1, 0], [0, 1]), 0);
});
test("portrait model concurrency accepts ten and rejects values above the hard limit", () => {
const settings = {
maxCandidates: 30,
searchScrolls: 5,
dedupMode: "phash" as const,
modelConcurrency: 10,
showBrowserForDebug: false,
};
assert.equal(portraitClearanceSettingsSchema.parse(settings).modelConcurrency, 10);
assert.throws(() => portraitClearanceSettingsSchema.parse({ ...settings, modelConcurrency: 11 }));
});
test("quality boundaries, SSIM and pHash remain finite for small fixtures", () => {
const flat = new Float32Array(16 * 16).fill(128);
const contrast = new Float32Array(16 * 16);
for (let index = 0; index < contrast.length; index += 1) contrast[index] = index % 2 ? 0 : 255;
assert.equal(imageQuality(16, 16, flat).grade, "poor");
assert.equal(imageQuality(16, 16, contrast).grade, "good");
const first = { width: 16, height: 16, values: contrast };
const second = { width: 16, height: 16, values: contrast.slice() };
const hash = computePHash(first);
assert.equal(hash.length, 64);
assert.equal(hammingDistance(hash, computePHash(second)), 0);
assert.equal(structuralSimilarity(first, second), 1);
});
test("dedup uses byte hash first and pHash/ArcFace as a conservative second condition", () => {
const result = deduplicatePortraitCandidates([
{ id: "a", byteHash: "same", phash: "0".repeat(64), byteSize: 100, pixelArea: 100 },
{ id: "b", byteHash: "same", phash: "1".repeat(64), byteSize: 90, pixelArea: 200 },
{ id: "c", byteHash: "c", phash: "0".repeat(63) + "1", byteSize: 100, pixelArea: 100, embedding: [1, 0] },
{ id: "d", byteHash: "d", phash: "0".repeat(63) + "1", byteSize: 100, pixelArea: 100, embedding: [0, 1] },
], "arcface");
assert.deepEqual(result.kept.map((item) => item.id), ["b", "c", "d"]);
assert.equal(result.byteDeduplicatedCount, 1);
assert.equal(result.visualDeduplicatedCount, 0);
});
test("task store persists input bytes without returning data URLs and keeps idempotency owner scoped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "portrait-clearance-store-"));
const store = new PortraitTaskStore(root, "runtime-owner");
const owner = { keyId: "key-1", origin: "http://127.0.0.1:3000" };
const input = "data:image/png;base64,iVBORw0KGgo=";
const request = createPortraitClearanceTaskRequestSchema.parse({
schemaVersion: 1,
clientOperationId: "portrait-store-0001",
ownerScopeHash: "a".repeat(64),
projectId: "project-1",
nodeId: "node-1",
mode: "direct-compare",
analysisMode: "local-only",
inputs: [
{ nodeId: "query", role: "query", fileName: "query.png", mimeType: "image/png", dataUrl: input },
{ nodeId: "reference", role: "reference", fileName: "reference.png", mimeType: "image/png", dataUrl: input },
],
settings: { maxCandidates: 30, searchScrolls: 5, dedupMode: "phash", modelConcurrency: 2, showBrowserForDebug: false },
});
try {
const first = await store.create(request, owner);
const retry = await store.create(request, owner);
assert.equal(first.created, true);
assert.equal(retry.created, false);
assert.equal(retry.record.taskId, first.record.taskId);
assert.equal("dataUrl" in retry.record, false);
const persisted = await store.readInput(first.record.taskId, owner, "input-1");
assert.deepEqual(persisted.bytes, Buffer.from("89504e470d0a1a0a", "hex"));
await assert.rejects(() => store.get(first.record.taskId, { keyId: "other", origin: owner.origin }), { code: "portrait_task_forbidden" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("candidate URL validation blocks private destinations before fetching", async () => {
assert.throws(() => validatePublicUrl("file:///C:/secret.png"), { code: "portrait_candidate_download_blocked" });
assert.throws(() => validatePublicUrl("https://user:pass@example.com/a.png"), { code: "portrait_candidate_download_blocked" });
await assert.rejects(() => downloadPortraitCandidate("http://127.0.0.1/image.png", { fetch: async () => { throw new Error("must not fetch"); } }), { code: "portrait_candidate_download_blocked" });
});
test("reports escape untrusted text and stay script-free", async () => {
const result = {
schemaVersion: 1 as const,
taskId: "portrait-report-test",
mode: "direct-compare" as const,
queryImageId: "input-1",
highestRisk: "unable_to_determine" as const,
riskCounts: { unable_to_determine: 1 },
candidateCount: 1,
comparedCount: 1,
candidates: [{ id: "candidate-1", originalRank: 1, title: "", imageArtifactId: "input-2", source: "connected" as const, byteSize: 8, resultId: "pair-1" }],
pairs: [{ id: "pair-1", queryImageId: "input-1", comparisonImageId: "input-2", source: "connected-reference" as const, status: "success" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, localPrecheck: { qualityA: { width: 1, height: 1, sharpness: 0, brightness: 0, contrast: 0, grade: "poor" as const }, qualityB: { width: 1, height: 1, sharpness: 0, brightness: 0, contrast: 0, grade: "poor" as const }, facesA: 0, facesB: 0, ssim: 0, colorHistogramCorrelation: 0, canExtractEmbedding: false, reliabilityIssues: [""] }, basis: [""], limitations: [] }],
limitations: ["
", "本结果不能替代司法鉴定"],
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const reports = await buildPortraitReports(result);
assert.equal(reports.html.includes("