import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test, { afterEach, beforeEach } from "node:test"; import { createSupplementCacheApi } from "./supplements.mjs"; const testArchiveBase = { repository: "example/coa-archive-fixture", revision: "a".repeat(40), shortRevision: "a".repeat(12), capturedAt: "2026-08-12T21:02:51+00:00", fetchManifestPath: "raw/talents/_fetch.json", builderSnapshotPath: "raw/talents/coa-builder-voljin.html", }; let tempRoot; let archive; let fileBytes; let treeUrl; let api; function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function gitBlobSha(bytes) { return createHash("sha1") .update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8")) .update(bytes) .digest("hex"); } function pathIsWithin(parent, candidate) { const relative = path.relative(path.resolve(parent), path.resolve(candidate)); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } function makeFixture({ traversal = false } = {}) { const html = Buffer.from("fixture builder snapshot payload\n", "utf8"); archive = { ...testArchiveBase, builderSnapshotSha256: sha256(html), }; const descriptors = Array.from({ length: 21 }, (_, index) => ({ name: `Class ${index + 1}`, file: traversal && index === 0 ? "../outside.json" : index === 0 ? "Barbarian.json" : `Class-${String(index + 1).padStart(2, "0")}.json`, })); const captureManifest = Buffer.from(`${JSON.stringify({ capturedUtc: archive.capturedAt, sha256: archive.builderSnapshotSha256, savedAs: path.posix.basename(archive.builderSnapshotPath), })}\n`, "utf8"); const entries = new Map([ [archive.fetchManifestPath, captureManifest], [archive.builderSnapshotPath, html], ["data/talents/coa/index.json", Buffer.from(`${JSON.stringify({ classes: descriptors })}\n`, "utf8")], ["data/talents/coa/_meta.json", Buffer.from('{"capture":"talents"}\n', "utf8")], ["raw/provenance.json", Buffer.from('{"sources":[]}\n', "utf8")], ["raw/_snapshot.json", Buffer.from('{"commit":"fixture"}\n', "utf8")], ["data/spells/_meta.json", Buffer.from('{"capture":"spells"}\n', "utf8")], ]); for (let index = 0; index < 21; index += 1) { const fileName = index === 0 ? "Barbarian.json" : `Class-${String(index + 1).padStart(2, "0")}.json`; const filePath = `data/talents/coa/${fileName}`; entries.set(filePath, Buffer.from(`${JSON.stringify({ classNumber: index + 1, trees: [{ id: index + 1 }] })}\n`, "utf8")); } // These must remain untouched: only metadata and the pinned capture are selected. entries.set("data/spells/123456.json", Buffer.from("large spell payload")); entries.set("raw/Spell.dbc", Buffer.from("client dbc")); fileBytes = entries; const tree = [...entries].map(([filePath, bytes]) => ({ path: filePath, mode: "100644", type: "blob", sha: gitBlobSha(bytes), size: bytes.byteLength, })); treeUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`; api = createSupplementCacheApi(archive); return { tree }; } function createFetch({ tree, failPath = null, failOnce = false, calls = [] } = {}) { let didFail = false; return { calls, fetch: async (url) => { calls.push(url); if (url === treeUrl) { return response(Buffer.from(JSON.stringify({ truncated: false, tree }), "utf8")); } const rawPrefix = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/`; assert.ok(url.startsWith(rawPrefix), `unexpected URL ${url}`); const filePath = url.slice(rawPrefix.length).split("/").map(decodeURIComponent).join("/"); if (filePath === failPath && (!failOnce || !didFail)) { didFail = true; return { ok: false, status: 503 }; } const bytes = fileBytes.get(filePath); if (!bytes) return { ok: false, status: 404 }; return response(bytes); }, }; } function response(bytes) { return { ok: true, arrayBuffer: async () => Uint8Array.from(bytes).buffer }; } async function freshRoot() { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-supplements-test-")); return path.join(tempRoot, "cache"); } beforeEach(() => { tempRoot = null; }); afterEach(async () => { if (tempRoot) { assert.ok(pathIsWithin(os.tmpdir(), tempRoot), "cleanup path must stay inside the OS temporary directory"); await fs.rm(tempRoot, { recursive: true, force: true }); } }); test("downloads the selected archive files and reuses a valid cache offline", async () => { const { tree } = makeFixture(); const cacheRoot = await freshRoot(); const firstFetch = createFetch({ tree }); const first = await api.downloadSupplements({ cacheRoot, fetchImpl: firstFetch.fetch }); assert.equal(first.reused, false); assert.equal(first.fileCount, 28); assert.equal(first.classCount, 21); assert.equal(firstFetch.calls.length, 29); assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/data/spells/123456.json`), false); assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/raw/Spell.dbc`), false); const second = await api.downloadSupplements({ cacheRoot, fetchImpl: async () => { throw new Error("valid cache should avoid the network"); }, }); const supplements = await api.readSupplements({ cacheRoot }); assert.equal(second.reused, true); assert.equal(supplements.classes.length, 21); assert.deepEqual(supplements.metadata, { talents: { capture: "talents" }, provenance: { sources: [] }, clientSnapshot: { commit: "fixture" }, spells: { capture: "spells" }, }); assert.equal(supplements.classFiles[0].path, "data/talents/coa/Barbarian.json"); assert.deepEqual(supplements.classFiles[0].data, { classNumber: 1, trees: [{ id: 1 }] }); }); test("resumes verified files after an interrupted download", async () => { const { tree } = makeFixture(); const cacheRoot = await freshRoot(); const interrupted = createFetch({ tree, failPath: "data/talents/coa/Class-03.json" }); await assert.rejects( api.downloadSupplements({ cacheRoot, fetchImpl: interrupted.fetch }), /HTTP 503/, ); await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/); const resumedFetch = createFetch({ tree }); const summary = await api.downloadSupplements({ cacheRoot, fetchImpl: resumedFetch.fetch }); assert.equal(summary.reused, false); assert.equal(summary.downloadedFiles.length, 19); const supplements = await api.readSupplements({ cacheRoot }); assert.equal(supplements.classes.length, 21); }); test("offline reads refuse corrupted files, and an online run repairs them", async () => { const { tree } = makeFixture(); const cacheRoot = await freshRoot(); await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch }); const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`); const manifest = JSON.parse(await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8")); const generationFile = path.join(archiveRoot, manifest.generation, "data", "talents", "coa", "Barbarian.json"); await fs.writeFile(generationFile, "corrupted\n", "utf8"); await assert.rejects(api.readSupplements({ cacheRoot }), /size mismatch|checksum mismatch/); await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch }); const repaired = await api.readSupplements({ cacheRoot }); assert.deepEqual(repaired.classes[0], { classNumber: 1, trees: [{ id: 1 }] }); }); test("reports an unavailable source without publishing a partial manifest", async () => { makeFixture(); const cacheRoot = await freshRoot(); await assert.rejects( api.downloadSupplements({ cacheRoot, fetchImpl: async () => { throw new Error("offline"); } }), /CoA supplemental source unavailable/, ); await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/); }); test("keeps the last valid manifest and generation when a refresh fails", async () => { const { tree } = makeFixture(); const cacheRoot = await freshRoot(); await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch }); const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`); const manifestPath = path.join(archiveRoot, "manifest.json"); const previousManifest = await fs.readFile(manifestPath); const previous = JSON.parse(previousManifest.toString("utf8")); const previousClassPath = path.join(archiveRoot, previous.generation, "data", "talents", "coa", "Barbarian.json"); const previousClass = await fs.readFile(previousClassPath); const interruptedRefresh = createFetch({ tree, failPath: "data/talents/coa/Class-04.json" }); await assert.rejects( api.downloadSupplements({ cacheRoot, fetchImpl: interruptedRefresh.fetch, refresh: true }), /HTTP 503/, ); assert.deepEqual(await fs.readFile(manifestPath), previousManifest); assert.deepEqual(await fs.readFile(previousClassPath), previousClass); assert.equal((await api.readSupplements({ cacheRoot })).classes.length, 21); }); test("rejects index path traversal before requesting a class file", async () => { const { tree } = makeFixture({ traversal: true }); const cacheRoot = await freshRoot(); const source = createFetch({ tree }); await assert.rejects( api.downloadSupplements({ cacheRoot, fetchImpl: source.fetch }), /unsafe path segment/, ); assert.equal(source.calls.some((url) => url.includes("outside.json")), false); assert.equal(await fs.stat(path.join(tempRoot, "outside.json")).then(() => true, () => false), false); });