Files
HY2XS_flamy/docs/testing/11-2-builder-layer.md
T

76 KiB
Raw Blame History

A. Тесты слоя сборки

Часть набора проверок HY2XS. Карта всех частей — docs/testing/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 на сборке»:

Сегодня:  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\nbn не входит в SHELL_NEED_ESCAPE, слеш сохраняется
KEY="$HOME" $HOME — подстановок в env-файле нет
KEY='a\b' a\b — в одинарных кавычках escape нет вовсе
строка без = отказ (намеренное расхождение: systemd её отбрасывает молча)
незакрытая кавычка на EOF отказ (второе намеренное расхождение: systemd принял бы накопленное)

И обратимость: любое значение — с краевыми пробелами, кавычками, обратными слешами, $, `, #, эмодзи — переживает render -> parse побайтово, а обычные значения (8080, /etc/hysteria/server.crt, 50 mbps) остаются без кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.

Отдельно проверяется домен значений — чужое множество, а не наша политика:

Вход Ожидание
NUL, U+FEFF, U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF отказ ЗАПИСИ: публичный контракт EnvironmentFile запрещает такое значение
одиночный суррогат U+D800 отказ — иначе TextEncoder молча заменил бы его на U+FFFD, то есть подменил бы секрет
U+FDCF, U+FDF0, U+FFFD, U+10FFFD, U+1F600 принимаются: правило описывает диапазон, а не окрестность
\n, \r, \t, U+007F, U+0085 формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт

A4b. Непригодная конфигурация отвергается до первой мутации

Там же. validateRuntimeEnvTransport вызывается из parseRuntimeEnv, поэтому preflight-install и install видят отказ одинаково — до bootstrap оркестратора, apt и раскладки файловой системы. Проверяется:

  • parseRuntimeEnv отвергает значение вне домена systemd;
  • проверяется КАЖДОЕ значение файла, а не только пароль администратора (HY2XS_ADMIN_CON_PASS, HY2XS_HYSTERIA_BANDWIDTH_UP, HY2XS_ACME_EMAIL);
  • запись и проверка ходят по одному списку runtimeEnvEntries;
  • всё, что parseRuntimeEnv принял, записывается без отказа.

Рендер конфига (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 → долговечная запись installedcleanup;
  • успешный 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 копировался как есть.

orchestrator/test/diagnostics-storage.test.ts отдельно фиксирует границу привилегий: production path не входит в HY2XS_LOG_DIR, symlink и чужой владелец отвергаются, режим root-каталога равен 0700, рабочие каталоги уникальны, а archive path резервируется эксклюзивно до запуска tar.

orchestrator/test/package-meta-utf8.test.ts проверяет соседнюю fail-closed границу metadata: fallback разрешён только для отсутствующего файла; каталог вместо файла и повреждённый UTF-8 пробрасываются как ошибка пакета.

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 отвергнут

    последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;

  • пробел по краям — часть пароля, шесть пробелов являются корректным паролем;

  • управляющие символы Unicode целиком, то есть Cc: \n, \r, \t, NUL, DEL и C1 (U+0085, U+009F). Раньше проверялись только C0 и DEL, а документация обещала «без управляющих символов» — то есть была шире кода;

  • значения вне документированного домена systemd (U+FEFF, U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8) отвергаются: с ними /etc/hy2xs/hy2xs.env не загрузится и юнит не стартует;

  • соседи запрещённых диапазонов (U+FDCF, U+FDF0, U+FFFD) принимаются: правило описывает множество systemd, а не окрестность подозрительных значений;

  • U+FEFF отвергается транспортным доменом: публичная документация systemd запрещает его, хотя реализация v257.13 случайно пропускает из-за маски 0xFEFF & 0xFFFE == 0xFEFE. Тест TestEnvTransportDomainMatchesDocumentedSystemdContract закрепляет публичный контракт, а TestProductPolicyIsWiderThanTransportDomain — что политика и домен остаются различимы.

orchestrator/test/strict-text-read.test.ts подаёт reader'у реальные байтовые последовательности 0xFF, оборванную 0xC3 и ED A0 80. Ни одна из них не превращается в U+FFFD; начальный BOM сохраняется как U+FEFF и доходит до транспортного отказа.

apps/controller/json_body_test.go и HTTP-тест входа доказывают то же на API: повреждённый UTF-8 и непарные \uD800/\uDC00 отвергаются до стандартного Go-декодера, а настоящий U+FFFD остаётся допустимым значением.

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.goPagePeer отвечает onlineState: unavailable и не теряет список пиров, когда Traffic Stats API недоступен; при доступном API признак ok, а online/onlineDevices в строках отражают фактический ответ.

apps/util/exec_probe_test.goExecProbe отличает ненулевой код возврата (ответ команды) от невозможности запустить процесс. Требует рабочего 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; здесь важно поведение при отказе:

Код 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
run_frontend_tests dependency-free контракты панели
bundle_ui после frozen install: bun test test/i18n-runtime.test.ts, затем pnpm run typecheck и bundle
run_admin_tests go vet ./..., go test ./...

Приёмка проверяет:

  • отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs (место для истории — CHANGELOG.md);
  • metadata/package.env содержит tests_gate=true;
  • общий frontend-флаг выставляется только после ранних контрактов и runtime-компиляции всех сообщений RU/EN;
  • runtime-gate стоит между frozen install и typecheck/build, использует vue-i18n из lock-графа и считает ошибкой compiler diagnostics;
  • утверждение о прогоне выставляется после самого прогона, а не до него;
  • 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 на таблице маршрутов собранного роутера — и существование этого теста само проверяется контрактом.