import { afterEach, describe, expect, test } from "bun:test"; import { classifyFailure } from "../src/commands/install"; import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; import { writeText, writeTextAtomic } from "../src/lib/fs"; import { runHidden, runRawVisible, runVisible } from "../src/lib/process"; type Ownership = Parameters[0]; function ownership(overrides: Partial = {}): Ownership { return { stateWritten: false, depsInstalled: false, filesystemPrepared: false, unitsDeployed: false, firewallTouched: false, postInstallWritten: false, servicesStarted: false, ...overrides }; } afterEach(() => { disableReadOnlyGuard(); }); describe("read-only guard (PHASE 0)", () => { test("по умолчанию выключен", () => { expect(isReadOnlyGuardActive()).toBe(false); }); 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/); }); test("под guard'ом мутирующие раннеры недоступны", async () => { enableReadOnlyGuard("test phase"); await expect(runVisible`true`).rejects.toThrow(/read-only guard violation/); await expect(runHidden`true`).rejects.toThrow(/read-only guard violation/); await expect(runRawVisible("true")).rejects.toThrow(/read-only guard violation/); }); 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"); }); test("установленные пакеты уже делают отказ post-apply", () => { expect(classifyFailure(ownership({ depsInstalled: true }), "preflight_ok")).toBe("fatal_post_apply"); }); test("развёрнутые юниты без firewall — post-apply", () => { expect(classifyFailure(ownership({ filesystemPrepared: true, unitsDeployed: true }), "units_deployed")).toBe( "fatal_post_apply" ); }); test("тронутый firewall классифицируется как firewall failure", () => { expect( classifyFailure(ownership({ unitsDeployed: true, firewallTouched: true }), "firewall_applied") ).toBe("firewall_connectivity_failure"); }); test("после записи post-install env отказ — postinstall validation", () => { expect( classifyFailure( ownership({ unitsDeployed: true, firewallTouched: true, postInstallWritten: true }), "postinstall_env_written" ) ).toBe("postinstall_validation_failure"); }); test("после старта сервисов различаются smoke и service failure", () => { const started = ownership({ unitsDeployed: true, firewallTouched: true, postInstallWritten: 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({ depsInstalled: true }), "deps_ok")).toBe("fatal_post_apply"); }); });