feat(catalog): verify deterministic release manifests before Git export
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-13 17:48:23 +02:00
1 parent 422be3ce2d
commit a320e47c29
9 files changed
+491 -9

No files matched your search

@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
vi.mock("@/lib/services/figuredata", () => ({
getFigureDataPath: async () =>
path.join(await mocks.gamedata(), "custom/FigureData.json"),
@@ -125,6 +127,26 @@ describe("catalog snapshot", () => {
const root = await fixture();
const files = await createCatalogSnapshot(path.join(root, "output"));
const targets = files.map((f) => f.target);
const manifestFile = files.find(
(file) => file.target === "Gamedata/catalog-release.json",
);
expect(manifestFile).toBeDefined();
if (!manifestFile) throw new Error("Missing manifest");
const manifest = JSON.parse(await readFile(manifestFile.source, "utf8"));
expect(manifest.files).toHaveLength(files.length - 1);
expect(
manifest.files.some((file: { path: string }) =>
file.path.endsWith("items_base.sql"),
),
).toBe(true);
for (const file of files.filter((file) => file !== manifestFile)) {
const bytes = await readFile(file.source);
expect(manifest.files).toContainEqual({
path: file.target,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
}
expect(targets).toContain("Gamedata/bundled/furniture/chair.nitro");
expect(targets).toContain("Gamedata/icons/chair_icon.png");
expect(targets).toContain("Gamedata/c_images/catalogue/icon_1.png");
@@ -163,4 +185,41 @@ describe("catalog snapshot", () => {
createCatalogSnapshot(path.join(root, "output")),
).rejects.toThrow();
});
it("uses one consistent SQL transaction and rejects sources changed during capture", async () => {
const root = await fixture();
const original = mocks.query.getMockImplementation();
if (!original) throw new Error("Missing query fixture");
mocks.query.mockImplementation(async (sql: string) => {
if (sql.startsWith("SELECT") && sql.includes("items_base")) {
await writeFile(
path.join(root, "icons/chair_icon.png"),
"changed image",
);
}
return original(sql);
});
await expect(
createCatalogSnapshot(path.join(root, "output")),
).rejects.toThrow("Assets changed");
const queries = mocks.query.mock.calls.map(([sql]) => sql);
expect(
queries.filter((sql) => sql.startsWith("START TRANSACTION")),
).toEqual(["START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY"]);
expect(queries.filter((sql) => sql.startsWith("SELECT"))).toHaveLength(3);
});
it("rejects nontransactional tables instead of advertising a consistent database snapshot", async () => {
const root = await fixture();
const original = mocks.query.getMockImplementation();
if (!original) throw new Error("Missing query fixture");
mocks.query.mockImplementation(async (sql: string) =>
sql.startsWith("SHOW CREATE")
? [[{ "Create Table": "CREATE TABLE `test` (`id` int) ENGINE=MyISAM" }]]
: original(sql),
);
await expect(
createCatalogSnapshot(path.join(root, "output")),
).rejects.toThrow("InnoDB");
expect(mocks.rollback).toHaveBeenCalledOnce();
expect(mocks.release).toHaveBeenCalledOnce();
});
});