65042ee335
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
904 lines
71 KiB
Markdown
904 lines
71 KiB
Markdown
# A. Тесты слоя сборки
|
||
|
||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||
|
||
## A. Builder layer tests
|
||
|
||
### Проверяем
|
||
1. builder запускается на Debian 13 amd64 build host
|
||
2. итоговый пакет собирается без target-side шагов
|
||
3. bundled HY2XS admin реально входит в пакет
|
||
4. package metadata / build id присутствуют
|
||
5. compiled Bun/TypeScript orchestrator artifact присутствует
|
||
6. в пакет не попадает build-мусор
|
||
7. builder сам доставляет отсутствующие build-зависимости
|
||
8. builder проверяет версии Go/Bun/Node.js/pnpm
|
||
9. builder пишет версии toolchain в metadata
|
||
10. builder прогоняет `bun test` и `go test` до упаковки
|
||
|
||
## A1. Latest-stable resolver
|
||
|
||
Фикстуры и ожидаемое поведение (`orchestrator/test/hysteria-release.test.ts`):
|
||
|
||
| Сценарий | Ожидание |
|
||
| --- | --- |
|
||
| stable `app/v2.12.2` | выбирается |
|
||
| prerelease `app/v2.13.0` | игнорируется |
|
||
| draft `app/v2.14.0` | игнорируется |
|
||
| чужое семейство тегов (`core/`, `docs/`) | игнорируется |
|
||
| тег без префикса `app/` | игнорируется |
|
||
| `app/v2.9.10` против `app/v2.9.2` | выбирается `2.9.10` (числовое сравнение, не строковое) |
|
||
| отсутствует `hysteria-linux-amd64` | ошибка |
|
||
| дублирующийся `hysteria-linux-amd64` | ошибка, а не случайный выбор |
|
||
| non-https URL артефакта | ошибка |
|
||
| невалидный semver в теге | игнорируется |
|
||
| пустой список релизов | понятная ошибка |
|
||
| несовпадение SHA-256 | сборка падает |
|
||
| сетевая ошибка / rate limit | понятная ошибка с подсказкой про `GITHUB_TOKEN` и `HYSTERIA_CHANNEL=pinned` |
|
||
|
||
Отдельно проверяется, что **`latest stable` — это именно stable, а не максимальная строка или самый свежий тег**.
|
||
|
||
## A2. Release rollover
|
||
|
||
Ключевой acceptance-критерий модели «latest на сборке»:
|
||
|
||
```text
|
||
Сегодня: latest = 2.12.2 → пакет A закрепляет 2.12.2
|
||
Завтра: latest = 2.12.3 → пакет B закрепляет 2.12.3
|
||
|
||
Повторная установка пакета A всё равно ставит 2.12.2
|
||
```
|
||
|
||
Проверяется на двух уровнях:
|
||
- резолвер даёт разный результат на разных снимках upstream (`orchestrator/test/release-rollover.test.ts`);
|
||
- install-time код не импортирует резолвер, не обращается к `api.github.com` и не использует moving `latest` — это утверждение проверяется тестом и acceptance-шагом сборки.
|
||
|
||
## A3. Compatibility gate
|
||
|
||
1. скачанный артефакт проходит проверку SHA-256;
|
||
2. `hysteria version` совпадает с разрешённой версией;
|
||
3. реальный бинарник принимает канонический конфиг HY2XS для Gecko;
|
||
4. то же для Salamander;
|
||
5. при несовместимости падает **сборка** с сообщением `BUILD FAILED: unsupported Hysteria stable vX.Y.Z`, а не установка у пользователя.
|
||
|
||
## A4. Конфигурационный контракт (unit)
|
||
|
||
Таблица `orchestrator/test/env.test.ts`:
|
||
|
||
| Вход | Ожидание |
|
||
| --- | --- |
|
||
| значение не задано | `gecko` |
|
||
| `gecko` | принято |
|
||
| `salamander` | принято |
|
||
| неизвестный тип | отклонено |
|
||
| `Gecko` (регистр) | отклонено |
|
||
| gecko `max < min` | отклонено |
|
||
| gecko `max > 2048` | отклонено |
|
||
| gecko `max == 2048` | принято |
|
||
| неположительный/нецелый `min` | отклонено |
|
||
| пустой obfs-пароль | автогенерация, а не пустое значение в конфиге |
|
||
| `HY2XS_CONFIG_SCHEMA_VERSION=1` | отклонено с указанием на чистую установку |
|
||
| `HY2XS_CONFIG_SCHEMA_VERSION` отсутствует | отклонено как legacy-конфигурация |
|
||
| `HY2XS_CONFIG_SCHEMA_VERSION=` (пусто) | отклонено как legacy-конфигурация |
|
||
| `HY2XS_CONFIG_SCHEMA_VERSION=2` | принято |
|
||
|
||
Отсутствие маркера схемы отклоняется намеренно: до v1 этого поля не
|
||
существовало, поэтому именно пустое значение — самый вероятный признак
|
||
конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в
|
||
якобы валидный.
|
||
|
||
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
|
||
|
||
### A4a. Формат env-файла совпадает с systemd
|
||
|
||
`orchestrator/test/env-file.test.ts`. У `hy2xs.env` два читателя, и один из них
|
||
не наш: файл объявлен `EnvironmentFile=` в юните `hy2xs-admin`. Поэтому
|
||
проверяется не «работает на наших данных», а совпадение с правилами systemd
|
||
(`src/basic/env-file.c`) на тех значениях, где прежний разбор расходился:
|
||
|
||
| Вход | Ожидание |
|
||
| --- | --- |
|
||
| `KEY= value ` | `value` — незакавыченное значение теряет краевые пробелы, как и у systemd |
|
||
| `KEY=" value "` | ` value ` — в кавычках сохраняются |
|
||
| `KEY="a\"b"` | `a"b` |
|
||
| `KEY="a\\b"` | `a\b` |
|
||
| `KEY="a\nb"` | `a\nb` — `n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется |
|
||
| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет |
|
||
| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе |
|
||
| строка без `=` | отказ (единственное намеренное расхождение: systemd её отбрасывает молча) |
|
||
| незакрытая кавычка | отказ |
|
||
|
||
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
|
||
слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а
|
||
обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без
|
||
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
|
||
|
||
Управляющий символ в значении — отказ ЗАПИСИ, а не потеря части секрета.
|
||
|
||
Рендер конфига (`orchestrator/test/render-config.test.ts`):
|
||
|
||
- Gecko рендерит **только** gecko-подблок;
|
||
- Salamander рендерит **только** salamander-подблок;
|
||
- в конфиге никогда нет двух подтипов obfs одновременно;
|
||
- шаблон не содержит захардкоженного типа обфускации;
|
||
- пароль с пробелами и спецсимволами экранируется;
|
||
- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
|
||
|
||
## A5. Граница установки и поколение (unit)
|
||
|
||
`orchestrator/test/clean-host.test.ts`:
|
||
|
||
- чистый хост проходит;
|
||
- **каждый** маркер по отдельности останавливает установку;
|
||
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
|
||
- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`)
|
||
попадают в список, а не только значения по умолчанию;
|
||
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
|
||
описывают одну границу и не имеют права разъезжаться;
|
||
- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`,
|
||
`/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**:
|
||
их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой»
|
||
версии списка для PHASE 1 больше не существует;
|
||
- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который
|
||
их создаёт, и контракт, который на них отказывает, обязаны читать одно
|
||
значение;
|
||
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
|
||
|
||
`orchestrator/test/install-boundary.test.ts`:
|
||
|
||
- под read-only guard недоступны `writeText`, `writeTextAtomic`, `ensureDir` и
|
||
все `runMutating*`-раннеры;
|
||
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
|
||
запрет наблюдения, а запрет мутации;
|
||
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
||
ошибки;
|
||
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
|
||
`stateTouched`: записанный `install-state.json` уже делает хост изменённым;
|
||
- частично выполненная запись маркера (отказ на `chown` после успешного `write`)
|
||
тоже даёт post-apply: флаг взводится **до** записи, а не после неё;
|
||
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
||
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
|
||
установщик заявил, что ничего не тронул»;
|
||
- начатый bootstrap (`bootstrapTouched`) тоже даёт `fatal_post_apply`: раскладку
|
||
выполняет оркестратор, и она учитывается наравне с остальными шагами.
|
||
|
||
`orchestrator/test/install-sequence.test.ts` — порядок фаз, который иначе
|
||
проверяется только на живом сервере:
|
||
|
||
- `preflight()` в режиме install **отказывается работать без явного
|
||
`checkCleanHost`**, и отказ наступает до любой работы с системой;
|
||
- clean-host запрашивается ровно один раз за операцию и **до** первой записи
|
||
install-state — регрессия на сценарий, где повторный preflight после
|
||
`installDeps` опознавал собственный `install-state.json` как маркер чужой
|
||
установки и валил каждую чистую установку;
|
||
- проход capabilities явно отказывается от clean-host;
|
||
- `bootstrapTouched` взводится **перед** `bootstrapRuntime`, а сам bootstrap
|
||
идёт до `installDeps`;
|
||
- дальнейшая установка работает от установленного runtime-пакета;
|
||
- `diagnosticsCollect` обёрнута в `try/catch`, и `catch` стоит **до** отката:
|
||
диагностика — best effort, откат — обязателен;
|
||
- в `package/install.sh` не осталось ни одной мутирующей команды, и он
|
||
передаёт управление оркестратору через `exec`;
|
||
- классификация отказа `reconfigure`/`repair` идёт по ownership-флагам, а не по
|
||
регулярному выражению над текстом ошибки.
|
||
|
||
`orchestrator/test/install-state.test.ts`:
|
||
|
||
- маркер текущего поколения принимается;
|
||
- маркер без полей поколения отклоняется, **несмотря на `installed: true`**;
|
||
- чужой `product`, `release_line` или `config_schema_version` отклоняются;
|
||
- записываемый маркер всегда несёт идентификацию поколения;
|
||
- незавершённая установка подсказывает `repair --allow-partial-state`.
|
||
|
||
## A5a. Обязательный откат (unit)
|
||
|
||
`orchestrator/test/rollback-mandatory.test.ts` — поведение механизма проверяется
|
||
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
|
||
исходника (поднять systemd и nftables в этой среде нельзя):
|
||
|
||
- при отказе первой стадии отката выполняются **все** последующие;
|
||
- отказавшие стадии перечисляются по именам и в порядке объявления;
|
||
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная
|
||
ошибка операции, а не проблема внутри восстановления;
|
||
- не-`Error` причина (брошенная строка) не роняет откат;
|
||
- `persistFailureState` не пробрасывает отказ записи наружу — это и был P0:
|
||
падение записи маркера отменяло откат целиком;
|
||
- в обработчике ошибки `install` и `reconfigure` не осталось незащищённой записи
|
||
состояния (`advanceInstallState` / `markPhase` голым `await`);
|
||
- откат в обеих командах идёт через `runRollbackStages`, а не цепочкой `await`;
|
||
- внутри `rollbackCurrentState` ни одна команда не обрывает следующие: отказ
|
||
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
|
||
восстановленные unit-файлы так и не применялись.
|
||
|
||
## A5c. Целостность резервных копий (unit)
|
||
|
||
`orchestrator/test/backup-integrity.test.ts`:
|
||
|
||
- копия каждой операции адресуется своим каталогом, разные `op-id` не
|
||
пересекаются;
|
||
- разные пути дают разные имена файлов копии, и имя не выходит за пределы
|
||
каталога;
|
||
- отсутствовавший файл записан **явно** (`present: false`), а не выведен из
|
||
неудачи `cp`;
|
||
- манифест переживает сериализацию без потерь;
|
||
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON,
|
||
запись без пути, без признака существования или без имени копии —
|
||
отклоняются. «Поле не разобралось, будем считать, что файла не было» означало
|
||
бы удаление существующего файла при откате;
|
||
- копирование в `reconfigure` и в `firewall` не глушит ошибки, факт создания
|
||
копии проверяется, а копия снимается **до** первой мутации;
|
||
- маркер готовности firewall (`prepared`) ставится после проверенных копий;
|
||
- восстановление firewall не глушит ошибки `cp`/`nft`, а резервные копии
|
||
удаляются только после подтверждённого успеха — иначе сохраняются вместе с
|
||
сообщением `manual recovery data preserved at …`.
|
||
|
||
## A5d. Порядок фиксации успеха (unit)
|
||
|
||
`orchestrator/test/commit-ordering.test.ts`:
|
||
|
||
- снятие таймера автоотката и удаление резервных копий — **разные** операции
|
||
(`disarmFirewallRollback` / `cleanupFirewallRollback`), объединённая
|
||
`cancelFirewallRollback` не вернулась ни в один вызов;
|
||
- `disarm` не удаляет копии;
|
||
- порядок в `install` и `reconfigure` одинаков: `disarm` → долговечная запись
|
||
`installed` → `cleanup`;
|
||
- успешный smoke фиксируется отдельной фазой до снятия таймера;
|
||
- уборка после точки фиксации выполняется best-effort.
|
||
|
||
## A5e. Транзакционность rollback guard (unit)
|
||
|
||
`orchestrator/test/firewall-guard.test.ts`:
|
||
|
||
- маркер `auto-rollback-fired` создаётся rollback-скриптом **первым действием** —
|
||
до проверки `prepared` и до первой попытки восстановления, в том числе когда
|
||
восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные
|
||
юниты systemd после выполнения исчезают, и `systemctl stop` для них неотличим
|
||
от успешного снятия взведённого таймера;
|
||
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
||
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
||
поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`;
|
||
- `rc` объявляется **до** создания маркера, а ранний выход возвращает его, а не
|
||
жёсткий `0`. Инвариант фиксации верен только при условии «guard способен
|
||
записать маркер»: пока `rc=0` стояло после, отказ записи (заполненный tmpfs
|
||
`/run`, read-only ФС) не влиял ни на что — скрипт восстанавливал прежний
|
||
firewall, завершался нулём, и операция фиксировала успех после реально
|
||
сработавшего отката. Теперь у факта два канала: маркер и отказ юнита;
|
||
- маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin POSIX,
|
||
и ошибка перенаправления на нём обязана завершить неинтерактивный shell
|
||
целиком, то есть в dash скрипт умер бы **до** восстановления firewall;
|
||
- поведенчески проверяется ранний путь скрипта — он заканчивается до первой
|
||
команды восстановления и потому безопасен для запуска: при доступном каталоге
|
||
маркер создаётся и выход нулевой, при недоступном — выход ненулевой;
|
||
- скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и
|
||
остановка сервиса стёрла бы только что восстановленные правила;
|
||
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
||
после двусторонней проверки — он обязан принять заведомо корректный скрипт и
|
||
отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
|
||
- небезопасный ключ операции отвергается: он служит именем каталога, именем
|
||
systemd-юнита и подставляется в текст скрипта;
|
||
- снятие guard проверяет маркер **с обеих сторон** остановки, подтверждается
|
||
`ActiveState` обоих юнитов, и на пути фиксации успеха допускает единственное
|
||
состояние — `inactive`; на пути восстановления `failed` тоже допустим;
|
||
- сработавший guard опознаётся по **типу** ошибки: ошибка с тем же текстом, но
|
||
другого типа классифицируется по владению, как и прежде;
|
||
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
|
||
загруженная таблица), и проверка выполняется read-only раннерами — та же
|
||
проверка идёт в `doctor`;
|
||
- состояние `nftables.service` снимается до первой мутации и восстанавливается
|
||
стадиями, идущими **до** применения ruleset;
|
||
- candidate-файлы убираются после успеха и best-effort при откате;
|
||
- ключ операции считается одной функцией: install писал в маркер сырой
|
||
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал;
|
||
- команда взведения guard проверяется как **значение**, а не грепом по
|
||
исходнику: `buildArmGuardArgv` возвращает готовый argv, и тест сверяет его
|
||
целиком — имя юнита с явным суффиксом, `--on-active=45s`,
|
||
`--timer-property=AccuracySec=1s`, `--timer-property=RemainAfterElapse=no`.
|
||
Небезопасный ключ операции отвергается до запуска: shell в этой команде не
|
||
участвует, поэтому единственная защита — отказ;
|
||
- барьер покоя проверяется **поведенчески**, с подставляемым наблюдателем
|
||
systemd (`SystemdUnitProbe`), без systemd и без Linux:
|
||
- взведённый таймер (`active/waiting`) и идущий прямо сейчас откат
|
||
(`activating`) запрещают операцию с `PendingRecoveryError`;
|
||
- отказ `list-units` **и** отказ `show` на любом отдельном юните дают
|
||
`GuardStateUnknownError`: отсутствие ответа systemd — отсутствие
|
||
наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное
|
||
(`return [];` в тексте функции) и потому пережил инверсию смысла: строка
|
||
была на месте, а решение стало неверным;
|
||
- оба отказа имеют общего предка `OperationBarrierError`;
|
||
- покой — ровно `inactive` и `failed`; `maintenance`, `refreshing` и любое
|
||
незнакомое состояние блокируют операцию, потому что политика перечисляет
|
||
безопасные состояния, а не опасные;
|
||
- `*.timer` в `SubState=elapsed` считается покоем: `TIMER_ELAPSED` в systemd
|
||
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер
|
||
блокировал бы `repair` навсегда. У сервиса тот же `SubState` ничего не
|
||
значит;
|
||
- разбор вывода `list-units` находит имя юнита и когда первой колонкой идёт
|
||
маркер `●` (состояние `failed`) — иначе терялся бы именно аварийно
|
||
сработавший guard.
|
||
|
||
## A5f. Взаимное исключение операций (unit)
|
||
|
||
`orchestrator/test/operation-lock.test.ts`:
|
||
|
||
- второй захват при живом держателе отказывает, и отказ называет держателя —
|
||
команду, PID и время начала;
|
||
- замок снимается в `finally` и после отказа операции: иначе первая же неудачная
|
||
установка заблокировала бы сервер до перезагрузки;
|
||
- замок мёртвого держателя переиспользуется, временный файл переиспользования не
|
||
остаётся на диске;
|
||
- непонятое содержимое замка **не** снимается автоматически: оно не доказывает
|
||
отсутствие операции, и сомнение трактуется в пользу отказа;
|
||
- `readLockHolder` отличает «замка нет» от «замок нечитаем»;
|
||
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
||
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
||
читающей фазы отказал громко, а не записал файл молча;
|
||
- барьер покоя вызывается **дважды** — до захвата и уже под замком, — а отказ
|
||
второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт:
|
||
он защищает production paths, пока жив держатель, а rollback guard переживает
|
||
свой процесс;
|
||
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
||
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
||
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
||
|
||
## A5b. Долговечная запись маркера (unit)
|
||
|
||
`orchestrator/test/atomic-write.test.ts`:
|
||
|
||
- содержимое заменяется целиком, а не дописывается поверх прежнего;
|
||
- при отказе записи по целевому пути остаётся **прежний полный** документ;
|
||
- временный файл не выживает ни при успехе, ни при отказе подстановки;
|
||
- права выставляются точно, независимо от umask (`0600`, `0644`);
|
||
- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir`
|
||
этого не делает, поэтому «создать» и «права такие, как объявлено» — два
|
||
разных действия;
|
||
- и запись, и создание каталога проходят через read-only guard;
|
||
- `persistInstallState` под guard'ом отказывает: единственный писатель маркера
|
||
обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать
|
||
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
|
||
файла, по которому clean-host принимает решение.
|
||
|
||
- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель
|
||
синхронизируется только при создании, иначе долговечность записи `hy2xs` в
|
||
`/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть
|
||
весь каталог вместе с маркером.
|
||
|
||
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
|
||
пользовательского процесса их не наблюдать, а без них `rename()` даёт
|
||
атомарность видимости без долговечности.
|
||
|
||
## A6. Редактирование секретов (unit)
|
||
|
||
`orchestrator/test/redaction.test.ts`:
|
||
|
||
- machine token не переживает редакцию серверного конфига — регрессия на
|
||
построчное правило `auth:`, оставлявшее нетронутым `auth.http.url`;
|
||
- obfs-пароль не переживает редакцию;
|
||
- результат остаётся валидным YAML;
|
||
- несекретные поля сохраняются: диагностика должна оставаться полезной;
|
||
- неизвестное поле с секретоподобным именем вырезается;
|
||
- `acme.dns.config` вырезается целиком;
|
||
- невалидный YAML не роняет редакцию и всё равно чистится;
|
||
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
|
||
(`HY2_AUTH_URL`);
|
||
- URL под **произвольным** именем ключа теряет встроенные учётные данные и
|
||
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
|
||
- `redactLogText` вырезает machine token из строки journald, сохраняя host,
|
||
port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет
|
||
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
||
редактировались env и YAML, а `journal-admin.log` копировался как есть.
|
||
|
||
## A7. Machine token в журналах (unit)
|
||
|
||
`apps/middleware/log_test.go` — запрос
|
||
`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`:
|
||
|
||
- sentinel **не появляется** в журнале ни в каком виде;
|
||
- в журнале есть `reqPath`, поля `reqUri` нет;
|
||
- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет;
|
||
- пустой список параметров в журнал не пишется;
|
||
- то же правило действует на операторских маршрутах, а не только на машинном.
|
||
|
||
`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал
|
||
Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт
|
||
`access_token`, который upstream волен упомянуть в сообщении об ошибке.
|
||
|
||
## A8. Инвариант публичного endpoint (unit)
|
||
|
||
`orchestrator/test/network-endpoint.test.ts` — проба подменяет и DNS, и список
|
||
локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины
|
||
разработчика.
|
||
|
||
| Сценарий | Результат |
|
||
| --- | --- |
|
||
| A-запись == текущий публичный IPv4 | PASS |
|
||
| A-запись == старый IPv4 | FAIL, в тексте оба адреса |
|
||
| A-запись отсутствует | FAIL |
|
||
| A == текущий + чужой | FAIL |
|
||
| у сервера 2 публичных IP, DNS использует один | PASS |
|
||
| `PUBLIC_HOST` — правильный IPv4-литерал | PASS |
|
||
| `PUBLIC_HOST` — устаревший IPv4-литерал | FAIL |
|
||
| `DOMAIN` совпадает, отдельный `PUBLIC_HOST` устарел | FAIL |
|
||
| `PUBLIC_HOST` совпадает, отдельный TLS-домен устарел | FAIL |
|
||
| нет ни одного локального публичного IPv4 | FAIL |
|
||
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
|
||
| отсутствие A-записи при любой политике | FAIL |
|
||
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
|
||
|
||
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
|
||
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
|
||
reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные**
|
||
диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются
|
||
публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше
|
||
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
|
||
сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются
|
||
публичными.
|
||
|
||
Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN`
|
||
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
|
||
ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как
|
||
«has no A-record», и при сломанном резолвере оператор шёл править запись,
|
||
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
|
||
а не «выполнена с замечанием».
|
||
|
||
## A9. Регистрация маршрутов (unit)
|
||
|
||
`apps/router/router_test.go` — единственное место, где ошибка проявляется
|
||
**паникой при старте сервиса**, а не ответом с кодом. Конфликт с
|
||
wildcard-маршрутом фронтенда или дублирующая регистрация обнаружились бы иначе
|
||
только на живом сервере.
|
||
|
||
- контур маршрутов собирается без паники;
|
||
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
|
||
- операторский и auth API — под `constant.AdminAPIBase`;
|
||
- ни один маршрут не начинается со старого пространства имён;
|
||
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
|
||
вернулись;
|
||
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
|
||
новый обязан быть добавлен в тест осознанно;
|
||
- `/healthz` на месте.
|
||
|
||
## A9a. Доступ к таблице `config` (unit)
|
||
|
||
`apps/model/constant/config_test.go` — allowlist как структура, а не как
|
||
соглашение:
|
||
|
||
- ни один внутренний ключ не читается и не записывается через API;
|
||
- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и
|
||
`HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними;
|
||
- пользовательские настройки остаются доступными;
|
||
- множество записываемых ключей — подмножество читаемых;
|
||
- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы
|
||
сразу публичным.
|
||
|
||
`apps/controller/config_test.go` — то же на уровне HTTP:
|
||
|
||
- чтение и запись каждого секрета отклоняются;
|
||
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
|
||
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
|
||
что обработчик не падает, это и доказывает);
|
||
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
|
||
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`;
|
||
- удалённые ключи (`HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`,
|
||
`HYSTERIA2_TRAFFIC_TIME`, `HYSTERIA2_CONFIG_REMARK`) отклоняются как
|
||
неизвестные — проверка идёт по строковым литералам, потому что соответствующих
|
||
констант в коде уже нет и появиться они не должны.
|
||
|
||
Атомарность партии проверяется на **настоящей** SQLite: без базы утверждение
|
||
«партия не применилась частично» бессмысленно, поскольку предметом утверждения
|
||
является именно состояние базы.
|
||
|
||
- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение
|
||
первого ключа в базе **не изменилось**;
|
||
- невалидное cron-выражение → отказ, значение в базе не изменилось;
|
||
- один ключ дважды в партии → отказ (какое из двух значений считать намерением
|
||
оператора, определить нельзя);
|
||
- корректная партия → значение сохранено, расписание применено к планировщику,
|
||
число его записей не выросло.
|
||
|
||
Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ
|
||
**первым** и до второго элемента не доходила, поэтому проходила и на реализации,
|
||
которая проверяла и записывала настройки в одном цикле.
|
||
|
||
## A9b. Планировщик (unit)
|
||
|
||
`apps/service/cron_scheduler_test.go` — планировщик как собственность процесса:
|
||
|
||
- четыре последовательные смены расписания **не увеличивают** число записей
|
||
планировщика (главная регрессия: раньше каждая смена добавляла целый
|
||
дублирующий набор джоб, а старое расписание продолжало работать);
|
||
- пустое выражение снимает джобу сброса, непустое возвращает её — без
|
||
перезапуска процесса;
|
||
- невалидное выражение не меняет планировщик и не снимает действующую джобу;
|
||
- набор валидных и невалидных выражений проверяется тем же парсером, что и
|
||
runtime: то, что `cron` умеет, обязано приниматься, остальное — отклоняться;
|
||
- невалидное значение в базе **не роняет старт**: на панели висит
|
||
`/internal/hysteria/auth`, и отказ старта из-за строки расписания положил бы
|
||
подключения пользователей. Фиксированные джобы поднимаются, сброс отключён,
|
||
в журнале ERROR, и настройка чинится через API без перезапуска;
|
||
- второй `InitCron` поверх работающего отклоняется;
|
||
- `StopCron` идемпотентен и оставляет планировщик пустым.
|
||
|
||
## A9c. Токены и пароли (unit)
|
||
|
||
`apps/service/jwt_test.go`:
|
||
|
||
- round-trip: claims, включая `token_version`, переживают выписку и разбор;
|
||
- токен, подписанный **другим** HMAC-алгоритмом тем же ключом, отклоняется
|
||
(прежний `keyfunc` не смотрел на `token.Method` вовсе);
|
||
- токен без `exp` отклоняется, истёкший отклоняется отдельным сообщением;
|
||
- токен с чужим `issuer` отклоняется даже при совпадении ключа;
|
||
- пустой `JWT_SECRET` — отказ и на выписку, и на разбор, а не подпись ключом
|
||
нулевой длины.
|
||
|
||
`apps/util/encrypt_test.go`:
|
||
|
||
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
||
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
|
||
- любая не-bcrypt строка в поле хеша отклоняется;
|
||
- `HashPassword` следует ОБЩЕМУ контракту, а не собственной проверке. Раньше
|
||
здесь стояло `len(strings.TrimSpace(password)) < 6` — третья копия правила, и
|
||
она расходилась с двумя остальными: значение `abcde ` считалось корректным
|
||
всеми дверями продукта и не могло быть захешировано, а верхней границы у
|
||
копии не было вовсе;
|
||
- **всё, что контракт принял, обязано хешироваться.** Проверяются граничные
|
||
значения (36 x `я` = 72 байта, 18 x `😀` = 72 байта): именно здесь
|
||
расхождение с bcrypt и проявлялось.
|
||
|
||
## A9c1. Контракт учётных данных администратора (unit)
|
||
|
||
Контракт объявлен один раз в `apps/credential/admin.go` — в leaf-пакете, потому
|
||
что его зовут и `util.HashPassword`, и слой данных при создании первой учётной
|
||
записи, а `service` импортирует `util`.
|
||
|
||
`apps/credential/admin_test.go` — сам контракт:
|
||
|
||
- набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а
|
||
не вход администратора на живом сервере;
|
||
- границы пароля проверяются таблицей, и она включает случаи, где границы в
|
||
символах и в байтах расходятся:
|
||
|
||
| значение | символов | байт | результат |
|
||
| --- | --- | --- | --- |
|
||
| 64 x `a` | 64 | 64 | принят |
|
||
| 36 x `я` | 36 | 72 | принят (граница bcrypt) |
|
||
| 37 x `я` | 37 | 74 | **отвергнут** |
|
||
| 18 x `😀` | 18 | 72 | принят (граница bcrypt) |
|
||
| 19 x `😀` | 19 | 76 | **отвергнут** |
|
||
| 64 x `я` | 64 | 128 | **отвергнут** |
|
||
|
||
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
|
||
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
|
||
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
|
||
- управляющие символы (`\n`, `\r`, `\t`, `NUL`, `DEL`) отвергаются.
|
||
|
||
`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
|
||
`credentialStr` и `adminPassword` прогоняются через production-валидатор и
|
||
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
|
||
|
||
`apps/controller/auth_test.go`:
|
||
|
||
- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное
|
||
правило (обход исходников, а не проверка одного экземпляра);
|
||
- границы пароля **не стоят рядом** с правилом: тег умеет считать только
|
||
символы, а у пароля есть ещё граница в байтах, которую тегом не выразить;
|
||
- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт
|
||
валидации с причиной `admin_password_format` на поле `pass`, а не системную
|
||
ошибку из bcrypt;
|
||
- пароль не триммится: `bootstrap-password ` и `bootstrap-password` — разные
|
||
пароли.
|
||
|
||
`apps/dao/bootstrap_admin_test.go` — bootstrap-путь на настоящей SQLite:
|
||
|
||
- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход
|
||
обрезанным значением невозможен;
|
||
- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а
|
||
не сообщением bcrypt;
|
||
- пароль ровно в 72 байта проходит установку целиком.
|
||
|
||
`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов
|
||
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
||
5%. Прежняя реализация давала здесь отношение 1.25.
|
||
|
||
## A9d. Пир установщика (unit)
|
||
|
||
`apps/service/peer_bootstrap_guard_test.go` — защита действует во всех путях
|
||
записи, а не только в импорте:
|
||
|
||
- смена секрета и переименование `bootstrap-admin-peer` отклоняются, состояние
|
||
в базе не меняется;
|
||
- переименование обычного пира в зарезервированное имя отклоняется;
|
||
- создание пира с зарезервированным именем отклоняется;
|
||
- отключение и изменение квоты **разрешены**;
|
||
- удаление **разрешено**: это осознанное действие оператора, и расхождения
|
||
между базой и `bootstrap-admin.secret` оно не создаёт.
|
||
|
||
## A9e. Жизненный цикл пира установщика (unit, настоящая SQLite)
|
||
|
||
`apps/dao/bootstrap_peer_test.go` — проверяется не функция, а поведение сервиса
|
||
при перезапуске: дефект, ради которого написан этот файл, проявлялся только на
|
||
ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность
|
||
`InitSqlAt` дважды на одной базе.
|
||
|
||
- первый запуск создаёт пира и выставляет отметку `BOOTSTRAP_PEER_SEEDED`;
|
||
- обычный перезапуск не пересоздаёт пира и не плодит дублей (`id` тот же,
|
||
запись ровно одна);
|
||
- **удаление переживает перезапуск**: после `DELETE` и рестарта пир не
|
||
возвращается, хотя `HY2XS_ADMIN_CON_PASS` остаётся в окружении;
|
||
- то же после трёх перезапусков подряд;
|
||
- отключённый пир сохраняет `disabled = 1` и свой `secret_digest`;
|
||
- отметка и пир пишутся одной транзакцией: при конфликте `UNIQUE(name)` внутри
|
||
транзакции отметка не остаётся выставленной;
|
||
- отсутствие `HY2XS_ADMIN_CON_PASS` на чистой базе — отказ старта;
|
||
- перезапуск установленного сервиса без этой переменной проходит штатно;
|
||
- `HYSTERIA2_TRAFFIC_STATS_SECRET`: пустой env при пустой базе — отказ старта,
|
||
сгенерированного токена в базе не появляется; токен, уже согласованный
|
||
ранее, принимается без переменной.
|
||
|
||
## A9f. Резервная копия пиров (unit)
|
||
|
||
`apps/service/peer_export_backup_test.go`:
|
||
|
||
- `includeSecrets=true` на исправных данных отдаёт секрет каждого пира;
|
||
- нерасшифровываемый секрет хотя бы одного пира отклоняет **весь** запрос,
|
||
сообщение называет пира, частичное содержимое не возвращается;
|
||
- пир вовсе без шифртекста — тот же отказ;
|
||
- `includeSecrets=false` повреждённых данных не замечает и пустой `secret`
|
||
отдаёт штатно: это и есть смысл безопасного режима.
|
||
|
||
## A9g. Слой данных: «нет записи» против «база не ответила» (unit)
|
||
|
||
`apps/dao/config_test.go`:
|
||
|
||
- `UpdateConfig` по отсутствующей строке — **отказ**, а не тихий успех: UPDATE
|
||
без совпавших строк не является ошибкой SQL, и раньше оператор получал
|
||
подтверждение изменения, которого не произошло, а планировщик тут же получал
|
||
новое расписание;
|
||
- `UpdateConfig` не создаёт строк: это работа `UpsertConfigValue`;
|
||
- транзакционная партия откатывается целиком, если одна из строк отсутствует;
|
||
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
|
||
отличимые через `errors.Is` от `ErrStorage`.
|
||
|
||
`apps/cmd/reset_test.go` — единственный оставшийся потребитель, который склеивал
|
||
эти два ответа:
|
||
|
||
- на пустой базе `reset-admin` создаёт учётную запись, bcrypt-хеш подходит к
|
||
напечатанному паролю, `force_password_change` выставлен;
|
||
- поверх существующей записи обновление идёт **на месте**: тот же `id`, новый
|
||
пароль, увеличенный `token_version`, прежний пароль больше не действует;
|
||
- при отказе чтения (`ErrStorage`) сброс **останавливается**: вторая учётная
|
||
запись не создаётся, существующая не меняется, напечатанный пароль не
|
||
действует. База в этом тесте полностью работоспособна — воспроизводится ровно
|
||
транзиентный отказ («database is locked»), при котором прежний код уходил в
|
||
ветку создания и оставлял на сервере вторую рабочую учётку с уже
|
||
напечатанным паролем;
|
||
- при `ErrAdminUserNotFound` создание по-прежнему выполняется: строгость к
|
||
отказу хранилища не имеет права сломать штатный путь восстановления;
|
||
- непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
|
||
в `password_hash` — раньше ошибка хеширования проглатывалась
|
||
(`hash, _ := util.HashPassword(...)`), и команда восстановления доступа
|
||
молча его отбирала: `VerifyPassword` отклоняет всё, что не bcrypt;
|
||
- sentinel'ы разных таблиц несут одинаковый текст (`WrongPassword` уезжает в
|
||
ответ Hysteria и менять его нельзя), поэтому проверяется именно
|
||
различимость через `errors.Is`, а не по строке.
|
||
|
||
## A10. Импорт пиров (unit)
|
||
|
||
`apps/service/peer_import_test.go`:
|
||
|
||
- выгрузка, сделанная `ExportPeer`, принимается без правок;
|
||
- имя проверяется теми же правилами, что и при обычном создании пира: длина,
|
||
набор символов, отсутствие пробелов и переводов строки;
|
||
- `bootstrap-admin-peer` не может быть импортирован ни по имени, ни по `authId`:
|
||
его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`;
|
||
- диапазоны `quotaBytes`, `expiresAt`, `maxDevices`, `disabled`, `bannedUntil`,
|
||
счётчиков трафика и длины секрета проверяются;
|
||
- sentinel-значения (`quotaBytes = -1`, `maxDevices = 0`) остаются валидными;
|
||
- дубликаты имени и `authId` внутри одной партии отклоняются;
|
||
- партия сверх лимита отклоняется;
|
||
- невалидная **последняя** запись отклоняет весь файл: импорт применяется
|
||
целиком или не применяется вовсе.
|
||
|
||
`apps/service/peer_import_tx_test.go` — та же гарантия уже на уровне базы, на
|
||
настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего
|
||
не говорит о конфликте с тем, что УЖЕ лежит в базе:
|
||
|
||
- валидная партия применяется целиком;
|
||
- **cross-conflict откатывается полностью**: пусть в базе есть
|
||
`A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт
|
||
`(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается
|
||
переименовать её в `bob123`, прямо в `UNIQUE(name)`. Записи, шедшие в файле
|
||
до конфликтной, не должны остаться применёнными. Тест дополнительно
|
||
убеждается, что отказ пришёл **из базы**, а не из валидации;
|
||
- дубликат `auth_id` на вставке ведёт себя так же;
|
||
- отказ валидации не доходит до базы вовсе;
|
||
- пир установщика защищён и внутри транзакции;
|
||
- обновление без секрета в файле не перезаписывает существующий секрет.
|
||
|
||
`apps/controller/peer_test.go` — разбор загруженного файла:
|
||
|
||
- файл с **хвостовым** JSON-документом отклоняется: `json.Decoder` читает первый
|
||
документ и останавливается, поэтому раньше оператор видел «импорт выполнен»,
|
||
а вторая половина файла молча не применялась;
|
||
- неизвестные поля и файл не с расширением `.json` отклоняются;
|
||
- корректный одиночный документ доходит до базы и создаёт пира.
|
||
|
||
## A10a. Отзыв учётных данных и сходимость сессий (unit)
|
||
|
||
`apps/service/peer_secret_rotation_test.go` — контракт «новое поколение
|
||
credentials получает новую идентичность сессий». Проверяется поведение, а не
|
||
наличие поля:
|
||
|
||
- **сессия, установленная по отозванному секрету ПОСЛЕ успешного `/kick`,
|
||
завершается очередным циклом учёта.** Сценарий воспроизводится буквально:
|
||
авторизация по старому секрету удерживается внутри `GET /online`, за это время
|
||
выполняется полная ротация с `/kick` → 200, затем авторизация отпускается и
|
||
возвращает старый `authId` — то есть соединение регистрируется уже после
|
||
разрыва. Ни одна операция при этом не отказала; сходимость даёт то, что старое
|
||
поколение стало orphan;
|
||
- то же после `/kick` → 500;
|
||
- повторная отправка **того же** секрета рвёт сессию (повтор отзыва), но
|
||
идентичность не меняет: `secret_digest` тот же;
|
||
- секрет из одних пробелов не меняет ничего — ни digest, ни `auth_id`, ни
|
||
сессии; прежде правило было записано двумя разными условиями, и такой секрет
|
||
записывался бы в базу, не разрывая сессий;
|
||
- импорт: новый секрет при **прежнем** `authId` в файле и при совпадении по
|
||
**имени** ротирует идентичность; свой новый `authId` из файла не подменяется;
|
||
повторный импорт того же файла идентичность не трогает;
|
||
- `newPeerAuthID` выдаёт идентификатор той же формы, что и создание пира, и
|
||
проходит собственную проверку продукта (`peerAuthIDPattern`).
|
||
|
||
`apps/service/cron_test.go` дополнительно доказывает, что старое поколение
|
||
уходит в `/kick` именно как сессия без строки в базе.
|
||
|
||
## A10b. Правдивая диагностика (unit)
|
||
|
||
`apps/service/hysteria2_state_test.go` — матрица двух независимых источников:
|
||
|
||
| systemd | Traffic Stats API | что обязано быть показано |
|
||
| --- | --- | --- |
|
||
| `active` | отвечает | служба работает, API доступен, картина подключений реальная |
|
||
| `inactive` | молчит | оба факта согласованы |
|
||
| **`unknown`** | **отвечает** | «состояние неизвестно» + API доступен + картина подключений реальная |
|
||
| `active` | отказывает | служба работает, API недоступен, состояние данных — `error` |
|
||
|
||
Третья строка — главная регрессия: прежний путь показывал здесь «служба
|
||
остановлена» и «API доступен» одновременно, причём второе — не сходив в API.
|
||
|
||
Отдельно проверяется разбор ответа `systemctl is-active`: `active`, `inactive`,
|
||
`failed`, `activating`, `deactivating`, пробелы, многострочный вывод, пустой
|
||
ответ и **незнакомое слово** — последнее означает `unknown`, а не «остановлена».
|
||
|
||
`apps/service/peer_access_test.go` — `PagePeer` отвечает
|
||
`onlineState: unavailable` и не теряет список пиров, когда Traffic Stats API
|
||
недоступен; при доступном API признак `ok`, а `online`/`onlineDevices` в строках
|
||
отражают фактический ответ.
|
||
|
||
`apps/util/exec_probe_test.go` — `ExecProbe` отличает ненулевой код возврата
|
||
(ответ команды) от невозможности запустить процесс. Требует рабочего `bash`,
|
||
поэтому вне Linux пропускается.
|
||
|
||
## A10c. Журнал Hysteria в фактическом формате upstream (unit)
|
||
|
||
`apps/service/journal_test.go` — записи собираются так же, как их пишет zap с
|
||
`EncoderConfig` upstream:
|
||
|
||
- числовое `time` (epoch millis, **дробное** — `EpochMillisTimeEncoder` делит
|
||
наносекунды на миллисекунду) разбирается; прежний разбор падал на каждой такой
|
||
строке и показывал оператору сырой JSON;
|
||
- структурный контекст (`addr`, `id`, `error`, `listen`, `tx`, …) сохраняется и
|
||
дописывается к сообщению в устойчивом (алфавитном) порядке;
|
||
- числа печатаются без экспоненты, вложенные объекты — компактным JSON в одну
|
||
строку;
|
||
- секреты вырезаются и из сообщения, и из контекста, а адрес остаётся читаемым;
|
||
- не-JSON строка и JSON без `msg` не теряются;
|
||
- запись без собственных `level`/`time` добирает их из journald;
|
||
- `MESSAGE`, отданный journald **массивом байт** (сообщение не является
|
||
корректным UTF-8), больше не выбрасывает всю запись.
|
||
|
||
## A10d. Проекция конфига на production-профиль (unit)
|
||
|
||
`apps/service/hysteria2_profile_test.go`:
|
||
|
||
- канонический конфиг оркестратора читается целиком и расхождений не даёт;
|
||
- отсутствующая секция остаётся отсутствующей — в частности, `trafficStats` не
|
||
превращается в выдуманный `:9999`;
|
||
- явное `false` отличается от «не задано»;
|
||
- секции вне профиля перечисляются поимённо и по порядку, включая **неизвестные
|
||
HY2XS** — они считаются по сырому YAML, а не по типизированной модели;
|
||
- ответ, сериализованный **так, как его получит браузер**, не содержит ни
|
||
пароля обфускации, ни секрета Traffic Stats API, ни machine token, ни учётных
|
||
данных outbound; при этом диагностические факты сохранены («пароль задан»,
|
||
адрес auth-URL, имена параметров ACME DNS);
|
||
- отсутствующий файл конфига — отказ, а не пустой профиль.
|
||
|
||
`tools/test/frontend-contract.test.ts` закрепляет ту же границу со стороны
|
||
панели: имя ACME DNS-провайдера показывается как фактическое значение профиля
|
||
(`text(profile.acme.dnsProvider)`), тип поля — обычная строка, а собственного
|
||
списка провайдеров в панели нет. Проверка перечисляет их поимённо
|
||
(`cloudflare`, `duckdns`, …, `namedotcom`) и требует, чтобы **ни один** не
|
||
встречался в исходнике страницы: реестр в UI был бы вторым экземпляром
|
||
upstream-списка, который умеет от него отстать — ровно так `namedotcom`
|
||
пришлось выпиливать вручную после его удаления в Hysteria 2.11.0.
|
||
|
||
`apps/service/hysteria2_export_test.go` дополнен якорями YAML: секрет за
|
||
`&anchor`/`*alias` вырезается и по ссылке, и в самом объявлении; URL с учётными
|
||
данными за якорем — тоже; ссылка на составной узел редактируется целиком;
|
||
рекурсивная ссылка (alias на предка) не зацикливает санитайзер и не отказывает —
|
||
`yaml.v3` строит на ней действительно циклический граф узлов.
|
||
|
||
## A7. Контракт версий (build)
|
||
|
||
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
||
создания tarball при рассинхроне `versions.env` с `PACKAGE_VERSION`,
|
||
`packageManager` обоих `package.json`, схемой в `package/config/hy2xs.env`,
|
||
константами, скомпилированными в оркестратор, директивой `go` в `apps/go.mod`,
|
||
metadata пакета и версией, которую сообщает собранный `hy2xs-admin`.
|
||
|
||
Разбор upstream `hashes.txt` покрыт `orchestrator/test/hysteria-release.test.ts`:
|
||
реальный формат релиза, отсутствие путаницы `hysteria-linux-amd64` с
|
||
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
|
||
противоречивые записи, отсутствие нужной строки.
|
||
|
||
## A11. Проверка зависимостей на уязвимости (build)
|
||
|
||
`tools/build/lib/security.sh` — обязательный шаг между тестами админки и записью
|
||
metadata. Подробности в [docs/02](../build/02-build-layer-and-package.md); здесь важно
|
||
поведение при отказе:
|
||
|
||
| Код `govulncheck` | Трактовка |
|
||
| --- | --- |
|
||
| `0` | чисто |
|
||
| `3` | найдены **вызываемые** уязвимости → сборка падает |
|
||
| иное | отказ самого инструмента → сборка падает отдельным сообщением |
|
||
|
||
Последняя строка существенна: ненулевой код неизвестной природы нельзя
|
||
трактовать как «уязвимостей нет». По той же причине недоступность реестра npm
|
||
для `pnpm audit` — это отказ проверки, а не её отрицательный результат.
|
||
|
||
`pnpm audit` проверяет **весь** lock-граф frontend, а не production-подграф:
|
||
build tooling исполняется на build-машине и порождает production-бандл, поэтому
|
||
уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы `--prod` не
|
||
вернулся в гейт.
|
||
|
||
## A11a. Обязательные тесты (build)
|
||
|
||
Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет,
|
||
результат виден по готовому артефакту.
|
||
|
||
| Шаг сборки | Что запускается |
|
||
| --- | --- |
|
||
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
||
| `bundle_ui` | `pnpm run typecheck` до сборки bundle |
|
||
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||
|
||
Приёмка проверяет:
|
||
|
||
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
||
(место для истории — `CHANGELOG.md`);
|
||
- `metadata/package.env` содержит `tests_gate=true`;
|
||
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
|
||
- `write_metadata` отказывается писать метаданные, если хотя бы один из двух
|
||
прогонов не подтверждён.
|
||
|
||
## A12. Приёмка проверяет код, а не упоминания
|
||
|
||
Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на
|
||
корректном коде**, и оба — по одной причине: они искали подстроку там, где
|
||
подстрока обязана присутствовать.
|
||
|
||
| Проверка | Что ловила на самом деле |
|
||
| --- | --- |
|
||
| `verify_api_namespace_contract` | `grep -rlF '/hui'` возвращал `apps/router/router_test.go` (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам `versions.sh`, где эта строка стоит в тексте проверки |
|
||
| «peer import не выходит за транзакцию» | `source.slice(start)` брал файл от начала `applyPeerImportEntry` **и до конца**, захватывая `ExistPeerName` и `UpdatePeerLastConnectionAt` — обычные операции вне импорта, которым глобальное соединение положено |
|
||
|
||
Первая падала на шаге versions contract — шестым из четырнадцати, до резолва
|
||
Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.
|
||
|
||
Отсюда правило и помощники `code_without_comments` / `code_mentions_in` в
|
||
`acceptance.sh`: проверка смотрит на код, а комментарий, объясняющий, почему
|
||
чего-то больше нет, обязан называть это по имени и не должен ломать сборку.
|
||
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
||
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
||
и существование этого теста само проверяется контрактом.
|
||
|