import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { preflight } from "../src/steps/preflight"; import { classifyReconfigureFailure } from "../src/commands/reconfigure"; import { FirewallGuardFiredError } from "../src/steps/firewall"; import { baselineConfig, testContext } from "./fixtures"; /** * Порядок фаз установки — инвариант, который нельзя проверить ни модульно, ни * на живом сервере дешевле, чем разбором самой последовательности. * * Оба дефекта, которые здесь закреплены, проявлялись ТОЛЬКО на настоящей * Debian-машине и оба ломали установку полностью: * * 1. clean-host проверялся дважды, второй раз — уже после записи * install-state.json, и опознавал собственный маркер как чужую установку; * 2. bootstrap (раскладка оркестратора и runtime-пакета) выполнялся вне * оркестратора, поэтому не имел владельца и не попадал в rollback. */ const installSource = readFileSync(new URL("../src/commands/install.ts", import.meta.url), "utf8"); const reconfigureSource = readFileSync( new URL("../src/commands/reconfigure.ts", import.meta.url), "utf8" ); const installerSource = readFileSync(new URL("../../package/install.sh", import.meta.url), "utf8"); function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } describe("preflight: clean-host — условие входа, а не проверка возможностей", () => { const context = testContext(baselineConfig()); test("в режиме install умолчания у checkCleanHost нет", async () => { await expect(preflight(context)).rejects.toThrow(/checkCleanHost must be stated explicitly/); await expect(preflight(context, { requireCapabilities: true })).rejects.toThrow( /checkCleanHost must be stated explicitly/ ); }); test("отказ наступает раньше любой работы с системой", async () => { // Проверка стоит первой в preflight, поэтому тест проходит и не под root, // и на не-Debian машине: до assertPlatform дело не доходит. await expect(preflight(context)).rejects.toThrow(/checkCleanHost/); }); }); describe("последовательность install", () => { test("clean-host запрашивается ровно один раз за операцию", () => { expect(occurrences(installSource, "checkCleanHost: true")).toBe(1); expect(occurrences(installSource, "checkCleanHost: false")).toBe(1); }); // Регрессия: повторный clean-host стоял ПОСЛЕ advanceInstallState и падал на // /var/lib/hy2xs/install-state.json, записанном этой же установкой. test("clean-host проверяется до первой записи состояния", () => { const cleanHostAt = installSource.indexOf("checkCleanHost: true"); const firstStateWriteAt = installSource.indexOf("await advanceInstallState("); expect(cleanHostAt).toBeGreaterThan(-1); expect(firstStateWriteAt).toBeGreaterThan(-1); expect(cleanHostAt).toBeLessThan(firstStateWriteAt); }); test("проход capabilities явно отказывается от clean-host", () => { const capabilitiesAt = installSource.indexOf("requireCapabilities: true"); const optOutAt = installSource.indexOf("checkCleanHost: false"); expect(capabilitiesAt).toBeGreaterThan(-1); // Оба флага стоят в одном вызове. expect(Math.abs(capabilitiesAt - optOutAt)).toBeLessThan(80); }); test("bootstrap — первый мутирующий шаг и он под флагом владения", () => { const flagAt = installSource.indexOf("ownership.bootstrapTouched = true"); const callAt = installSource.indexOf("await bootstrapRuntime("); const depsAt = installSource.indexOf("await installDeps("); expect(flagAt).toBeGreaterThan(-1); expect(callAt).toBeGreaterThan(-1); expect(flagAt).toBeLessThan(callAt); expect(callAt).toBeLessThan(depsAt); }); test("установка продолжается от установленного runtime-пакета", () => { expect(installSource).toContain("context.options.packageDir = await bootstrapRuntime(context)"); }); // Регрессия: diagnosticsCollect стоял перед rollback обычным await, поэтому // её собственный отказ (нет места, недоступен journalctl) отменял откат. test("отказ диагностики не отменяет rollback", () => { const diagnosticsAt = installSource.indexOf("await diagnosticsCollect(options)"); const rollbackAt = installSource.indexOf("await rollbackFailedInstall("); const tryAt = installSource.lastIndexOf("try {", diagnosticsAt); const catchAt = installSource.indexOf("catch (diagnosticsError)"); expect(tryAt).toBeLessThan(diagnosticsAt); expect(catchAt).toBeGreaterThan(diagnosticsAt); expect(catchAt).toBeLessThan(rollbackAt); }); }); describe("классификация отказа reconfigure/repair", () => { function ownership(overrides: Partial[0]> = {}) { return { configTouched: false, unitsTouched: false, firewallTouched: false, envTouched: false, servicesRestarted: false, ...overrides }; } // Регрессия: фаза выбиралась регулярным выражением по тексту ошибки. Тот же // приём уже убрали из install — здесь он остался. test("до firewall отказ не выдаёт себя за firewall failure", () => { expect(classifyReconfigureFailure(ownership())).toBe("reconfigure_failed"); expect(classifyReconfigureFailure(ownership({ configTouched: true }))).toBe("reconfigure_failed"); expect(classifyReconfigureFailure(ownership({ unitsTouched: true }))).toBe("reconfigure_failed"); }); test("тронутый firewall даёт firewall failure", () => { expect(classifyReconfigureFailure(ownership({ unitsTouched: true, firewallTouched: true }))).toBe( "firewall_connectivity_failure" ); }); test("после рестарта сервисов отказ относится к smoke", () => { expect( classifyReconfigureFailure(ownership({ firewallTouched: true, servicesRestarted: true })) ).toBe("smoke_failed"); }); // Проверяется код, а не упоминание: комментарий, объясняющий, ПОЧЕМУ // классификация по тексту ошибки убрана, должен быть разрешён. test("текст ошибки на классификацию не влияет", () => { expect(reconfigureSource).not.toContain(".test(message)"); expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership, error)"); }); /** * Сработавший guard классифицируется по ТИПУ ошибки. * * Разница с прежним разбором сообщения принципиальна, и проверяется она * именно так: ошибка, в тексте которой есть все нужные слова, но у которой * другой тип, обязана классифицироваться по владению, как и раньше. */ test("сработавший guard опознаётся по типу ошибки, а не по её тексту", () => { const late = ownership({ firewallTouched: true, servicesRestarted: true }); expect(classifyReconfigureFailure(late, new FirewallGuardFiredError("guard"))).toBe( "firewall_guard_fired" ); expect( classifyReconfigureFailure(late, new Error("automatic firewall rollback has already fired")) ).toBe("smoke_failed"); expect(classifyReconfigureFailure(late)).toBe("smoke_failed"); }); test("отказ диагностики не отменяет откат firewall и конфигов", () => { const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)"); const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)"); const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)"); expect(catchAt).toBeGreaterThan(diagnosticsAt); expect(catchAt).toBeLessThan(firewallRollbackAt); expect(firewallRollbackAt).toBeLessThan(stateRollbackAt); }); }); describe("install.sh остаётся read-only bootstrap", () => { test("установщик запускает preflight и передаёт мутацию через exec", () => { expect(installerSource).toContain("preflight-install --package-dir"); expect(installerSource).toMatch(/exec "\$ORCHESTRATOR" install --package-dir/); }); test("в установщике не осталось ни одной мутирующей команды", () => { const mutating = /^\s*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)\s/; const offenders = installerSource .split(/\r?\n/) .map((line, index) => ({ line, number: index + 1 })) .filter((entry) => mutating.test(entry.line)); expect( offenders, `install.sh мутирует хост: ${offenders.map((entry) => `${entry.number}: ${entry.line.trim()}`).join("; ")}` ).toEqual([]); }); test("установщик не раскладывает оркестратор сам", () => { expect(installerSource).not.toContain("ORCHESTRATOR_INSTALL_PATH="); expect(installerSource).not.toContain("RUNTIME_PACKAGE_DIR="); }); });