Files
HY2XS_flamy/orchestrator/test/firewall-guard.test.ts
T
founder 76d78ac71f fix(orchestrator): закрыть два остатка на стыке guard и замка операций
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.

1. Отказ записи `auto-rollback-fired` оставался незамеченным.

Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.

`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.

Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.

2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.

Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:

    A берёт замок -> применяет firewall -> вооружает guard на 45s
    A аварийно умирает
    B берёт замок и начинает менять production paths
    guard A срабатывает и возвращает firewall, который был ДО A

Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.

Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.

Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.

Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
2026-08-31 03:30:14 +05:00

632 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
buildAutoRollbackScript,
operationKeyFor,
parseNftablesServiceState,
renderNftablesEntrypoint,
renderNftablesServiceState
} from "../src/steps/firewall";
/**
* Транзакционность staged firewall guard.
*
* Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он
* тоньше первого. Первый был про то, что данные для отката уничтожались до
* фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное
* утверждение.
*
* systemctl stop <unit>.timer <unit>.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/<op_id> — по значению из маркера такого пути не было.
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("барьер покоя между операциями", () => {
/**
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
* который возвращает прежний firewall уже посреди следующей операции.
*/
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertNoPendingRollbackGuard"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
test("вооружённый guard предыдущей операции запрещает новую", () => {
expect(body).toContain("PendingRecoveryError");
expect(body).toContain("readUnitProperty(unit, \"ActiveState\")");
});
// `failed` и `inactive` — покой: guard уже отработал и больше ничего не
// сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он
// нужен для устранения последствий.
test("покоем считаются inactive и failed, а не только inactive", () => {
expect(firewallSource).toContain(
'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const'
);
});
test("отказ запроса к systemd не выдаётся за наличие guard", () => {
const listing = firewallSource.slice(
firewallSource.indexOf("export async function listRollbackGuardUnits"),
firewallSource.indexOf("export async function assertNoPendingRollbackGuard")
);
expect(listing).toContain("unable to list firewall rollback guard units");
expect(listing).toContain("return [];");
});
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<string, boolean> = {}) {
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);
});
});