Files
HY2XS_flamy/orchestrator/test/doctor-readonly.test.ts
T
founder b22b4b0d99 fix(v1): сделать read-only свойством doctor, а sentinel-ошибки — решением
Два свойства были описаны в документации, но не обеспечены кодом.

1. doctor «не изменяет диагностируемую систему».

   Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию —
   рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки
   выбрал правильный раннер: `test -s`, `grep -q`, `stat`, `sudo -u ... test`
   и `nft -c` шли через мутирующий namespace, хотя ничего не меняют. Ожидание
   между попытками выполнялось подпроцессом `sleep` через runMutatingHidden,
   то есть пауза между двумя чтениями объявлялась изменением системы.

   Следствие: настоящая мутация, случайно добавленная в smoke, ничем бы от них
   не отличалась и была бы разрешена в doctor молча — а включить guard было
   нельзя, он отказал бы на первой же читающей команде.

   Команды классифицированы честно, `sleep` заменён таймером, и doctor целиком
   выполняется под тем же read-only guard, что и PHASE 0 установки. Guard
   снимается в finally. Диагностика при этом не сузилась: слушатели, healthz,
   права, machine auth, trafficStats, версия бинаря, семантика конфига и
   синтаксис nft проверяются полностью.

2. reset-admin различает «администратора нет» и «база не ответила».

   Слой данных специально возвращает разные sentinel'ы, но команда склеивала их
   обычным `if err != nil { создать } else { обновить }`. Опасен здесь не
   только нарушенный смысл: при транзиентном отказе чтения («database is
   locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ
   учётные записи администратора. GetAdminUser берёт First() и о второй строке
   не сообщает — на сервере оставалась вторая рабочая учётка с паролем, уже
   напечатанным на экран, и ни один запрос об этом не говорил.

   Заодно исправлено проглатывание ошибки хеширования: в ветке обновления
   стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При
   отказе bcrypt в password_hash уезжала пустая строка, а на экран печатался
   пароль, которым войти уже невозможно — VerifyPassword отклоняет всё, что не
   bcrypt. Команда восстановления доступа умела молча его отобрать.

Тесты: doctor-readonly.test.ts дополнен поведенческой проверкой guard и
контролем набора раннеров в smoke; apps/cmd/reset_test.go проверяет обе ветки
на настоящей SQLite и отказ чтения при полностью работоспособной базе — ровно
тот случай, который прежний код превращал во второго администратора. Добавлена
dao.CountAdminUsers: до неё появление дубликата было ненаблюдаемым.
2026-08-30 18:28:39 +05:00

149 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
});
});