# 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-конфига попал литерал вместо значения из конфигурации». Рендер конфига (`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/` из 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 строка в поле хеша отклоняется. `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:`, верхний регистр, противоречивые записи, отсутствие нужной строки. ## 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` на таблице маршрутов собранного роутера — и существование этого теста само проверяется контрактом.