// A report's revision history (publish/reportHistory.ts): commits only on a // change, the site as author and committer in UTC with nothing of the // operator's, the history view, and the published dumb-HTTP clone — served by // the export's own static server and cloned with git. // // Run with: node_modules/.bin/tsx --test publish/reportHistory.test.ts import { after, test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { REPORT_HISTORY_REPO_FILE_RE, commitReportRevision, historyGitEnv, pendingRevision, publishReportHistory, readReportHistoryView, readRevisionHead, reportHistoryRef, revisionExports, } from "./reportHistory"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SERVE_OUT = path.resolve(HERE, "../../export/scripts/serve-out.mjs"); const ROOT = mkdtempSync(path.join(os.tmpdir(), "report-history-")); after(() => rmSync(ROOT, { recursive: true, force: true })); const SITE = { siteId: "demo-site", siteTitle: "Demo Reports" }; const sha = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); function reportJson(over: { title?: string; text?: string; verdict?: string } = {}): Buffer { return Buffer.from( `${JSON.stringify( { format: "archilyzer-report", version: 1, id: "demo", kind: "factcheck", title: over.title ?? "Checking a demo article", citations: { c01: { kind: "page", url: "https://example.org/a", quote: "A page." } }, sections: [ { id: "s1", title: "The bridge", claims: [{ id: "k1", text: over.text ?? "The bridge opened in 2018.", verdict: over.verdict ?? "PARTLY", citations: ["c01"] }], }, ], }, null, 2, )}\n`, ); } const gitLog = (gitDir: string, format: string) => execFileSync("git", ["--git-dir", gitDir, "log", `--format=${format}`, "refs/heads/main"], { env: historyGitEnv(), encoding: "utf8", }).trim(); async function commit(gitDir: string, data: Buffer, now: string) { return commitReportRevision({ gitDir, site: SITE, reportJson: data, markdown: `# ${JSON.parse(data.toString()).title}\n`, exports: revisionExports("demo", sha(data), { "report.md": "# md\n", "citations.csv": "a,b\n" }), now: new Date(now), }); } // The operator's identity as git and the environment would give it, when // there is one: none of it may reach a commit. function operatorStrings(): string[] { const out = new Set(["Leaky Operator", "leaky@operator.example", "Leaky Committer"]); for (const key of ["user.name", "user.email"]) { try { const v = execFileSync("git", ["config", "--global", key], { encoding: "utf8" }).trim(); if (v) out.add(v); } catch { // unset } } const user = os.userInfo().username; if (user && user.length > 2) out.add(user); return [...out]; } test("a revision is committed only when report.json changed", async () => { const gitDir = path.join(ROOT, "only-on-change", "history-git"); assert.deepEqual(await pendingRevision(gitDir, sha(reportJson())), { revision: 1, changed: true, head: null }); const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); assert.equal(r1.revision, 1); assert.equal(r1.committed, true); assert.deepEqual(r1.summary, ["First revision: 1 section, 1 claim, 1 citation."]); const again = await commit(gitDir, reportJson(), "2026-03-02T12:00:00Z"); assert.equal(again.committed, false); assert.equal(again.revision, 1); assert.equal(again.commit, r1.commit); assert.equal(gitLog(gitDir, "%H").split("\n").length, 1, "no second commit"); const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019.", verdict: "CORROBORATED" }), "2026-03-08T12:00:00Z"); assert.equal(r2.revision, 2); assert.deepEqual(r2.summary, ["Verdict changed: k1 PARTLY → CORROBORATED", "Claim edited: k1 (text)"]); const head = await readRevisionHead(gitDir); assert.equal(head?.commit, r2.commit); assert.equal(head?.date, "2026-03-08T12:00:00Z"); assert.deepEqual(gitLog(gitDir, "%s").split("\n"), ["Revision 2", "Revision 1"]); // A revision holds report.json byte for byte, report.md and exports.json. const tree = execFileSync("git", ["--git-dir", gitDir, "ls-tree", "--name-only", "refs/heads/main"], { env: historyGitEnv(), encoding: "utf8", }); assert.deepEqual(tree.trim().split("\n"), ["exports.json", "report.json", "report.md"]); const blob = execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:report.json"], { env: historyGitEnv() }); assert.equal(sha(blob), r2.reportSha256); const exportsJson = JSON.parse( execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:exports.json"], { env: historyGitEnv(), encoding: "utf8" }), ); assert.equal(exportsJson.format, "archilyzer-report-revision-exports"); assert.equal(exportsJson.reportSha256, r2.reportSha256); assert.deepEqual(exportsJson.files["citations.csv"], { bytes: 4, sha256: sha("a,b\n") }); }); test("the site is author and committer, dated in UTC; nothing of the operator's is in a commit", async () => { const saved = { ...process.env }; // What a careless git invocation would pick up from this process. Object.assign(process.env, { GIT_AUTHOR_NAME: "Leaky Operator", GIT_AUTHOR_EMAIL: "leaky@operator.example", GIT_COMMITTER_NAME: "Leaky Committer", GIT_COMMITTER_EMAIL: "leaky@operator.example", EMAIL: "leaky@operator.example", TZ: "America/Chicago", GIT_AUTHOR_DATE: "2001-01-01T00:00:00-0600", }); const gitDir = path.join(ROOT, "identity", "history-git"); try { await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); await commit(gitDir, reportJson({ title: "Checking the demo article" }), "2026-03-08T18:30:05Z"); } finally { for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k]; Object.assign(process.env, saved); } assert.equal( gitLog(gitDir, "%an|%ae|%cn|%ce|%ad|%cd").replace(/\n/g, "\n"), [ `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 8 18:30:05 2026 +0000|Sun Mar 8 18:30:05 2026 +0000`, `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 1 12:00:00 2026 +0000|Sun Mar 1 12:00:00 2026 +0000`, ].join("\n"), ); const raw = execFileSync("git", ["--git-dir", gitDir, "cat-file", "--batch-all-objects", "--batch"], { env: historyGitEnv() }).toString(); for (const s of operatorStrings()) assert.ok(!raw.includes(s), `a commit names the operator (${s.length} chars)`); assert.doesNotMatch(raw, /gpgsig/); assert.doesNotMatch(raw, /[+-](?!0000)\d{4}\n/, "every date is +0000"); }); test("the history view: each revision's hashes, summary and claim diff; the page's ref", async () => { const gitDir = path.join(ROOT, "view", "history-git"); const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019." }), "2026-03-08T12:00:00Z"); const view = await readReportHistoryView(gitDir, { reportId: "demo", siteUrl: "https://reports.example.org/" }); assert.ok(view); assert.equal(view.clone, "https://reports.example.org/reports/demo/history/repo"); assert.deepEqual( view.revisions.map((r) => [r.revision, r.commit, r.reportSha256, r.date]), [ [1, r1.commit, r1.reportSha256, "2026-03-01T12:00:00Z"], [2, r2.commit, r2.reportSha256, "2026-03-08T12:00:00Z"], ], ); assert.deepEqual(view.revisions[0].claims, []); assert.deepEqual(view.revisions[1].summary, ["Claim edited: k1 (text)"]); assert.deepEqual(view.revisions[1].claims[0].fields[0].diff, [ { op: "eq", text: "The bridge opened in " }, { op: "del", text: "2018." }, { op: "ins", text: "2019." }, ]); assert.deepEqual(reportHistoryRef(view, r2.reportSha256), { revision: 2, date: "2026-03-08T12:00:00Z", href: "/reports/demo/history/", current: true, }); assert.equal(reportHistoryRef(view, sha("edited")).current, false); const relative = await readReportHistoryView(gitDir, { reportId: "demo" }); assert.equal(relative?.clone, "/reports/demo/history/repo"); assert.equal(await readReportHistoryView(path.join(ROOT, "none"), { reportId: "demo" }), null); }); async function freePort(): Promise { return new Promise((resolve, reject) => { const s = net.createServer(); s.once("error", reject); s.listen(0, "127.0.0.1", () => { const { port } = s.address() as net.AddressInfo; s.close(() => resolve(port)); }); }); } test("the published clone: an allowlisted dumb-HTTP repository that clones over HTTP to the same report.json", async () => { const gitDir = path.join(ROOT, "clone", "history-git"); await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); await commit(gitDir, reportJson({ verdict: "CONTRADICTED" }), "2026-03-08T12:00:00Z"); const view = await readReportHistoryView(gitDir, { reportId: "demo" }); assert.ok(view); const publicDir = path.join(ROOT, "clone", "public"); const files = await publishReportHistory({ gitDir, publicDir, view }); for (const f of files) assert.match(f, REPORT_HISTORY_REPO_FILE_RE); assert.ok(files.includes("info/refs") && files.includes("objects/info/packs")); assert.ok(files.some((f) => f.endsWith(".pack"))); const published = JSON.parse(readFileSync(path.join(publicDir, "reports", "demo", "history", "history.json"), "utf8")); assert.deepEqual(published, view); const port = await freePort(); const server = spawn(process.execPath, [SERVE_OUT, publicDir, String(port)], { env: { ...process.env, HOST: "127.0.0.1" }, stdio: ["ignore", "pipe", "pipe"], }); try { await new Promise((resolve, reject) => { server.once("error", reject); server.once("exit", (code) => reject(new Error(`serve-out exited ${code}`))); server.stdout.on("data", (c: Buffer) => { if (c.toString().includes("serving")) resolve(); }); }); const dest = path.join(ROOT, "clone", "cloned"); execFileSync("git", ["clone", "--quiet", `http://127.0.0.1:${port}/reports/demo/history/repo`, dest], { env: historyGitEnv(), stdio: "pipe", }); const last = view.revisions[view.revisions.length - 1]; assert.equal(sha(readFileSync(path.join(dest, "report.json"))), last.reportSha256); const head = execFileSync("git", ["-C", dest, "rev-parse", "HEAD"], { env: historyGitEnv(), encoding: "utf8" }).trim(); assert.equal(head, last.commit); const first = execFileSync("git", ["-C", dest, "show", `${view.revisions[0].commit}:report.json`], { env: historyGitEnv() }); assert.equal(sha(first), view.revisions[0].reportSha256); } finally { server.kill(); } });