Files
HY2XS_flamy/docs/testing/11-5-negative-and-matrix.md
founder 82e5ca40cc fix(build): гейт ACME описывал снятую архитектуру панели
Приёмка требовала, чтобы страница Hysteria содержала жёсткий список ACME
DNS-провайдеров (cloudflare … vultr) и не содержала удалённого upstream
namedotcom. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в
UI был вторым экземпляром upstream-реестра и мог от него отстать — ровно так
namedotcom и пришлось выпиливать вручную.

После перевода страницы в read-only диагностику реестра нет и быть не должно:
имя провайдера читается из фактического конфига и показывается как есть, поэтому
новый upstream-провайдер отображается без правок панели. Возврат списка ради
прохождения grep-а создал бы фиктивный реестр, существующий только для гейта.

Гейт проверяет действующий контракт: провайдер приходит строкой и рисуется как
значение, параметры DNS отдаются только именами, селектора на странице нет.
Проверено положительно и на трёх нарушениях (провайдер перестал показываться,
на странице появился селектор, тип стал перечислением) — гейт падает на каждом.

Контрактный тест панели дополнен обратной проверкой: ни одно из восьми имён
провайдеров не должно встречаться в исходнике страницы. Та же формулировка
поправлена в матрице приёмки.

Дополнительно прогнаны целиком все восемь функций приёмки, которым не нужен
распакованный пакет: других устаревших утверждений нет.
2026-09-03 23:07:19 +05:00

24 KiB
Raw Permalink Blame History

D, E. Негативные тесты и матрица приёмки

Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.

D. Negative tests

  1. не Debian 13
  2. порт уже занят
  3. старое конфликтующее состояние уже существует
  4. домен / SNI заданы некорректно
  5. bundled UI отсутствует в пакете
  6. Hysteria upstream недоступен
  7. firewall применился частично
  8. install flow прерван посередине
  9. попытка использовать HY2XS_IPV6_ENABLED=true
  10. HY2XS_PUBLIC_HOST=0.0.0.0
  11. неизвестный HY2XS_HYSTERIA_OBFS_TYPE
  12. конфигурация со схемой HY2XS_CONFIG_SCHEMA_VERSION из линейки 0.x
  13. upstream latest несовместим с шаблоном HY2XS — падает сборка, не установка
  14. HY2XS_PUBLIC_HOST резолвится не на этот сервер
  15. HY2XS_DOMAIN резолвится не на этот сервер при отличном от него PUBLIC_HOST
  16. A-запись содержит правильный адрес и чужой одновременно
  17. неизвестное значение HY2XS_PUBLIC_ENDPOINT_POLICY
  18. импорт пиров с невалидной записью — файл не применяется частично
  19. импорт пиров, пытающийся перезаписать bootstrap-admin-peer
  20. HY2XS_HYSTERIA_TRAFFIC_STATS_HOST не равен 127.0.0.1 — install/reconfigure отказывают: 0.0.0.0, другой адрес loopback, адрес LAN, публичный адрес
  21. trafficStats.listen в уже установленном /etc/hysteria/config.yaml указывает на не-loopback адрес — админка отказывает с сообщением, называющим адрес и способ починки, а не молча обращается к 127.0.0.1

E. Fix20 production matrix (обязательные сценарии)

  1. Clean Debian 13 minimal:

    • только SSH, без ручной установки зависимостей;
    • default /etc/nftables.conf stub;
    • install проходит полностью;
    • doctor/status показывают рабочее состояние.
  2. Non-systemd container:

    • fail-fast до destructive шагов;
    • диагностическое сообщение с причиной capability/systemd.
  3. Foreign nftables:

    • при HY2XS_FIREWALL_MODE=managed install/reconfigure блокируются;
    • при HY2XS_FIREWALL_MODE=takeover создаются backup/rollback guard и apply проходит.
  4. Rollback guard cleanup (сценарий D1g):

    • после успешного apply/smoke не остаются hy2xs-fw-rollback-*.timer/.service;
    • /run/hy2xs/rollback/, /run/lock/hy2xs-orchestrator.lock и *.candidate не переживают успешную операцию.

4a. Guard доходит до дедлайна (сценарий D1e):

  • auto-rollback-fired создан, операция завершается отказом с phase: firewall_guard_fired;
  • installed: true не записан, даже если smoke успел сойтись.

4b. Конкурентные операции (сценарий D1f):

  • вторая операция отказывает до снятия резервной копии и первой мутации;
  • status/diagnostics не блокируются и сообщают об идущей операции;
  • замок не переживает своего держателя.

