2259f7c847
Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. - отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []` введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/ unknown и отдельным типом отказа GuardStateUnknownError; - покой перечисляется белым списком (inactive, failed): maintenance, refreshing и любое незнакомое состояние systemd блокируют операцию; - у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер дополнительно опознаёт SubState=elapsed у *.timer как покой; - команда взведения строится чистой buildArmGuardArgv и выполняется новым runMutatingArgv без shell, поэтому её контракт проверяется значением, а не грепом по исходнику; - status перестал листить guard-юниты своей копией кода: без --plain, с `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown; - purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке. Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe: прежние проверки грепом по тексту функции пережили инверсию смысла - строка `return [];` была на месте, а решение стало неверным. Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф.
852 lines
43 KiB
TypeScript
852 lines
43 KiB
TypeScript
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 <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(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<string, { ActiveState: string; SubState: string }>): 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<string> {
|
||
throw new Error("Failed to connect to bus: No such file or directory");
|
||
},
|
||
async showProperties(): Promise<Record<string, string>> {
|
||
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<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);
|
||
});
|
||
});
|