b22b4b0d99
Два свойства были описаны в документации, но не обеспечены кодом.
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: до неё появление дубликата было ненаблюдаемым.
149 lines
8.5 KiB
TypeScript
149 lines
8.5 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|