feat(catalog): verify deterministic release manifests before Git export
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user