import { describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { classifyFailure } from "../src/commands/install"; import { FirewallGuardFiredError, GuardStateUnknownError, OperationBarrierError, PendingRecoveryError, type SystemdUnitProbe, assertNoPendingRollbackGuard, buildArmGuardArgv, buildAutoRollbackScript, describeGuardUnits, guardUnitIsQuiescent, inspectRollbackGuard, operationKeyFor, parseNftablesServiceState, parseRollbackGuardUnitNames, renderNftablesEntrypoint, renderNftablesServiceState } from "../src/steps/firewall"; /** * Транзакционность staged firewall guard. * * Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он * тоньше первого. Первый был про то, что данные для отката уничтожались до * фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное * утверждение. * * systemctl stop .timer .service || true * -> "firewall rollback timer disarmed" * -> phase=installed * * Между «мы думаем, что guard снят» и «guard действительно снят» не было ни * одной проверки. Отказ остановки стирался через `|| true`, и взведённый таймер * мог вернуть прежний firewall уже ПОСЛЕ того, как установка объявлена * успешной и долговечно записана. * * Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke. * Окно 45 секунд заведомо короче худшего случая smoke (retry-бюджеты дают * заметно больше), а единственной проверкой firewall в smoke был `nft -c` — * разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset * проходил её зелёным. * * Оба закрываются одним фактом: маркером `auto-rollback-fired`, который * rollback-скрипт создаёт первым действием. Дальше инвариант становится * детерминированным: * * маркер отсутствует И юниты inactive => можно фиксировать успех */ function source(relativeToSrc: string): string { return readFileSync(new URL(`../src/${relativeToSrc}`, import.meta.url), "utf8"); } function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } const firewallSource = source("steps/firewall.ts"); const smokeSource = source("steps/smoke.ts"); const OP_ID = "2026-08-30T12-34-56.789Z"; /** * Ищет shell, которому можно доверить разбор скрипта. * * Кандидат принимается только после ДВУСТОРОННЕЙ проверки: он обязан принять * заведомо корректный скрипт и отвергнуть заведомо сломанный. Односторонней * проверки мало — `C:\Windows\system32\bash.exe` без установленного WSL * «проваливает» любой файл одинаково, и тест на таком парсере либо всегда * зелёный, либо всегда красный, но в обоих случаях ничего не проверяет. */ function findShellParser(sandbox: string): string | null { const good = join(sandbox, "good.sh"); const bad = join(sandbox, "bad.sh"); writeFileSync(good, "true\n"); writeFileSync(bad, "if then fi else\n"); const candidates = [ Bun.which("dash"), Bun.which("sh"), Bun.which("bash"), "C:\\Program Files\\Git\\usr\\bin\\bash.exe", "E:\\Git\\usr\\bin\\bash.exe" ].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string)); for (const candidate of candidates) { const accepts = Bun.spawnSync([candidate, "-n", good], { stdout: "pipe", stderr: "pipe" }); const rejects = Bun.spawnSync([candidate, "-n", bad], { stdout: "pipe", stderr: "pipe" }); if (accepts.exitCode === 0 && rejects.exitCode !== 0) { return candidate; } } return null; } /** * Запускает скрипт кандидатом-shell. * * Каталог самого shell добавляется в PATH: Git for Windows кладёт bash и * coreutils рядом, но при запуске напрямую из процесса PATH этого каталога не * содержит, и `mkdir`/`touch` внутри скрипта оказываются не найдены. Это * особенность окружения, а не скрипта, и тест не должен на неё падать. */ function spawnShell(shell: string, scriptPath: string) { return Bun.spawnSync([shell, scriptPath], { stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: `${dirname(shell)}${delimiter}${process.env.PATH ?? ""}` } }); } /** * Ищет shell, которому можно доверить ЗАПУСК скрипта. * * Проба самопроверяющая и намеренно строгая: кандидат обязан вернуть код * возврата скрипта И создать файл, который тест затем видит на своей файловой * системе. Одной проверки кода возврата мало — `C:\Windows\system32\bash.exe` * это launcher WSL, он честно выполнит `exit 7`, но в совершенно другом * пространстве имён путей, и поведенческий тест ниже проверял бы не скрипт. */ function findShell(sandbox: string): string | null { const probeDir = join(sandbox, "probe-dir"); const probe = join(sandbox, "probe.sh"); writeFileSync( probe, `mkdir -p '${probeDir.replace(/\\/g, "/")}'\ntouch '${probeDir.replace(/\\/g, "/")}/ok'\nexit 7\n` ); const candidates = [ Bun.which("dash"), Bun.which("sh"), Bun.which("bash"), "C:\\Program Files\\Git\\usr\\bin\\bash.exe", "E:\\Git\\usr\\bin\\bash.exe" ].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string)); for (const candidate of candidates) { rmSync(probeDir, { recursive: true, force: true }); const result = spawnShell(candidate, probe); if (result.exitCode === 7 && existsSync(join(probeDir, "ok"))) { return candidate; } } return null; } describe("скрипт автоматического отката firewall", () => { const script = buildAutoRollbackScript(OP_ID); test("маркер срабатывания создаётся ПЕРВЫМ действием", () => { const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`); const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`); expect(marker).toBeGreaterThan(-1); expect(prepared).toBeGreaterThan(marker); expect(firstRestore).toBeGreaterThan(prepared); }); // Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после // выполнения исчезают, и `systemctl stop` для них неотличим от успешного // снятия взведённого таймера. test("маркер создаётся даже когда восстанавливать нечего", () => { const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const earlyExit = script.indexOf("nothing to restore"); expect(marker).toBeLessThan(earlyExit); }); /** * Отказ записи маркера обязан входить в учёт rc. * * Инвариант фиксации — «маркера нет и юниты inactive => guard не сработал» — * верен только при дополнительном условии «guard способен записать маркер». * Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs * /run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал * прежний firewall и завершался кодом 0, юнит уходил в inactive, маркера не * было — и операция фиксировала успех после реально сработавшего отката. */ test("rc объявляется до создания маркера, а не после", () => { const rcInit = script.indexOf("rc=0"); const mkdir = script.indexOf('mkdir -p "$root"'); const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); expect(rcInit).toBeGreaterThan(-1); expect(rcInit).toBeLessThan(mkdir); expect(rcInit).toBeLessThan(marker); }); test("невозможность записать маркер поднимает код возврата", () => { expect(script).toContain("failed to access the recovery root"); expect(script).toContain("failed to create the fired marker"); }); /** * Маркер создаётся `touch`, а не `: >file`. * * Двоеточие — special builtin POSIX: ошибка перенаправления на нём обязана * завершить неинтерактивный shell целиком. На Debian /bin/sh — это dash, * который так и делает, поэтому при недоступном /run скрипт умер бы ДО * восстановления firewall — то есть guard перестал бы делать ровно то, ради * чего существует. */ test("маркер создаётся обычной командой, а не special builtin", () => { expect(script).toContain('touch "$root/auto-rollback-fired"'); expect(script).not.toContain(': >"$root/auto-rollback-fired"'); }); // Аварийный канал факта срабатывания: если маркер записать не удалось, юнит // обязан уйти в failed, а `failed` на пути фиксации успеха запрещён. test("ранний выход возвращает накопленный код, а не ноль", () => { const earlyExit = script.indexOf("nothing to restore"); const tail = script.slice(earlyExit); expect(tail).toContain('exit "$rc"'); expect(script).not.toContain("exit 0"); }); test("ошибки не маскируются", () => { expect(script).not.toContain("|| true"); expect(script).not.toContain("2>/dev/null"); expect(script).not.toContain(">/dev/null 2>&1"); }); // set -e прервал бы восстановление на первой отказавшей части, а задача // guard'а — восстановить ВСЁ, что получится. test("непрерывность обеспечивается rc, а не set -e", () => { expect(script).not.toMatch(/^set -e/m); expect(script).toContain("rc=0"); expect(script).toContain('exit "$rc"'); }); test("каждый сообщённый отказ поднимает код возврата", () => { expect(occurrences(script, "rc=1")).toBe(occurrences(script, "auto-rollback: failed")); expect(occurrences(script, "rc=1")).toBeGreaterThan(0); }); /** * На Debian у nftables.service `ExecStop=/usr/sbin/nft flush ruleset`. * Остановка сервиса внутри guard'а стёрла бы только что восстановленные * правила — то есть сделала бы ровно противоположное задаче. */ test("состояние nftables.service guard не трогает", () => { expect(script).not.toContain("systemctl"); }); test("пути привязаны к каталогу этой операции", () => { expect(script).toContain(`root='/run/hy2xs/rollback/${OP_ID}'`); expect(buildAutoRollbackScript("2026-09-01T00-00-00.000Z")).not.toContain(OP_ID); }); // Ключ операции служит именем каталога, именем systemd-юнита и подставляется // в текст скрипта. Прежний однострочник держался на том, что в нём не бывает // пробелов; теперь это проверяемое утверждение, а не допущение. test("небезопасный ключ операции отвергается", () => { expect(() => buildAutoRollbackScript("op id")).toThrow(/unsafe operation key/); expect(() => buildAutoRollbackScript("op'; rm -rf /")).toThrow(/unsafe operation key/); expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/); }); /** * Поведенческая проверка раннего пути скрипта. * * Запускается ТОЛЬКО ветка «prepared отсутствует»: она заканчивается до * первой команды восстановления, поэтому ничего в /etc не трогает и * безопасна на любой машине. Именно в этой ветке живёт исправленный дефект — * раньше она возвращала жёсткий `exit 0` и теряла факт неудачной записи * маркера. * * Строка `root=` подменяется на временный каталог: это единственное * изменение, остальные сорок строк — ровно те, что уезжают на сервер. */ function runEarlyPath(sandbox: string, root: string): { exitCode: number; stderr: string } | null { const shell = findShell(sandbox); if (!shell) { return null; } const scriptPath = join(sandbox, "run.sh"); writeFileSync( scriptPath, script.replace(/^root='.*'$/m, `root='${root.replace(/\\/g, "/")}'`) ); const result = spawnShell(shell, scriptPath); return { exitCode: result.exitCode, stderr: result.stderr.toString() }; } test("при доступном /run маркер создаётся, а ранний выход успешен", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); try { const root = join(sandbox, "rollback"); const result = runEarlyPath(sandbox, root); if (!result) { console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); return; } expect(existsSync(join(root, "auto-rollback-fired"))).toBe(true); expect(result.stderr).toContain("nothing to restore"); expect(result.exitCode).toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); // Ключевой сценарий исправления: маркер записать не удалось, восстановления // не было — юнит обязан уйти в failed, потому что это аварийный канал факта // срабатывания, и `failed` запрещает фиксацию успеха. test("при недоступном /run ранний выход возвращает отказ", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); try { // Родитель — файл, поэтому ни mkdir, ни touch выполниться не могут. const blocker = join(sandbox, "blocker"); writeFileSync(blocker, "не каталог\n"); const result = runEarlyPath(sandbox, join(blocker, "rollback")); if (!result) { console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); return; } expect(result.stderr).toContain("auto-rollback: failed"); expect(result.exitCode).not.toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); test("разбирается настоящим shell-парсером", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-")); try { const parser = findShellParser(sandbox); if (!parser) { // Сборка идёт на Linux, где парсер есть всегда; на машине разработчика // его может не быть, и это не повод объявлять скрипт корректным. console.warn("shell parser is unavailable: skipping the syntax check of the rollback script"); return; } const scriptPath = join(sandbox, "auto-rollback.sh"); writeFileSync(scriptPath, script); const parsed = Bun.spawnSync([parser, "-n", scriptPath], { stdout: "pipe", stderr: "pipe" }); expect(parsed.stderr.toString().trim()).toBe(""); expect(parsed.exitCode).toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); }); describe("ключ операции один на все её артефакты", () => { // Раньше формула была записана дважды, и копии разошлись: install писал в // маркер сырой ISO-timestamp с двоеточиями, а каталог отката назывался // санитизированным ключом. Runbook предлагает оператору открыть // /run/hy2xs/rollback/ — по значению из маркера такого пути не было. test("двоеточия ISO-времени заменяются", () => { expect(operationKeyFor("2026-08-30T12:34:56.789Z")).toBe("2026-08-30T12-34-56.789Z"); }); test("install и reconfigure берут ключ из одного места", () => { expect(source("commands/install.ts")).toContain("opId: operationKeyFor(context.installDate)"); expect(source("commands/reconfigure.ts")).toContain("return operationKeyFor(context.installDate)"); }); }); describe("состояние nftables.service переживает откат", () => { test("запись и разбор согласованы", () => { const state = { unitFileState: "disabled", activeState: "inactive" }; expect(parseNftablesServiceState(renderNftablesServiceState(state))).toEqual(state); }); test("непонятый файл не притворяется состоянием", () => { expect(parseNftablesServiceState("")).toBeNull(); expect(parseNftablesServiceState("мусор без разделителя\n")).toBeNull(); }); test("частичное состояние разбирается, а не отбрасывается", () => { expect(parseNftablesServiceState("unit_file_state=enabled\n")).toEqual({ unitFileState: "enabled", activeState: "" }); }); test("состояние снимается до первой мутации firewall", () => { const backup = firewallSource.indexOf("async function backupFirewallState"); const capture = firewallSource.indexOf("nftablesServiceStatePath(opId)", backup); const prepared = firewallSource.indexOf("touch ${rollbackMarker(opId)}", backup); expect(capture).toBeGreaterThan(backup); expect(prepared).toBeGreaterThan(capture); }); /** * Порядок стадий отката. `systemctl stop nftables` выполняет * `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти * ДО применения ruleset — иначе остановка стёрла бы восстановленные правила. */ test("состояние сервиса восстанавливается до применения ruleset", () => { const body = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); const unitFile = body.indexOf('name: "restore nftables.service unit file state"'); const inactive = body.indexOf('name: "restore nftables.service inactive state"'); const ruleset = body.indexOf('name: "apply restored ruleset"'); expect(unitFile).toBeGreaterThan(-1); expect(inactive).toBeGreaterThan(unitFile); expect(ruleset).toBeGreaterThan(inactive); }); }); describe("disarm доказывает снятие guard'а, а не сообщает о нём", () => { const body = firewallSource.slice( firewallSource.indexOf("async function stopRollbackGuard"), firewallSource.indexOf("export async function disarmFirewallRollback") ); test("остановка guard'а больше не глушит свой код возврата", () => { expect(body).not.toContain("|| true"); expect(body).toContain("runMutatingStatus`systemctl stop"); }); test("маркер срабатывания проверяется до и после остановки", () => { const before = body.indexOf("before stopping the rollback guard"); const stop = body.indexOf("runMutatingStatus`systemctl stop"); const after = body.indexOf("after stopping the rollback guard"); expect(before).toBeGreaterThan(-1); expect(stop).toBeGreaterThan(before); expect(after).toBeGreaterThan(stop); }); test("снятие подтверждается наблюдаемым состоянием юнитов", () => { expect(body).toContain('readUnitProperty(target, "ActiveState")'); expect(body).toContain("`${unit}.timer`"); expect(body).toContain("`${unit}.service`"); }); // На пути фиксации успеха допустимо ровно одно состояние. `failed` там // означает сработавший и упавший guard, то есть запрет на фиксацию. test("на пути фиксации допустим только inactive", () => { expect(firewallSource).toContain('const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const'); expect(firewallSource).toContain( 'const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const' ); }); /** * Имя транзиентного юнита передаётся с явным суффиксом. Голое имя systemd-run * пропускает через мангling, который смотрит, не заканчивается ли оно уже * известным типом юнита; ключ операции содержит точку (`...T12-34-56.789Z`), * и корректность зависела бы от того, что `.789Z` ни с чем не совпало. */ test("имя юнита guard'а не зависит от мангления systemd", () => { expect(buildArmGuardArgv(OP_ID)).toContain(`--unit=hy2xs-fw-rollback-${OP_ID}.service`); }); test("остановка guard'а стала стадией отката с отчётом", () => { const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); expect(rollback).toContain('name: "stop firewall rollback guard"'); expect(rollback).toContain("assertNotFired: false"); }); }); describe("взведение guard'а задаёт свойства таймера явно", () => { /** * У этой команды есть контракт, от которого зависят два чужих утверждения, и * оба до сих пор проверялись грепом по исходнику. * * `AccuracySec` — обещанное оператору окно. `systemd.timer` разрешает себе * сработать в интервале `[цель; цель + AccuracySec]`, а умолчание — `1min`. * То есть guard, про который README, docs и текст отказа барьера говорят «45 * секунд», по контракту systemd мог сработать через 105. * * `RemainAfterElapse` — право барьера считать исчезновение юнита покоем. * systemd-run выставляет `false` сам, но инвариант, который держится на чужом * умолчании, нигде не записан и ничем не проверяется. */ const argv = buildArmGuardArgv(OP_ID); test("окно отката задано вместе с точностью таймера", () => { expect(argv).toContain("--on-active=45s"); expect(argv).toContain("--timer-property=AccuracySec=1s"); }); test("отработавший таймер обязан выгрузиться", () => { expect(argv).toContain("--timer-property=RemainAfterElapse=no"); }); test("команда собрана целиком, а не по кускам", () => { expect(argv).toEqual([ "systemd-run", `--unit=hy2xs-fw-rollback-${OP_ID}.service`, "--on-active=45s", "--timer-property=RemainAfterElapse=no", "--timer-property=AccuracySec=1s", "/bin/sh", `/run/hy2xs/rollback/${OP_ID}/auto-rollback.sh` ]); }); // Ключ операции подставляется в имя systemd-юнита и в путь скрипта. Без shell // квотирование не спасает — спасает отказ. test("небезопасный ключ операции отвергается до запуска", () => { expect(() => buildArmGuardArgv("op id")).toThrow(/unsafe operation key/); expect(() => buildArmGuardArgv("op'; rm -rf /")).toThrow(/unsafe operation key/); }); // Готовый argv уходит в exec как есть: shell в этой команде не участвует. test("взведение идёт без shell", () => { const arm = firewallSource.slice( firewallSource.indexOf("async function armRollbackGuard"), firewallSource.indexOf("export async function applyFirewall") ); expect(arm).toContain("runMutatingArgv(buildArmGuardArgv(opId))"); expect(arm).not.toMatch(/runMutatingVisible`/); }); }); describe("барьер покоя между операциями", () => { /** * Стык двух защитных механизмов. Замок защищает production paths, пока жив * процесс-держатель; rollback guard — отдельный systemd-объект, переживающий * свой процесс. Аварийно умершая операция оставляет вооружённый guard, * который возвращает прежний firewall уже посреди следующей операции. * * Проверки здесь поведенческие. Прежние сверяли ТЕКСТ функции, и именно * поэтому пропустили инверсию смысла: тест утверждал, что в теле есть * `return [];`, строка была на месте, а решение при этом стало неверным. */ const TIMER = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.timer"; const SERVICE = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.service"; /** Probe, отвечающий заранее заданными состояниями. */ function probeWith(states: Record): SystemdUnitProbe { return { async listGuardUnits() { return Object.keys(states) .map((unit) => `${unit} loaded active running HY2XS firewall rollback guard`) .join("\n"); }, async showProperties(unit) { const state = states[unit]; if (!state) { throw new Error(`unexpected unit: ${unit}`); } return { ...state }; } }; } const failingProbe: SystemdUnitProbe = { async listGuardUnits(): Promise { throw new Error("Failed to connect to bus: No such file or directory"); }, async showProperties(): Promise> { throw new Error("Failed to connect to bus: No such file or directory"); } }; test("guard'ов нет — операция разрешена", async () => { const inspection = await inspectRollbackGuard(probeWith({})); expect(inspection.kind).toBe("quiescent"); await expect(assertNoPendingRollbackGuard(probeWith({}))).resolves.toBeUndefined(); }); test("взведённый таймер предыдущей операции запрещает новую", async () => { const probe = probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } }); const inspection = await inspectRollbackGuard(probe); expect(inspection.kind).toBe("pending"); const error = await assertNoPendingRollbackGuard(probe).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(PendingRecoveryError); expect((error as Error).message).toContain(TIMER); expect((error as Error).message).toContain("active/waiting"); }); test("выполняющийся прямо сейчас откат — тоже непокой", async () => { const probe = probeWith({ [TIMER]: { ActiveState: "active", SubState: "running" }, [SERVICE]: { ActiveState: "activating", SubState: "start" } }); await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(PendingRecoveryError); }); /** * Отказ запроса к systemd — ОТСУТСТВИЕ наблюдения, а не наблюдение покоя. * * Прежний код возвращал пустой список и тем самым принимал невозможность * получить доказательство за положительный результат: * * systemd жив, старый таймер взведён * -> systemctl временно отказывает * -> список пуст -> барьер считает систему спокойной * -> новая операция меняет firewall, старый таймер срабатывает поверх */ test("отказ запроса к systemd запрещает операцию, а не разрешает её", async () => { const inspection = await inspectRollbackGuard(failingProbe); expect(inspection.kind).toBe("unknown"); const error = await assertNoPendingRollbackGuard(failingProbe).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(GuardStateUnknownError); expect((error as Error).message).toContain("refusing to start a lifecycle operation"); }); test("отказ на одном юните тоже делает картину неполной", async () => { const probe: SystemdUnitProbe = { async listGuardUnits() { return `${TIMER} loaded active waiting guard\n${SERVICE} loaded inactive dead guard`; }, async showProperties(unit) { if (unit === SERVICE) { throw new Error("Connection timed out"); } return { ActiveState: "inactive", SubState: "dead" }; } }; await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(GuardStateUnknownError); }); // Обе причины отказа — про то, что операцию нельзя начинать, и вызывающий // вправе не различать их по конкретному типу. test("оба отказа барьера имеют общего предка", async () => { await expect(assertNoPendingRollbackGuard(failingProbe)).rejects.toBeInstanceOf(OperationBarrierError); await expect( assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } })) ).rejects.toBeInstanceOf(OperationBarrierError); }); // Отработавший guard больше ничего не сделает. Отказ по `failed` заблокировал // бы `repair` ровно тогда, когда им чинят последствия. test("покой — это inactive и failed", async () => { await expect( assertNoPendingRollbackGuard( probeWith({ [TIMER]: { ActiveState: "inactive", SubState: "dead" }, [SERVICE]: { ActiveState: "failed", SubState: "failed" } }) ) ).resolves.toBeUndefined(); }); /** * Политика покоя — белый список, а не чёрный. * * Прежняя перечисляла непокойные состояния, то есть объявляла безопасным * любое, которого автор не назвал. systemd 257 знает `maintenance` и * `refreshing` помимо перечисленных, и список может пополниться снова. */ test("незнакомое состояние systemd блокирует операцию, а не проходит молча", async () => { for (const activeState of ["maintenance", "refreshing", "some-future-state"]) { await expect( assertNoPendingRollbackGuard(probeWith({ [SERVICE]: { ActiveState: activeState, SubState: "x" } })) ).rejects.toBeInstanceOf(PendingRecoveryError); } }); /** * Отработавший таймер не должен блокировать операцию навсегда. * * `TIMER_ELAPSED` в systemd отображается в `UNIT_ACTIVE` так же, как * `TIMER_WAITING`, — различает их только SubState. У наших guard'ов такого не * бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не * должен зависеть от того, чем именно создан таймер. */ test("таймер в elapsed — покой, а не вечная блокировка", async () => { await expect( assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "elapsed" } })) ).resolves.toBeUndefined(); }); // Исключение узкое: у сервиса тот же SubState ничего не значит. test("исключение по SubState действует только для таймера", async () => { expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "elapsed" })).toBe(true); expect(guardUnitIsQuiescent({ unit: SERVICE, activeState: "active", subState: "elapsed" })).toBe(false); expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "waiting" })).toBe(false); }); /** * Разбор вывода `systemctl list-units`. * * У юнита в состоянии `failed` первой колонкой идёт маркер `●`, поэтому * «имя юнита — первое поле строки» теряло бы именно аварийно сработавший * guard. Ищется первое поле, начинающееся с префикса. */ test("имя юнита находится и при маркере failed в первой колонке", () => { const listed = [ `${TIMER} loaded active waiting HY2XS firewall rollback guard`, `● ${SERVICE} loaded failed failed HY2XS firewall rollback guard`, "", "hysteria-server.service loaded active running Hysteria" ].join("\n"); expect(parseRollbackGuardUnitNames(listed)).toEqual([TIMER, SERVICE]); }); test("посторонние юниты и пустой вывод не попадают в разбор", () => { expect(parseRollbackGuardUnitNames("")).toEqual([]); expect(parseRollbackGuardUnitNames("nftables.service loaded active exited nftables\n")).toEqual([]); }); test("описание юнита называет и состояние, и подсостояние", () => { expect(describeGuardUnits([{ unit: TIMER, activeState: "active", subState: "waiting" }])).toBe( `${TIMER} (active/waiting)` ); }); test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => { const cliSource = source("cli.ts"); expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }"); // Обработчик сигналов снимает замок сам, поэтому у прерванной операции // stale-замка может не быть вовсе, а таймер останется. expect(cliSource).toContain("await assertNoPendingRollbackGuard()"); }); // Барьер вызывается дважды: между проверкой и захватом умирающая операция // успевает вооружить guard. test("барьер проверяется до и после захвата замка", () => { const lockSource = source("lib/operationLock.ts"); const start = lockSource.indexOf("export async function acquireOperationLock"); const acquire = lockSource.slice(start); const before = acquire.indexOf("await options.barrier?.()"); const write = acquire.indexOf("await writeLockFile(path, record)"); const after = acquire.indexOf("await options.barrier()"); expect(before).toBeGreaterThan(-1); expect(write).toBeGreaterThan(before); expect(after).toBeGreaterThan(write); // Отказ второй проверки не имеет права оставить замок за собой. expect(acquire.slice(after)).toContain("releaseSync(path, record.nonce)"); }); }); describe("сработавший guard запрещает фиксацию успеха", () => { function ownership(overrides: Record = {}) { return { stateTouched: true, bootstrapTouched: true, depsTouched: true, filesystemTouched: true, uiTouched: true, hysteriaTouched: true, configTouched: true, unitsTouched: true, firewallTouched: true, postInstallTouched: true, bootstrapSecretTouched: true, servicesStarted: true, ...overrides }; } test("install классифицирует его отдельной причиной отказа", () => { expect(classifyFailure(ownership(), "smoke_ok", new FirewallGuardFiredError("guard"))).toBe( "firewall_guard_fired" ); }); // Классификация по ТИПУ, а не по тексту: ошибка с теми же словами, но другим // типом обязана классифицироваться по владению, как и прежде. test("похожий текст чужой ошибки на классификацию не влияет", () => { expect( classifyFailure(ownership(), "smoke_ok", new Error("automatic firewall rollback has already fired")) ).toBe("service_start_failure"); }); test("маркер отказа получает собственную фазу", () => { const installSource = source("commands/install.ts"); expect(installSource).toContain('? "firewall_guard_fired"'); expect(source("commands/reconfigure.ts")).toContain('return "firewall_guard_fired"'); }); }); describe("эффективный firewall проверяется семантически", () => { test("smoke сверяет firewall, а не только разбирает файл", () => { const syntax = smokeSource.indexOf("nft -c -f /etc/nftables.conf"); const semantic = smokeSource.indexOf("assertEffectiveFirewallIsOurs(context)"); expect(syntax).toBeGreaterThan(-1); expect(semantic).toBeGreaterThan(syntax); }); test("сверяется отрендеренный фрагмент, entrypoint и загруженная таблица", () => { const body = firewallSource.slice( firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"), firewallSource.indexOf("function firewallRollbackIsInactive") ); expect(body).toContain("await renderHy2xsNft(context)"); expect(body).toContain("effective !== expected"); expect(body).toContain('entrypointKind !== "hy2xs-managed"'); expect(body).toContain("nft list table inet hy2xs"); }); // Проверка обязана оставаться наблюдением: она выполняется и в doctor, // который целиком идёт под read-only guard. test("проверка выполняется read-only раннером", () => { const body = firewallSource.slice( firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"), firewallSource.indexOf("function firewallRollbackIsInactive") ); expect(body).not.toMatch(/runMutating[A-Za-z]*`/); }); test("рендер фрагмента объявлен один раз и переиспользуется", () => { expect(occurrences(firewallSource, "templates/nftables/hy2xs.nft.tpl")).toBe(1); }); }); describe("candidate-файлы не переживают операцию", () => { test("успешный apply убирает оба", () => { expect(firewallSource).toContain( "runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`" ); const apply = firewallSource.slice( firewallSource.indexOf("export async function applyFirewall"), firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs") ); expect(apply).toContain("await cleanupFirewallCandidates()"); }); test("откат убирает их best-effort и не срывается на этом", () => { const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); const cleanup = rollback.indexOf("await cleanupFirewallCandidates()"); const guarded = rollback.indexOf("catch (candidateError)"); expect(cleanup).toBeGreaterThan(-1); expect(guarded).toBeGreaterThan(cleanup); }); }); describe("entrypoint nftables", () => { test("объявляет владение HY2XS и подключает переданный фрагмент", () => { const rendered = renderNftablesEntrypoint("/etc/nftables.d/hy2xs.nft"); expect(rendered).toContain("HY2XS-MANAGED"); expect(rendered).toContain("flush ruleset"); expect(rendered).toContain('include "/etc/nftables.d/hy2xs.nft"'); }); test("candidate и production entrypoint строятся одной функцией", () => { expect(occurrences(firewallSource, "HY2XS-MANAGED: root nftables entrypoint")).toBe(1); }); });