import { describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { classifyFailure } from "../src/commands/install"; import { FirewallGuardFiredError, buildAutoRollbackScript, operationKeyFor, parseNftablesServiceState, 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; } describe("скрипт автоматического отката firewall", () => { const script = buildAutoRollbackScript(OP_ID); test("маркер срабатывания создаётся ПЕРВЫМ действием", () => { const marker = script.indexOf(`: >"$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(`: >"$root/auto-rollback-fired"`); const earlyExit = script.indexOf("nothing to restore"); expect(marker).toBeLessThan(earlyExit); }); 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/); }); 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(firewallSource).toContain("systemd-run --unit ${`${unit}.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 запрещает фиксацию успеха", () => { 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); }); });