Files
EpicNext-Cms/scripts/backup/core.test.mjs
Simo 46f7ad6571
CI / check (push) Successful in 4m12s
CI / deploy (push) Failing after 2m8s
CI / publish-container (push) Skipped
feat(ops): add integrity-checked backups and isolated restore drills
2026-09-13 20:29:18 +02:00

168 lines
5.8 KiB
JavaScript

import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { createArtifact, restoreFiles, verifyArtifact } from "./core.mjs";
let directory;
let roots;
let output;
const database = { database: "fixture", objects: [], tables: [] };
const dump = async (target) => {
await writeFile(target, "CREATE DATABASE fixture;\n");
return database;
};
beforeEach(async () => {
directory = await mkdtemp(path.join(tmpdir(), "cms-backup-test-"));
roots = Object.fromEntries(
["storage", "nitro", "swf"].map((key) => [key, path.join(directory, key)]),
);
for (const root of Object.values(roots)) await mkdir(root);
await mkdir(path.join(roots.storage, "empty"));
await writeFile(
path.join(roots.storage, "image.bin"),
Buffer.from([0, 255, 128, 1]),
);
await writeFile(
path.join(roots.nitro, "furniture.json"),
'{"caption":"Caffè 🏨"}',
);
output = path.join(directory, "artifact");
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it("copies exact bytes and empty directories, records hashes without source paths, and restores only into a new directory", async () => {
const manifest = await createArtifact({ roots, output }, dump);
expect(manifest.complete).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(directory);
expect(
manifest.entries.find((entry) => entry.path === "files/storage/image.bin")
.sha256,
).toMatch(/^[a-f0-9]{64}$/);
expect(await verifyArtifact(output)).toEqual(manifest);
const restored = path.join(directory, "restored");
await restoreFiles(output, restored);
expect(await readFile(path.join(restored, "storage/image.bin"))).toEqual(
Buffer.from([0, 255, 128, 1]),
);
expect(await readdir(path.join(restored, "storage/empty"))).toEqual([]);
await expect(restoreFiles(output, restored)).rejects.toThrow();
});
it.each(["files/storage/image.bin", "database.sql"])(
"rejects modified %s before restoring files",
async (entry) => {
await createArtifact({ roots, output }, dump);
await writeFile(path.join(output, entry), "tampered");
const restored = path.join(directory, "restored");
await expect(restoreFiles(output, restored)).rejects.toThrow();
await expect(readdir(restored)).rejects.toThrow();
},
);
it("rejects unlisted files and malicious manifest traversal", async () => {
await createArtifact({ roots, output }, dump);
const extra = path.join(output, "unexpected.txt");
await writeFile(extra, "extra");
await expect(verifyArtifact(output)).rejects.toThrow();
await rm(extra);
const manifest = JSON.parse(
await readFile(path.join(output, "manifest.json"), "utf8"),
);
manifest.entries[0].path = "../escape";
await writeFile(path.join(output, "manifest.json"), JSON.stringify(manifest));
await expect(verifyArtifact(output)).rejects.toThrow();
});
it("rejects an incomplete artifact and leaves no final artifact after dump failure", async () => {
await expect(
createArtifact({ roots, output }, async () => {
throw Error("database failed");
}),
).rejects.toThrow();
await expect(readdir(output)).rejects.toThrow();
await mkdir(output);
await writeFile(path.join(output, "database.sql"), "partial");
await expect(verifyArtifact(output)).rejects.toThrow();
});
it("refuses overwrites and output inside a source root", async () => {
await mkdir(output);
await writeFile(path.join(output, "keep"), "original");
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
expect(await readFile(path.join(output, "keep"), "utf8")).toBe("original");
await expect(
createArtifact({ roots, output: path.join(roots.storage, "backup") }, dump),
).rejects.toThrow();
});
it("rejects omitted roots, overlapping roots, and secret configuration files", async () => {
await expect(
createArtifact({ roots: { storage: roots.storage }, output }, dump),
).rejects.toThrow();
await expect(
createArtifact({ roots: { ...roots, nitro: roots.storage }, output }, dump),
).rejects.toThrow();
await writeFile(path.join(roots.storage, ".env"), "DATABASE_URL=secret");
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
});
it("rejects symbolic links in source trees and artifact trees", async () => {
const outside = path.join(directory, "outside");
await mkdir(outside);
await writeFile(path.join(outside, "secret"), "private");
const link = path.join(roots.storage, "linked");
await symlink(
outside,
link,
process.platform === "win32" ? "junction" : "dir",
);
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
await rm(link);
await createArtifact({ roots, output }, dump);
await symlink(
outside,
path.join(output, "files/storage/linked"),
process.platform === "win32" ? "junction" : "dir",
);
await expect(verifyArtifact(output)).rejects.toThrow();
});
it("rejects files changed while the database is being dumped", async () => {
await expect(
createArtifact({ roots, output }, async (target) => {
await writeFile(
path.join(roots.storage, "image.bin"),
"changed during backup",
);
return dump(target);
}),
).rejects.toThrow();
await expect(readdir(output)).rejects.toThrow();
});
it("restores a directory whose prefix also appears in a sibling filename", async () => {
await mkdir(path.join(roots.storage, "foo"));
await writeFile(path.join(roots.storage, "foo/file"), "nested");
await writeFile(path.join(roots.storage, "foo.json"), "sibling");
await createArtifact({ roots, output }, dump);
const destination = path.join(directory, "restored");
await restoreFiles(output, destination);
expect(
await readFile(path.join(destination, "storage/foo/file"), "utf8"),
).toBe("nested");
expect(
await readFile(path.join(destination, "storage/foo.json"), "utf8"),
).toBe("sibling");
});