82e5ca40cc
Приёмка требовала, чтобы страница Hysteria содержала жёсткий список ACME DNS-провайдеров (cloudflare … vultr) и не содержала удалённого upstream namedotcom. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в UI был вторым экземпляром upstream-реестра и мог от него отстать — ровно так namedotcom и пришлось выпиливать вручную. После перевода страницы в read-only диагностику реестра нет и быть не должно: имя провайдера читается из фактического конфига и показывается как есть, поэтому новый upstream-провайдер отображается без правок панели. Возврат списка ради прохождения grep-а создал бы фиктивный реестр, существующий только для гейта. Гейт проверяет действующий контракт: провайдер приходит строкой и рисуется как значение, параметры DNS отдаются только именами, селектора на странице нет. Проверено положительно и на трёх нарушениях (провайдер перестал показываться, на странице появился селектор, тип стал перечислением) — гейт падает на каждом. Контрактный тест панели дополнен обратной проверкой: ни одно из восьми имён провайдеров не должно встречаться в исходнике страницы. Та же формулировка поправлена в матрице приёмки. Дополнительно прогнаны целиком все восемь функций приёмки, которым не нужен распакованный пакет: других устаревших утверждений нет.
236 lines
24 KiB
Markdown
236 lines
24 KiB
Markdown
# D, E. Негативные тесты и матрица приёмки
|
||
|
||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](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` проходит.
|
||
|
||
5. **Partial install + repair**:
|
||
- состояние `install-state` фиксирует промежуточную фазу;
|
||
- `repair` завершает граф до `installed=true`.
|
||
|
||
6. **AAAA при IPv4-only**:
|
||
- policy строго валидируется preflight;
|
||
- soft warning path не используется в production baseline.
|
||
|
||
7. **Slow-start admin readiness**:
|
||
- install не падает на race после restart;
|
||
- readiness waiters дожидаются listener/healthz.
|
||
|
||
8. **Отказ между PHASE 0 и первой мутацией** (сценарий D1):
|
||
- `install-state.json` честно показывает `failed`;
|
||
- установщик не заявляет, что хост не изменён.
|
||
|
||
9. **Устаревший 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 статусом всей
|
||
конструкции. Смысл инвертируется:
|
||
|
||
```text
|
||
совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»
|
||
```
|
||
|
||
Для утвердительной проверки это ложный FAIL — гейт отвергает корректный
|
||
артефакт. Для отрицательной («такой конструкции в коде нет») — **ложный PASS**:
|
||
запрещённая конструкция найдена, а гейт зелёный.
|
||
|
||
Порог измерим и резкий. Пока весь вывод продюсера помещается в буфер канала —
|
||
64 KiB на Linux, — он записывает всё, не блокируясь, и успевает завершиться
|
||
раньше, чем потребитель вообще начнёт читать. Замер на 60 прогонах каждого
|
||
размера:
|
||
|
||
```text
|
||
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, у которого пайплайна нет вовсе:
|
||
|
||
```bash
|
||
grep -q 'PATTERN' <<<"$content" || fail "..."
|
||
```
|
||
|
||
Для содержимого файла есть `code_has FILE [флаги] -- PATTERN`: он читает код без
|
||
комментариев в переменную **отдельным оператором** и сравнивает через
|
||
here-string. Отдельный оператор важен: в контексте `! code_has …` bash отключает
|
||
`errexit` на весь вызов, поэтому неудачное чтение проверяется явно, а не
|
||
рассчитывает на `set -e`.
|