import { afterEach, describe, expect, test } from "bun:test"; import { classifyFailure } from "../src/commands/install"; import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; import { ensureDir, writeText, writeTextAtomic } from "../src/lib/fs"; import { runMutating, runMutatingHidden, runMutatingRaw, runMutatingVisible, runReadOnly, runReadOnlySecret } from "../src/lib/process"; type Ownership = Parameters[0]; function ownership(overrides: Partial = {}): Ownership { return { stateTouched: false, bootstrapTouched: false, depsTouched: false, filesystemTouched: false, uiTouched: false, hysteriaTouched: false, configTouched: false, unitsTouched: false, firewallTouched: false, postInstallTouched: false, bootstrapSecretTouched: false, servicesStarted: false, ...overrides }; } afterEach(() => { disableReadOnlyGuard(); }); describe("read-only guard (PHASE 0)", () => { test("по умолчанию выключен", () => { expect(isReadOnlyGuardActive()).toBe(false); }); // Guard обязан покрывать ВСЕ примитивы записи lib/fs, а не только те, что // существовали на момент его появления: непокрытый примитив — это дыра в // границе PHASE 0, и заметить её можно только здесь. test("под guard'ом запись в файл невозможна", async () => { enableReadOnlyGuard("test phase"); await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow(/read-only guard violation/); await expect( writeTextAtomic("/tmp/hy2xs-guard-probe", "x", { mode: 0o600, owner: "root", group: "root" }) ).rejects.toThrow(/read-only guard violation/); }); // Создание каталога — тоже запись. /var/lib/hy2xs, созданный до успешной // PHASE 0, уже делает хост изменённым: следующая чистая установка опознает // его содержимое как чужую установку. test("под guard'ом создание каталога невозможно", async () => { enableReadOnlyGuard("test phase"); await expect(ensureDir("/tmp/hy2xs-guard-probe-dir", { mode: 0o755 })).rejects.toThrow( /read-only guard violation/ ); }); test("под guard'ом мутирующие раннеры недоступны", async () => { enableReadOnlyGuard("test phase"); await expect(runMutating`true`).rejects.toThrow(/read-only guard violation/); await expect(runMutatingVisible`true`).rejects.toThrow(/read-only guard violation/); await expect(runMutatingHidden`true`).rejects.toThrow(/read-only guard violation/); await expect(runMutatingRaw("true")).rejects.toThrow(/read-only guard violation/); }); // Регрессия: раньше существовал один универсальный `run`, через который // мутирующие команды (useradd, install -d, mkdir) проходили мимо guard'а. // Read-only раннеры обязаны работать под guard'ом, мутирующие — нет. test("read-only раннеры под guard'ом не блокируются", async () => { enableReadOnlyGuard("test phase"); // Проверяется guard, а не наличие POSIX-shell: на машине разработчика без // `sh` вызов упадёт по ENOENT, и это тоже доказывает, что guard его // пропустил. Значение имеет только отсутствие guard violation. for (const call of [ () => runReadOnly`printf hy2xs`, () => runReadOnlySecret`printf hy2xs` ]) { try { expect(await call()).toBe("hy2xs"); } catch (error) { expect(String(error)).not.toMatch(/read-only guard violation/); } } }); test("сообщение мутирующего раннера называет операцию", async () => { enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); await expect(runMutating`mktemp -d`).rejects.toThrow(/runMutating\(mktemp -d\).*PHASE 0/s); }); test("сообщение называет операцию и фазу", async () => { enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow( /writeText\(\/tmp\/hy2xs-guard-probe\).*PHASE 0/s ); }); test("guard снимается явно", () => { enableReadOnlyGuard("test phase"); expect(isReadOnlyGuardActive()).toBe(true); disableReadOnlyGuard(); expect(isReadOnlyGuardActive()).toBe(false); }); }); describe("классификация отказа установки", () => { // Ключевой инвариант: пока операция ничего не применила, отказ обязан быть // pre-apply, что бы ни было написано в тексте ошибки. test("до любой мутации отказ — fatal_pre_apply", () => { expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply"); expect(classifyFailure(ownership(), "preflight_ok")).toBe("fatal_pre_apply"); }); // Регрессия: install-state.json пишется сразу после успешного preflight, до // installDeps. Пока classifyFailure его не учитывал, падение apt-get // объявлялось «на сервере ничего не изменено», rollback пропускался, а // /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую // установку по clean-host контракту. test("тронутый install-state сам по себе делает отказ post-apply", () => { expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).toBe("fatal_post_apply"); }); // Вторая половина той же регрессии. Флаг назывался stateWritten и взводился // ПОСЛЕ успешной записи, а запись маркера — три операции (mkdir, write, // chown). Отказ на chown оставлял /var/lib/hy2xs/install-state.json на диске // при невзведённом флаге, то есть давал «на сервере ничего не изменено» с // уже существующим маркером установки. test("частично выполненная запись маркера уже post-apply", () => { // Ровно то состояние, которое оставляет упавший на chown advanceInstallState: // флаг взведён, а фаза ещё preflight_ok. expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).not.toBe( "fatal_pre_apply" ); }); // Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh, // поэтому у оркестратора не было флага, которым это можно было бы учесть. // Отказ сразу после bootstrap выглядел как «хост не тронут», хотя на диске // уже лежали /usr/local/lib/hy2xs и symlink в /usr/local/bin. test("начатый bootstrap уже делает отказ post-apply", () => { expect(classifyFailure(ownership({ bootstrapTouched: true }), "preflight_ok")).toBe( "fatal_post_apply" ); expect( classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed") ).toBe("fatal_post_apply"); }); test("падение installDeps после записи состояния — post-apply", () => { expect( classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok") ).toBe("fatal_post_apply"); }); // Флаг взводится ПЕРЕД шагом, поэтому частично применённый apt-get уже // считается изменением хоста, даже если installDeps не завершился. test("начатая установка пакетов уже делает отказ post-apply", () => { expect(classifyFailure(ownership({ depsTouched: true }), "preflight_ok")).toBe("fatal_post_apply"); }); test("каждая мутирующая стадия до firewall даёт post-apply", () => { const stages = [ "filesystemTouched", "uiTouched", "hysteriaTouched", "configTouched", "unitsTouched" ] as const; for (const stage of stages) { expect(classifyFailure(ownership({ [stage]: true }), "installing")).toBe("fatal_post_apply"); } }); test("тронутый firewall классифицируется как firewall failure", () => { expect( classifyFailure(ownership({ unitsTouched: true, firewallTouched: true }), "firewall_applied") ).toBe("firewall_connectivity_failure"); }); test("после записи post-install env отказ — postinstall validation", () => { expect( classifyFailure( ownership({ unitsTouched: true, firewallTouched: true, postInstallTouched: true }), "postinstall_env_written" ) ).toBe("postinstall_validation_failure"); }); test("отказ на bootstrap-секрете тоже postinstall validation", () => { expect( classifyFailure( ownership({ unitsTouched: true, firewallTouched: true, postInstallTouched: true, bootstrapSecretTouched: true }), "postinstall_env_written" ) ).toBe("postinstall_validation_failure"); }); test("после старта сервисов различаются smoke и service failure", () => { const started = ownership({ unitsTouched: true, firewallTouched: true, postInstallTouched: true, bootstrapSecretTouched: true, servicesStarted: true }); expect(classifyFailure(started, "smoke_running")).toBe("smoke_readiness_timeout"); expect(classifyFailure(started, "smoke_failed")).toBe("smoke_readiness_timeout"); expect(classifyFailure(started, "services_started")).toBe("service_start_failure"); }); // Регрессия: раньше классификация шла по подстрокам сообщения, поэтому // preflight-ошибка со словом "nftables" приводила к откату чужого firewall. test("текст ошибки не влияет на классификацию", () => { expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply"); expect(classifyFailure(ownership({ depsTouched: true }), "deps_ok")).toBe("fatal_post_apply"); }); // Единственный способ получить fatal_pre_apply — не тронуть вообще ничего. test("fatal_pre_apply невозможен ни при одном взведённом флаге", () => { const empty = ownership(); for (const key of Object.keys(empty) as (keyof typeof empty)[]) { expect(classifyFailure(ownership({ [key]: true }), "installing")).not.toBe("fatal_pre_apply"); } }); });