import { afterEach, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; import { writeText } from "../src/lib/fs"; import { runMutating, runMutatingHidden, runMutatingVisible } from "../src/lib/process"; /** * doctor обязан диагностировать установку, не изменяя её. * * Первая регрессия. doctor собирал контекст с `skipServiceStart` по умолчанию * (false) и звал общий smoke, а тот первым же действием выполняет * `systemctl restart hysteria-server hy2xs-admin`. Команда, которую runbook * предлагает запускать при подозрении на проблему, обрывала все живые * VPN-соединения на сервере — в том числе когда с сервисом всё в порядке. * * Вторая — уровнем глубже. Принудительный `skipServiceStart` закрывал ровно * одну ИЗВЕСТНУЮ мутацию. Всё остальное в smoke держалось на выборе раннера * автором правки, а читающие команды (`test -s`, `grep -q`, `stat`, * `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть * настоящая мутация, случайно добавленная в smoke, ничем бы от них не * отличалась и была бы разрешена в doctor молча. * * Часть утверждений проверяется разбором исходника: поднять настоящие * systemd-юниты и nftables в этой среде нельзя. Утверждения при этом точные — * рестарт в smoke стоит ровно под одним условием, и мутирующий раннер в smoke * ровно один. */ afterEach(() => { disableReadOnlyGuard(); }); // Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и // из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик // обычно второе. function source(relativeToSrc: string): string { return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); } describe("doctor не перезапускает сервисы", () => { test("контекст doctor принудительно выставляет skipServiceStart", () => { const doctorSource = source("commands/doctor.ts"); expect(doctorSource).toContain("skipServiceStart: true"); }); test("skipServiceStart выставляется ПОСЛЕ раскрытия options и не может быть перекрыт", () => { const doctorSource = source("commands/doctor.ts"); const spread = doctorSource.indexOf("...options"); const forced = doctorSource.indexOf("skipServiceStart: true"); expect(spread).toBeGreaterThan(-1); expect(forced).toBeGreaterThan(-1); // Порядок в объектном литерале решает: `{ ...options, skipServiceStart: true }` // игнорирует значение из командной строки, обратный порядок — нет. expect(forced).toBeGreaterThan(spread); }); test("smoke по-прежнему рестартует сервисы только под флагом", () => { const smokeSource = source("steps/smoke.ts"); const restart = "systemctl restart hysteria-server hy2xs-admin"; expect(smokeSource).toContain(restart); // Ровно одно место рестарта, и оно под проверкой флага: иначе выставление // skipServiceStart в doctor ничего бы не гарантировало. expect(smokeSource.split(restart).length - 1).toBe(1); const guard = smokeSource.indexOf("if (!context.options.skipServiceStart)"); const restartAt = smokeSource.indexOf(restart); expect(guard).toBeGreaterThan(-1); expect(guard).toBeLessThan(restartAt); }); test("doctor не глушит остальные проверки smoke", () => { const doctorSource = source("commands/doctor.ts"); // skipSmoke приходит из options и по умолчанию false: doctor сужает только // рестарт, а не диагностику целиком. expect(doctorSource).not.toContain("skipSmoke: true"); expect(doctorSource).toContain("await smoke(context)"); }); }); describe("read-only — инвариант doctor, а не свойство текущего кода", () => { test("doctor включает read-only guard и снимает его в finally", () => { const doctorSource = source("commands/doctor.ts"); expect(doctorSource).toContain("enableReadOnlyGuard("); expect(doctorSource).toContain("disableReadOnlyGuard()"); // Guard обязан включаться ДО первой работы и сниматься в finally: иначе // отказ любой проверки оставил бы глобальное состояние взведённым. const enable = doctorSource.indexOf("enableReadOnlyGuard("); const body = doctorSource.indexOf("await readText(options.sourceConfigPath)"); const finallyAt = doctorSource.indexOf("} finally {"); const disable = doctorSource.indexOf("disableReadOnlyGuard()"); expect(enable).toBeLessThan(body); expect(finallyAt).toBeGreaterThan(-1); expect(disable).toBeGreaterThan(finallyAt); }); // Ровно один мутирующий вызов на весь smoke, и он под флагом, который doctor // выставляет принудительно. Всё остальное классифицировано как наблюдение. test("в smoke не осталось мутирующих раннеров, кроме рестарта под флагом", () => { const smokeSource = source("steps/smoke.ts"); const calls = smokeSource .split(/\r?\n/) .filter((line) => !line.trimStart().startsWith("//")) .filter((line) => /\brunMutating[A-Za-z]*`/.test(line)); expect(calls, `лишние мутирующие вызовы в smoke: ${calls.join("; ")}`).toHaveLength(1); expect(calls[0]).toContain("systemctl restart hysteria-server hy2xs-admin"); }); // Регрессия: ожидание между двумя чтениями выполнялось подпроцессом `sleep` // через мутирующий раннер, то есть пауза объявлялась изменением системы. test("ожидание между попытками не является операцией над хостом", () => { const smokeSource = source("steps/smoke.ts"); expect(smokeSource).not.toMatch(/runMutating[A-Za-z]*`sleep/); expect(smokeSource).toContain("await Bun.sleep(delayMs)"); }); test("проверки прав и синтаксиса классифицированы как наблюдение", () => { const smokeSource = source("steps/smoke.ts"); for (const observation of [ "runReadOnly`test -s /etc/hysteria/config.yaml`", "runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`", "runReadOnly`nft -c -f /etc/nftables.conf`" ]) { expect(smokeSource).toContain(observation); } }); // Поведенческая часть: механизм, на который опирается doctor, действительно // отказывает любой мутации и называет причину. test("под guard'ом doctor любая мутация отказывает с его причиной", async () => { enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)"); expect(isReadOnlyGuardActive()).toBe(true); for (const call of [ () => runMutating`systemctl restart hysteria-server`, () => runMutatingVisible`nft -f /etc/nftables.conf`, () => runMutatingHidden`install -d /var/lib/hy2xs`, () => writeText("/etc/hysteria/config.yaml", "x") ]) { await expect(call()).rejects.toThrow(/read-only guard violation.*doctor/s); } }); });