Files
healer-man/scripts/coa-reference/supplements.test.mjs
T

239 lines
9.8 KiB
JavaScript

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);
});