4c. Аварийная смерть с вооружённым guard (сценарий D1h):

  • новая операция отказывает, пока hy2xs-fw-rollback-* ещё активен, в том числе когда замка не осталось вовсе;
  • после срабатывания guard repair проходит.
  1. Partial install + repair:

    • состояние install-state фиксирует промежуточную фазу;
    • repair завершает граф до installed=true.
  2. AAAA при IPv4-only:

    • policy строго валидируется preflight;
    • soft warning path не используется в production baseline.
  3. Slow-start admin readiness:

    • install не падает на race после restart;
    • readiness waiters дожидаются listener/healthz.
  4. Отказ между PHASE 0 и первой мутацией (сценарий D1):

    • install-state.json честно показывает failed;
    • установщик не заявляет, что хост не изменён.
  5. Устаревший DNS после смены IPv4 (сценарий D2):

    • doctor и reconfigure отказывают;
    • в выводе присутствуют оба адреса.

Acceptance criteria

Система принимается, если:

  1. production builder на Debian 13 amd64 выдаёт переносимый install package
  2. target server не выполняет build step
  3. Hysteria2 получена из official upstream
  4. HY2XS admin поставлен из install package
  5. post-install.env отражает фактическое deploy-состояние
  6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
  7. оркестратор не требует standalone update / rollback / uninstall subcommands
  8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
  9. Telegram/access layer не требуется для прохождения install acceptance
  10. отсутствует production path для port hopping
  11. UI не запускается от root
  12. клиентские endpoint не зависят от request Host/hostname
  13. production build verify падает, если config/hy2xs.env содержит placeholder-значения
  14. production build verify падает при dirty git tree (кроме ALLOW_DIRTY_BUILD=true)
  15. metadata содержит source_git_commit, dirty_tree, build_profile=production
  16. builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
  17. собранный пакет содержит точные версию, URL и SHA-256
  18. выход новой версии Hysteria после сборки не меняет содержимое старого пакета
  19. новая установка генерирует Gecko
  20. Gecko использует 512/1200
  21. установленная Hysteria реально принимает сгенерированный YAML
  22. сервис запускается под существующим непривилегированным пользователем hysteria
  23. созданный пользователь получает hysteria2:// с obfs=gecko и obfs-password
  24. совместимый клиент Hysteria подключается напрямую по этой ссылке
  25. после перезапуска Hysteria клиент быстро восстанавливает соединение
  26. режим HY2XS_HYSTERIA_OBFS_TYPE=salamander полностью работоспособен
  27. admin читает Gecko-конфиг без ошибок
  28. экспорт не уничтожает современные и неизвестные upstream-поля
  29. экспорт не содержит секретов
  30. frontend отображает Gecko
  31. панель показывает фактическое имя ACME DNS-провайдера из конфига и не содержит собственного списка провайдеров: страница Hysteria — read-only диагностика, выбирать провайдера она не предлагает, поэтому новый upstream-провайдер отображается без правок панели
  32. документация нигде не утверждает, что Salamander — фиксированный инвариант
  33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
  34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
  35. hy2xs-orchestrator doctor не перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера
  36. удаление bootstrap-admin-peer переживает systemctl restart и reboot: пир не воскресает
  37. отключённый bootstrap-admin-peer остаётся отключённым после перезапуска
  38. резервная копия с includeSecrets=true завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
  39. админка не генерирует HYSTERIA2_TRAFFIC_STATS_SECRET сама: пустой env при пустой базе — отказ старта
  40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации, и покрывает весь lock-граф frontend
  41. apps/go.mod объявляет toolchain, совпадающий с GO_VERSION из versions.env
  42. tools/dev/doctor.sh / doctor.ps1 показывают расхождение среды разработки с versions.env
  43. маршруты-алиасы /:id/client-url и /:id/qr удалены и не входят в публичный API v1
  44. pnpm run typecheck (vue-tsc --noEmit) проходит без ошибок и является обязательным шагом сборки
  45. проверка типов идёт до сборки bundle, а не после
  46. vue-tsc версии 3 и выше: 0.x проверку шаблонов не выполняет
  47. pnpm audit по всему графу зависимостей frontend не находит уязвимостей
  48. локальные SVG-иконки собираются спрайтом из репозитория, без vite-plugin-svg-icons
  49. каждая иконка задаёт систему координат: viewBox либо пара width/height
  50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
  51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
  52. install-state.json пишется одним писателем, атомарно и с fsync файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
  53. ownership-флаг маркера установки взводится до записи, поэтому отказ на chown не даёт fatal_pre_apply при уже созданном файле
  54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; metadata/package.env содержит tests_gate=true, и это утверждение опирается на фактический прогон
  55. reset-admin при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому password_hash
  56. данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью phase: installed
  57. резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
  58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
  59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
  60. doctor не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом install
  61. снятие rollback guard доказывается, а не объявляется: отсутствие маркера auto-rollback-fired и ActiveState=inactive обоих юнитов — предусловие записи phase: installed
  62. сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа firewall_guard_fired
  63. smoke сверяет эффективный firewall с конфигурацией операции, а не только разбирает /etc/nftables.conf
  64. автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
  65. откат восстанавливает enabled/active состояние nftables.service, а не только файлы правил
  66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а status/diagnostics не блокируются
  67. замок снимается при любом завершении держателя, включая Ctrl+C, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
  68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
  69. отказ записи маркера auto-rollback-fired не может привести к фиксации успеха: он переводит юнит guard в failed, а failed фиксацию запрещает
  70. восстановление UnitFileState у nftables.service не обещает точности, которой не даёт: восстанавливаются enabled/disabled, остальные состояния называются оператору и не трогаются
  71. отказ запроса к systemd не выдаётся за покой: барьер обязан доказать отсутствие исполнителей предыдущей операции, а при невозможности получить доказательство отказывает с GuardStateUnknownError, а не разрешает операцию
  72. покой guard перечисляется белым списком (inactive, failed): незнакомое состояние systemd блокирует операцию, а не проходит молча по принципу «его нет в списке опасных»
  73. отработавший таймер не блокирует операцию навсегда: RemainAfterElapse=no выгружает его, а барьер дополнительно опознаёт SubState=elapsed у *.timer как покой
  74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан AccuracySec=1s, иначе умолчание AccuracySec=1min превращало «45 секунд» в 45–105
  75. состояние guard читает один наблюдатель: status берёт его у того же кода, что и барьер, и сообщает unknown вместо тихого «guard'ов нет» при отказе systemd
  76. ни один релизный гейт не подаёт вывод в поиск с флагом -q через пайплайн: под set -o pipefail оборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой
  77. отрицательные сканы по дереву исходников формулируют синтаксическую форму, а не подстроку: вызов — имя со скобкой или обратной кавычкой, импорт — import со спецификатором, зависимость — ключ в package.json. Прозаическое упоминание удалённой вещи разрешено, иначе гейт запрещает документировать собственную работу

