forked from phenom/healer-man
239 lines
9.8 KiB
JavaScript
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);
|
|
});
|