167 lines
5.2 KiB
TypeScript
167 lines
5.2 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import mysql from "mysql2/promise";
|
|
import {
|
|
GenericContainer,
|
|
type StartedTestContainer,
|
|
Wait,
|
|
} from "testcontainers";
|
|
import { afterAll, beforeAll, expect, it } from "vitest";
|
|
import {
|
|
createBackup,
|
|
drillBackup,
|
|
IMAGE,
|
|
} from "../scripts/backup/database.mjs";
|
|
|
|
let maria: StartedTestContainer | undefined;
|
|
let connection: mysql.Connection | undefined;
|
|
let directory: string;
|
|
let artifact: string;
|
|
let database: {
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
password: string;
|
|
database: string;
|
|
};
|
|
let roots: Record<string, string>;
|
|
|
|
beforeAll(async () => {
|
|
directory = await mkdtemp(path.join(tmpdir(), "cms-backup-integration-"));
|
|
const password = randomUUID();
|
|
// A real Docker failure fails this suite; there is no environment-dependent skip.
|
|
maria = await new GenericContainer(IMAGE)
|
|
.withCopyContentToContainer([
|
|
{
|
|
content: password,
|
|
target: "/run/secrets/backup-password",
|
|
mode: 0o600,
|
|
},
|
|
])
|
|
.withEnvironment({
|
|
MARIADB_ROOT_PASSWORD_FILE: "/run/secrets/backup-password",
|
|
MARIADB_DATABASE: "cms_backup_fixture",
|
|
})
|
|
.withExposedPorts(3306)
|
|
.withHealthCheck({
|
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
|
interval: 1000,
|
|
timeout: 5000,
|
|
retries: 90,
|
|
startPeriod: 1000,
|
|
})
|
|
.withWaitStrategy(Wait.forHealthCheck())
|
|
.withStartupTimeout(120_000)
|
|
.start();
|
|
database = {
|
|
host: maria.getHost(),
|
|
port: maria.getMappedPort(3306),
|
|
user: "root",
|
|
password,
|
|
database: "cms_backup_fixture",
|
|
};
|
|
connection = await mysql.createConnection({
|
|
...database,
|
|
charset: "utf8mb4",
|
|
supportBigNumbers: true,
|
|
bigNumberStrings: true,
|
|
});
|
|
await connection.query(
|
|
"CREATE TABLE parent (id BIGINT UNSIGNED PRIMARY KEY, title VARCHAR(255)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
);
|
|
await connection.query(
|
|
"CREATE TABLE child (id INT PRIMARY KEY, parent_id BIGINT UNSIGNED, FOREIGN KEY (parent_id) REFERENCES parent(id)) ENGINE=InnoDB",
|
|
);
|
|
await connection.query("INSERT INTO parent VALUES (?, ?)", [
|
|
"9007199254740993123",
|
|
"Caffè 🏨 漢字",
|
|
]);
|
|
await connection.query("INSERT INTO child VALUES (1, ?)", [
|
|
"9007199254740993123",
|
|
]);
|
|
await connection.query(
|
|
"CREATE VIEW parent_titles AS SELECT id, title FROM parent",
|
|
);
|
|
await connection.query(
|
|
"CREATE TRIGGER preserve_title BEFORE UPDATE ON parent FOR EACH ROW SET NEW.title = COALESCE(NEW.title, OLD.title)",
|
|
);
|
|
await connection.query(
|
|
"CREATE PROCEDURE count_parents() SELECT COUNT(*) FROM parent",
|
|
);
|
|
await connection.query(
|
|
"CREATE EVENT future_check ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1",
|
|
);
|
|
roots = Object.fromEntries(
|
|
["storage", "nitro", "swf", "gamedata"].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, "fixture.bin"),
|
|
Buffer.from([0, 128, 255, 42]),
|
|
);
|
|
await writeFile(
|
|
path.join(roots.gamedata, "FurnitureData.json"),
|
|
'{"caption":"Caffè 🏨 漢字"}',
|
|
);
|
|
artifact = path.join(directory, "artifact");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await connection?.end();
|
|
await maria?.stop();
|
|
if (directory) await rm(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates and restores a real database with UTF-8, large IDs, relationships and stored SQL objects, then rejects corruption", async () => {
|
|
const manifest = await createBackup({
|
|
database,
|
|
roots,
|
|
output: artifact,
|
|
writersQuiesced: true,
|
|
});
|
|
expect(manifest.database.tables).toHaveLength(2);
|
|
expect(manifest.database.objects).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: "VIEW", name: "parent_titles" },
|
|
{ kind: "TRIGGER", name: "preserve_title" },
|
|
{ kind: "PROCEDURE", name: "count_parents" },
|
|
{ kind: "EVENT", name: "future_check" },
|
|
]),
|
|
);
|
|
const verified = await drillBackup({ artifact });
|
|
expect(verified.verified).toBe(true);
|
|
expect(verified.database).toEqual(manifest.database);
|
|
expect(verified.files).toBe(2);
|
|
if (!connection) throw Error("Fixture database is unavailable");
|
|
const [rows] = await connection.query(
|
|
"SELECT CAST(parent.id AS CHAR) AS id, title FROM parent JOIN child ON child.parent_id = parent.id",
|
|
);
|
|
expect(rows).toEqual([{ id: "9007199254740993123", title: "Caffè 🏨 漢字" }]);
|
|
const sqlPath = path.join(artifact, "database.sql");
|
|
const sql = await readFile(sqlPath, "utf8");
|
|
expect(sql).toContain("Caffè 🏨 漢字");
|
|
const changed = sql.replace("Caffè 🏨 漢字", "Changed content");
|
|
await writeFile(sqlPath, changed);
|
|
await expect(drillBackup({ artifact })).rejects.toThrow();
|
|
// Even with a recomputed file hash, the restored table checksum must disagree.
|
|
const sqlEntry = manifest.entries.find(
|
|
(entry: { path: string }) => entry.path === "database.sql",
|
|
);
|
|
if (!sqlEntry) throw Error("SQL manifest entry is missing");
|
|
sqlEntry.bytes = Buffer.byteLength(changed);
|
|
sqlEntry.sha256 = createHash("sha256").update(changed).digest("hex");
|
|
await writeFile(
|
|
path.join(artifact, "manifest.json"),
|
|
JSON.stringify(manifest),
|
|
);
|
|
await expect(drillBackup({ artifact })).rejects.toThrow(
|
|
"Restored database inventory",
|
|
);
|
|
}, 240_000);
|