Почему отрицательный скан не ищет подстроку

Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по имени. Скан по голой подстроке такой комментарий не отличает от кода и падает ровно на документации к выполненной им же работе. В этом файле урок оплачен пять раз: скан versions contract ловил сам себя на /hui; dead-route скан падал на router_test.go, который перечисляет удалённые маршруты, чтобы доказать их отсутствие; скан иконок — на блочном комментарии о замене плагина; скан прежних имён раннеров — на слове systemd-run в прозе; скан cancelFirewallRollback — на комментарии о её разделении.

code_has отбрасывает строчные комментарии (//, #), но не блочные /* … */. Блок-парсер сознательно не заводится: наивный стриппер спотыкается о /* внутри строк и регулярных выражений и может вычистить настоящий код — а это ложный PASS, то есть лекарство хуже болезни. Для файлов с блочными комментариями формулируется синтаксическая форма либо утверждение опирается на более сильный гейт.

Пример последнего: литерального скана по virtual:svg-icons-register больше нет. Его роль исполняет production vite build, который проходит раньше: активный import "virtual:svg-icons-register" при отсутствующем плагине не разрешается резолвером, и сборка bundle падает. Проверяется исполняемый импорт, а не совпадение подстроки.

Почему пайплайн в grep -q запрещён

Поиск с флагом -q прекращает чтение на первом совпадении и закрывает свой конец канала. Продюсер, которому осталось что писать, получает SIGPIPE и завершается кодом 141, а set -o pipefail делает 141 статусом всей конструкции. Смысл инвертируется:

совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»

Для утвердительной проверки это ложный FAIL — гейт отвергает корректный артефакт. Для отрицательной («такой конструкции в коде нет») — ложный PASS: запрещённая конструкция найдена, а гейт зелёный.

Порог измерим и резкий. Пока весь вывод продюсера помещается в буфер канала — 64 KiB на Linux, — он записывает всё, не блокируясь, и успевает завершиться раньше, чем потребитель вообще начнёт читать. Замер на 60 прогонах каждого размера:

    4 KiB … 60 KiB   отказов 0
   64 KiB            отказов 58/60
   96 KiB и больше   отказов 60/60

Поэтому такая проверка годами выглядит исправной, а переворачивается на первом источнике крупнее буфера. В этом репозитории файлы такого размера уже есть (tools/build/lib/acceptance.sh — 123 KiB, orchestrator/src/steps/firewall.ts — 67 KiB).

Правильная форма — here-string, у которого пайплайна нет вовсе:

grep -q 'PATTERN' <<<"$content" || fail "..."

Для содержимого файла есть code_has FILE [флаги] -- PATTERN: он читает код без комментариев в переменную отдельным оператором и сравнивает через here-string. Отдельный оператор важен: в контексте ! code_has … bash отключает errexit на весь вызов, поэтому неудачное чтение проверяется явно, а не рассчитывает на set -e.