import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { BACKUP_MANIFEST_VERSION, backupDir, backupFilePath, backupManifestPath, buildManifest, parseManifest, renderManifest, storedNameFor } from "../src/lib/backupManifest"; /** * Целостность резервной копии — предпосылка отката, а не его деталь. * * Два дефекта, которые здесь закрепляются. * * 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять * систему, не имея копии, на которую рассчитывает откат. * * 2. Копии всех операций лежали одним общим набором `*.bak` в * /etc/hy2xs/backups. Если у операции B копирование падало, её откат * восстанавливал файл, сохранённый операцией A, — то есть возвращал сервер * в более старое состояние и выглядел при этом успешным. * * Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о * каждом файле, включая отсутствовавшие. */ function source(relativeToSrc: string): string { return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); } describe("манифест резервной копии", () => { test("копия каждой операции лежит в своём каталоге", () => { expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z"); expect(backupDir("a")).not.toBe(backupDir("b")); }); test("манифест и файлы адресуются внутри каталога операции", () => { const opId = "op-1"; expect(backupManifestPath(opId)).toStartWith(backupDir(opId)); expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId)); }); // Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf // не имеют права лечь в один файл копии. test("разные пути дают разные имена файлов копии", () => { const names = new Set( [ "/etc/nftables.conf", "/etc/hy2xs/nftables.conf", "/etc/nftables.d/hy2xs.nft", "/etc/hysteria/config.yaml", "/etc/systemd/system/hy2xs-admin.service" ].map(storedNameFor) ); expect(names.size).toBe(5); }); test("имя файла копии не выходит за пределы каталога", () => { for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) { const stored = storedNameFor(path); expect(stored).not.toContain("/"); expect(stored).not.toContain("../"); } }); // Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`. test("отсутствовавший файл записывается явно", () => { const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [ { path: "/etc/hysteria/config.yaml", present: true }, { path: "/etc/nftables.d/hy2xs.nft", present: false } ]); const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft"); expect(missing?.present).toBe(false); expect(missing?.stored).toBeNull(); const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml"); expect(kept?.present).toBe(true); expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml")); }); test("манифест переживает сериализацию без потерь", () => { const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [ { path: "/etc/hysteria/config.yaml", present: true }, { path: "/etc/hy2xs/hy2xs.env", present: false } ]); expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest); }); }); describe("разбор манифеста строгий", () => { const valid = renderManifest( buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }]) ); // Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого // копия стала привязанной к op-id. test("манифест другой операции отклоняется", () => { expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/); }); test("непонятная версия отклоняется", () => { const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99'); expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/); }); test("битый JSON отклоняется, а не считается пустой копией", () => { expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/); }); // «Поле не разобралось, будем считать, что файла не было» означает удаление // существующего файла при откате. Поэтому отказ, а не умолчание. test("запись без признака существования отклоняется", () => { const broken = JSON.stringify({ version: BACKUP_MANIFEST_VERSION, opId: "op-1", createdAt: "", entries: [{ path: "/etc/hysteria/config.yaml" }] }); expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/); }); test("существующий файл без имени копии отклоняется", () => { const broken = JSON.stringify({ version: BACKUP_MANIFEST_VERSION, opId: "op-1", createdAt: "", entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }] }); expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/); }); test("запись без пути отклоняется", () => { const broken = JSON.stringify({ version: BACKUP_MANIFEST_VERSION, opId: "op-1", createdAt: "", entries: [{ present: false }] }); expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/); }); }); describe("reconfigure: копия снимается строго и до первой мутации", () => { const reconfigureSource = source("commands/reconfigure.ts"); test("копирование не глушит ошибки", () => { const start = reconfigureSource.indexOf("async function backupCurrentState"); const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest")); const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); expect(muted, `копирование снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); }); // Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата // одного лишь `cp`. test("существование копии проверяется после копирования", () => { expect(reconfigureSource).toContain("резервная копия"); expect(reconfigureSource).toContain("await fileExists(target)"); }); test("копия снимается до первой мутации системы", () => { const backup = reconfigureSource.indexOf("await backupCurrentState(context)"); const generate = reconfigureSource.indexOf("await generateConfig(context)"); const firewall = reconfigureSource.indexOf("await applyFirewall(context)"); expect(backup).toBeGreaterThan(-1); expect(backup).toBeLessThan(generate); expect(backup).toBeLessThan(firewall); }); test("откат читает манифест текущей операции, а не общий каталог", () => { expect(reconfigureSource).toContain("readBackupManifest(opId)"); expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)"); // Прежняя схема общего набора *.bak не должна вернуться. expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak"); // Решение «восстанавливать или удалять» принимается по манифесту, а не по // маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups // допустима и проверяется отдельно. const restoreStart = reconfigureSource.indexOf("function restoreStages("); const restoreBody = reconfigureSource.slice( restoreStart, reconfigureSource.indexOf("async function rollbackCurrentState") ); expect(restoreBody).not.toContain(".existed"); expect(restoreBody).toContain("entry.present"); }); // Каталог копий не имеет права расти неограниченно, но уборка обязана идти // ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и // с firewall. test("копии предыдущих операций убираются после фиксации успеха", () => { const installed = reconfigureSource.indexOf('await markPhase(context, "installed")'); const prune = reconfigureSource.indexOf("pruneOldBackups(context)"); expect(installed).toBeGreaterThan(-1); expect(prune).toBeGreaterThan(installed); }); }); describe("firewall: копия снимается строго и переживает неудачный откат", () => { const firewallSource = source("steps/firewall.ts"); test("копирование firewall не глушит ошибки", () => { const start = firewallSource.indexOf("async function backupFirewallState"); const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments")); const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); }); // Маркер `prepared` означает «данные для отката существуют». Пока он // ставился ДО копирования, это было неправдой. test("маркер готовности ставится после проверенных копий", () => { const start = firewallSource.indexOf("async function backupFirewallState"); const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments")); const copy = body.indexOf("cp -a ${target.path}"); const verify = body.indexOf("await fileExists(backupPath)"); const marker = body.indexOf("touch ${rollbackMarker(opId)}"); expect(copy).toBeGreaterThan(-1); expect(verify).toBeGreaterThan(copy); expect(marker).toBeGreaterThan(verify); }); test("восстановление firewall не глушит ошибки cp и nft", () => { const start = firewallSource.indexOf("export async function rollbackFirewallNow"); const body = firewallSource.slice(start); const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")) // Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ // восстановления, а нормальное состояние при firewallStagedApply=false. .filter((line) => !line.includes("systemctl")); expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); }); // Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по // которым оператор мог бы поднять firewall вручную, уничтожены. test("резервные копии удаляются только после успешного восстановления", () => { const start = firewallSource.indexOf("export async function rollbackFirewallNow"); const body = firewallSource.slice(start); const failures = body.indexOf("if (failures.length > 0)"); const preserved = body.indexOf("manual recovery data preserved at"); const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)"); expect(failures).toBeGreaterThan(-1); expect(preserved).toBeGreaterThan(failures); expect(cleanup).toBeGreaterThan(preserved); }); });