fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+153 -4
View File
@@ -46,6 +46,155 @@ Hardening-проход перед релизом `1.0.0`. Основная те
результат на достаточно большом входе, опаснее отсутствующей: отсутствующая результат на достаточно большом входе, опаснее отсутствующей: отсутствующая
ничего не обещает. ничего не обещает.
Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на
живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не
выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и
сервер действительно умел его сгенерировать — до этой генерации не доходило
управление. Контракт `currentColor` был объявлен в двух местах — и не
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
войдите заново» существовала — и была недостижима сразу по двум причинам.
### Исправлено — панель оператора
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
«оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil: помощник `hasValue` считает
указатель на пустую строку «значением». Правило `min=6` применялось к пустой
строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой.
Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и
проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница
проходит по каждому полю отдельно — у `remark` пустая строка означает
«убрать пометку», у `disabled` ноль означает «включён», и общее правило
«пусто → не задано» молча сломало бы оба.
Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки
ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в
один клик по крестику.
- **Генерация секрета названа явным шагом сервисного слоя.**
`service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с
отбрасыванием смещённых байтов) используется и формой, и импортом: пир,
созданный панелью, и пир, импортированный без секрета, теперь неотличимы.
- **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при
этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами —
тот же антипаттерн, который запрещён панели, только на сервере. Ответ об
ошибке теперь несёт `errors: [{code, field, message, params}]`; панель
выбирает локализованную фразу по коду и подставляет причины под поля формы.
Границы числа и границы длины строки различаются кодом, хотя тег валидатора
у них один: оператору это разные фразы.
- **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой
отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе —
а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и
поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал
с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения
ошибок вместо свежих строк, middleware различает истечение и
недействительность через `errors.Is`, а панель показывает диалог и
возвращает на форму входа — один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
- **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`,
не проверив `error.response`, и при обрыве соединения подменял настоящую
причину `TypeError` внутри себя.
- **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал
выбранный оператором язык панели.
- **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал
`IdDto` с правилом `required`, хотя значение из тела всё равно затирается
значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`,
читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик
читает следом второй раз, а gin его не буферизует.
### Исправлено — отрисовка иконок
- **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor`
стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из
семнадцати ассетов несли литеральный `fill="#000000"` прямо на `<path>`, а
атрибут представления перебивает унаследованное CSS-свойство. Под это
попадали все семь иконок бокового меню на фоне `#181818`.
Литеральный цвет убран из ассетов; многоцветные объявлены явным списком;
преобразование в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит,
проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация
скрывала бы ровно тот дефект, который контракт обязан делать видимым.
- **У `SvgIcon` убран проп цвета** и атрибут `fill` на `<use>`: он приглашал
чинить отрисовку точечно в обход общего контракта.
### Исправлено — правила имени пира
- **Два правила на одном поле противоречили друг другу.** Стояли
`min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх
символов проходило одно правило и отказывалось на другом. Длина перенесена
внутрь одного правила.
- **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила
несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её
комментарий при этом утверждал, что набор тот же, что у импорта. Через панель
проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что
имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно,
но повторяет прежнее фактическое множество. Имя администратора приходит из
`HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила
означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести
порядок» роняла сборку, а не вход на живом сервере.
### Добавлено — атрибуция и контрактные тесты панели
- **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным
цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API,
ни из таблицы `config`. Высота области прокрутки меню вычитает высоту
подписи, поэтому пункты меню не могут на неё наехать.
- **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали
обязательным шагом сборки наравне с тестами оркестратора и админки: контракт
спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов
ошибок серверным константам, единственность адреса атрибуции.
Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не
вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему
lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
дополнительной гарантии.
### Изменено — документация
- **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов:
`architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`,
`acceptance/`. Двузначный префикс сохранён как стабильный идентификатор
документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения
оркестратора.
- **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей**
по слоям, на которых выполняются проверки.
- **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки,
отдельно от описания самих проверок. Документ проверок переживает релизы;
отчёт о прогоне относится к одному артефакту и одному хосту и после
публикации не редактируется. Первый отчёт — build/host acceptance
`v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия.
- **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не
проверяются ни типами, ни сборкой bundle.
- **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф
достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без
swap он был убит OOM killer.
- **Обход документации в приёмке стал рекурсивным.** Плоский шаблон
`docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом,
то есть проверка отчитывалась бы зелёным, не заглянув почти никуда.
### Исправлено — гейты сборки ### Исправлено — гейты сборки
- **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.** - **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.**
@@ -1069,7 +1218,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути
установки и данных берутся из конфигурации, а не захардкожены. установки и данных берутся из конфигурации, а не захардкожены.
- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — - **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) —
явная очистка сервера от предыдущего поколения. По умолчанию скрипт явная очистка сервера от предыдущего поколения. По умолчанию скрипт
показывает план и ничего не делает; выполнение требует показывает план и ничего не делает; выполнение требует
`--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло `--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло
@@ -1143,7 +1292,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема — - **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не `apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого
артефакта, который очистка должна найти. артефакта, который очистка должна найти.
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.** - **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
@@ -1210,7 +1359,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
их примениться повторно. их примениться повторно.
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
v1-доков этот словарь убран. v1-доков этот словарь убран.
@@ -1326,7 +1475,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
1. Выпишите с работающего сервера список пиров и их секреты. 1. Выпишите с работающего сервера список пиров и их секреты.
2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из 2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md). [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
3. Разверните `1.0.0` на чистом Debian 13 из release-пакета. 3. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
4. Заведите пиров заново и раздайте новые клиентские ссылки. 4. Заведите пиров заново и раздайте новые клиентские ссылки.
+25 -13
View File
@@ -515,7 +515,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в
том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх
предыдущего поколения и не мигрирует его состояние: очистка старой установки — предыдущего поколения и не мигрирует его состояние: очистка старой установки —
отдельная явная операция, см. [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md). отдельная явная операция, см. [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
### 10. Получите bootstrap‑пароль админки ### 10. Получите bootstrap‑пароль админки
@@ -818,7 +818,7 @@ HY2XS v1 не поддерживает установку поверх и не
Что делать: Что делать:
1. сохраните нужные данные (база пиров, конфиг) — см. 1. сохраните нужные данные (база пиров, конфиг) — см.
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md); [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md);
2. посмотрите план очистки: `sudo ./purge-v0.sh`; 2. посмотрите план очистки: `sudo ./purge-v0.sh`;
3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`; 3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`;
4. повторите установку. 4. повторите установку.
@@ -997,22 +997,25 @@ export GITHUB_TOKEN=<token>
1. проверяет контракт `versions.env` (`verify_versions_contract`); 1. проверяет контракт `versions.env` (`verify_versions_contract`);
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`); 2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
3. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт; 3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; 4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; 5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
6. прогоняет `go vet` и `go test` для HY2XS admin; 6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); 7. прогоняет `go vet` и `go test` для HY2XS admin;
8. формирует архив и прогоняет acceptance‑проверки. 8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
9. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–7 останавливает сборку до создания пакета. Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
утверждение опирается на фактический прогон, а не на намерение. утверждение опирается на фактический прогон, а не на намерение.
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`, Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают. `go vet ./...`, `go test ./...` и
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
запускаются напрямую и tarball не создают.
Переменные, управляющие выбором версии Hysteria: Переменные, управляющие выбором версии Hysteria:
@@ -1087,9 +1090,16 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
├── package/ # skeleton будущего install package ├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline ├── tools/build/ # production builder и packaging pipeline
├── tools/dev/ # doctor: сверка среды разработки с versions.env ├── tools/dev/ # doctor: сверка среды разработки с versions.env
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria ├── tools/test/ # e2e с реальным клиентом Hysteria и контракты панели
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения ├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
├── docs/ # спецификации baseline, тестов и эксплуатации ├── docs/ # документация, разложенная по слоям
│ ├── architecture/ # baseline-модель и рамки
│ ├── build/ # builder layer и состав пакета
│ ├── runtime/ # оркестратор, systemd, post-install
│ ├── admin/ # HY2XS admin и контракты панели
│ ├── operations/ # runbook, разбор отказов, очистка 0.x
│ ├── testing/ # набор проверок по слоям
│ └── acceptance/ # отчёты о фактических прогонах приёмки
├── versions.env # контракт продукта, платформы и toolchain ├── versions.env # контракт продукта, платформы и toolchain
├── CHANGELOG.md ├── CHANGELOG.md
├── README.md ├── README.md
@@ -1098,6 +1108,8 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
Каталог `dist/` создаётся builder’ом и не должен храниться в git. Каталог `dist/` создаётся builder’ом и не должен храниться в git.
Точка входа в документацию — [docs/README.md](docs/README.md).
## Для кого этот проект ## Для кого этот проект
HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу. HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу.
+2
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
) )
@@ -29,6 +30,7 @@ type apiResult struct {
Code int `json:"code"` Code int `json:"code"`
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Errors []vo.FieldError `json:"errors"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// failService переводит отказ сервисного слоя в ответ панели.
//
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
// при этом не придётся ни в одном известном случае.
func failService(err error, c *gin.Context) {
var peerErr *service.PeerError
if errors.As(err, &peerErr) {
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
return
}
vo.Fail(err.Error(), c)
}
+44 -14
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@@ -18,18 +19,31 @@ import (
"hy2xs-admin/service" "hy2xs-admin/service"
) )
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
//
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
// вызывала validateField, который читает тело запроса, а обработчик следом
// читает то же тело второй раз — gin его не буферизует, и второй разбор
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
// когда понадобился бы.
func resolveID(c *gin.Context) (int64, error) { func resolveID(c *gin.Context) (int64, error) {
if raw := strings.TrimSpace(c.Param("id")); raw != "" { raw := strings.TrimSpace(c.Param("id"))
parsed, err := strconv.ParseInt(raw, 10, 64) parsed, err := strconv.ParseInt(raw, 10, 64)
if err == nil && parsed > 0 { if err != nil || parsed <= 0 {
return parsed, nil vo.FailValidation(
} "идентификатор пира в адресе некорректен",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Field: "id",
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
}},
c,
)
return 0, errors.New(constant.ErrCodeBodyInvalid)
} }
idDto, err := validateField(c, dto.IdDto{}) return parsed, nil
if err != nil {
return 0, err
}
return *idDto.Id, nil
} }
func Login(c *gin.Context) { func Login(c *gin.Context) {
@@ -39,6 +53,14 @@ func Login(c *gin.Context) {
} }
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass) token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil { if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
@@ -65,7 +87,7 @@ func SavePeer(c *gin.Context) {
} }
peerVo, err := service.CreatePeer(peerSaveDto) peerVo, err := service.CreatePeer(peerSaveDto)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(peerVo, c) vo.Success(peerVo, c)
@@ -100,12 +122,12 @@ func UpdatePeer(c *gin.Context) {
return return
} }
if taken { if taken {
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
return return
} }
} }
if err = service.UpdatePeer(id, peerUpdateDto); err != nil { if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
@@ -158,7 +180,15 @@ func ImportPeer(c *gin.Context) {
return return
} }
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") { if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.Fail(constant.InvalidError, c) vo.FailValidation(
"импорт принимает только файлы .json",
[]vo.FieldError{{
Code: constant.ErrCodeImportFileExtension,
Field: "file",
Message: "импорт принимает только файлы .json",
}},
c,
)
return return
} }
+505
View File
@@ -0,0 +1,505 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/service"
)
// Контракт формы пира: необязательный секрет и внятный отказ.
//
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
// слоёв и ни один из них по отдельности его не показывал: панель обещала
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
func newPeerControllerDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
// peerPayload — тело создания пира со всеми обязательными полями.
// Тесты меняют в нём ровно то, что проверяют.
func peerPayload(name string) map[string]any {
return map[string]any{
"name": name,
"quotaBytes": -1,
"expiresAt": 0,
"maxDevices": 3,
"disabled": 0,
"remark": "",
}
}
func createPeer(t *testing.T, body map[string]any) apiResult {
t.Helper()
return postJSON(t, SavePeer, "/peers", body)
}
// errorFor возвращает причину отказа по имени поля.
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
t.Helper()
for _, item := range result.Errors {
if item.Field == field {
return item.Code, true
}
}
return "", false
}
func storedPeer(t *testing.T, name string) entity.Peer {
t.Helper()
peer, err := dao.GetPeer("name = ?", name)
if err != nil {
t.Fatalf("пир %q не найден в базе: %v", name, err)
}
return peer
}
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
// генерация в CreatePeer была недостижима.
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
cases := map[string]func(map[string]any){
"поле отсутствует": func(body map[string]any) {},
"пустая строка": func(body map[string]any) { body["secret"] = "" },
"только пробелы": func(body map[string]any) { body["secret"] = " " },
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
}
for label, mutate := range cases {
t.Run(label, func(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
mutate(body)
result := createPeer(t, body)
if result.Type != "ok" {
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
peer := storedPeer(t, "client-01")
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
t.Fatal("секрет не сохранён")
}
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
}
if len(secret) < 6 {
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
}
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
// записан, ничего не значит, пока по нему не проходит проверка
// доступа. Это же связывает digest и шифртекст между собой.
id, authID, authErr := service.Hysteria2Auth(secret)
if authErr != nil {
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
}
if id != *peer.Id || authID != *peer.AuthId {
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
}
})
}
}
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
// обязан быть случайным, а не производной от имени.
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
newPeerControllerDB(t)
secrets := make(map[string]struct{}, 5)
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
t.Fatalf("создание %q отклонено: %+v", name, result)
}
peer := storedPeer(t, name)
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
}
if _, seen := secrets[secret]; seen {
t.Fatalf("сгенерированный секрет повторился: %q", secret)
}
secrets[secret] = struct{}{}
}
}
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
cases := []struct {
label string
secret string
accepted bool
expectCode string
}{
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
{"6 символов", strings.Repeat("a", 6), true, ""},
{"128 символов", strings.Repeat("a", 128), true, ""},
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["secret"] = tc.secret
result := createPeer(t, body)
if tc.accepted {
if result.Type != "ok" {
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
}
peer := storedPeer(t, "client-01")
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет не расшифровывается: %v", err)
}
if stored != tc.secret {
t.Fatalf("сохранён не тот секрет, который передали")
}
return
}
if result.Type != "no" {
t.Fatalf("секрет длиной %d принят", len(tc.secret))
}
code, ok := errorFor(t, result, "secret")
if !ok {
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
}
if code != tc.expectCode {
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
}
})
}
}
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
// вынуждена была бы разбирать текст, чтобы попытаться.
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
cases := []struct {
label string
body func() map[string]any
field string
code string
}{
{
label: "имя не передано",
body: func() map[string]any {
body := peerPayload("client-01")
delete(body, "name")
return body
},
field: "name",
code: constant.ErrCodeRequired,
},
{
label: "имя короче допустимого",
body: func() map[string]any { return peerPayload("pc1") },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "имя длиннее допустимого",
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "лимит устройств меньше единицы",
body: func() map[string]any {
body := peerPayload("client-01")
body["maxDevices"] = 0
return body
},
field: "maxDevices",
code: constant.ErrCodeMin,
},
{
label: "disabled вне множества значений",
body: func() map[string]any {
body := peerPayload("client-01")
body["disabled"] = 7
return body
},
field: "disabled",
code: constant.ErrCodeOneOf,
},
{
label: "квота меньше минимума",
body: func() map[string]any {
body := peerPayload("client-01")
body["quotaBytes"] = -2
return body
},
field: "quotaBytes",
code: constant.ErrCodeMin,
},
{
label: "комментарий длиннее допустимого",
body: func() map[string]any {
body := peerPayload("client-01")
body["remark"] = strings.Repeat("я", 65)
return body
},
field: "remark",
code: constant.ErrCodeMaxLength,
},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, tc.body())
if result.Type != "no" {
t.Fatalf("некорректный ввод принят: %+v", result)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
}
code, ok := errorFor(t, result, tc.field)
if !ok {
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
}
if code != tc.code {
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
}
// Сообщение остаётся человекочитаемым для клиента без панели, но
// панель им не пользуется: у неё есть код.
if strings.TrimSpace(result.Message) == "" {
t.Fatal("отказ без человекочитаемого сообщения")
}
})
}
}
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
for _, name := range []string{
"peer/name",
"peer:name",
"peer;name",
"peer,name",
"peer.name",
"peer<name",
"peer name",
"пир-01",
} {
t.Run(name, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(name))
if result.Type != "no" {
t.Fatalf("имя %q принято", name)
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
}
// Обе двери в таблицу пиров обязаны требовать одного и того же.
if service.IsValidPeerName(name) {
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
}
})
}
}
func TestCreatePeerReportsTakenName(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("первое создание отклонено: %+v", result)
}
result := createPeer(t, peerPayload("client-01"))
if result.Type != "no" {
t.Fatal("повторное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
}
}
func TestCreatePeerReportsReservedName(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
if result.Type != "no" {
t.Fatal("зарезервированное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
}
}
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
// Панели важно различать: в первом случае подсвечивать нечего.
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.POST("/peers", SavePeer)
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "no" {
t.Fatal("неразбираемое тело принято")
}
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
}
if result.Errors[0].Field != "" {
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
}
}
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
engine := gin.New()
engine.PATCH("/peers/:id", UpdatePeer)
target := "/peers/" + strconv.FormatInt(id, 10)
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return result
}
// При изменении пустой секрет означает «не менять», и это то же самое
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
// когда оператор открыл форму и не трогал поле секрета.
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
before := storedPeer(t, "client-01")
for _, blank := range []string{"", " "} {
result := patchPeer(t, *before.Id, map[string]any{
"name": "client-01",
"secret": blank,
"remark": "рабочее устройство",
})
if result.Type != "ok" {
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
}
after := storedPeer(t, "client-01")
if *after.SecretDigest != *before.SecretDigest {
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
}
if after.Remark == nil || *after.Remark != "рабочее устройство" {
t.Fatal("остальные поля формы не применились")
}
}
}
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
// которой нормализация проходит для каждого поля отдельно.
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["remark"] = "временная пометка"
if result := createPeer(t, body); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
peer := storedPeer(t, "client-01")
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
t.Fatalf("очистка комментария отклонена: %+v", result)
}
after := storedPeer(t, "client-01")
if after.Remark != nil && *after.Remark != "" {
t.Fatalf("комментарий не очищен: %q", *after.Remark)
}
}
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
// «invalid» — то есть список пиров ломался в один клик по крестику.
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/peers", PagePeer)
query := url.Values{}
query.Set("pageNum", "1")
query.Set("pageSize", "10")
query.Set("name", "")
query.Set("remark", "")
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "ok" {
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
}
+183 -17
View File
@@ -1,47 +1,213 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"net/http" "hy2xs-admin/service"
"regexp"
) )
var validate *validator.Validate var validate *validator.Validate
func init() { func init() {
validate = validator.New() validate = validator.New()
_ = validate.RegisterValidation("validateStr", validateStr)
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "" || name == "-" {
return field.Name
}
return name
})
mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr)
} }
func validateStr(f validator.FieldLevel) bool { func mustRegister(tag string, fn validator.Func) {
if err := validate.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
}
}
// validatePeerName — единственное правило имени пира.
//
// Набор символов и длина берутся из service: имя пира проверяется на двух
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
// независимые копии правила уже расходились. Копия в слое контроллеров
// выглядела так:
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// credentialStrPattern — набор символов логина и пароля администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String() field := f.Field().String()
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов return field == "" || credentialStrPattern.MatchString(field)
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
} }
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
// правила.
//
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
// нарушение любого правила любого поля превращались в одну строку `invalid`:
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
// же и предлагала сделать.
func validateField[T interface{}](c *gin.Context, field T) (T, error) { func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error var bindErr error
if c.Request.Method == http.MethodGet { switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field) bindErr = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost || case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
c.Request.Method == http.MethodPut ||
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
bindErr = c.ShouldBindJSON(&field) bindErr = c.ShouldBindJSON(&field)
} }
if bindErr != nil { if bindErr != nil {
vo.Fail(constant.InvalidError, c) vo.FailValidation(
return field, fmt.Errorf(constant.InvalidError) "запрос не разобран: проверьте формат и типы полей",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Message: bindErr.Error(),
}},
c,
)
return field, errors.New(constant.ErrCodeBodyInvalid)
} }
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
// состояниями для валидатора и одинаковыми для человека.
if normalizable, ok := any(&field).(dto.Normalizable); ok {
normalizable.Normalize()
}
if err := validate.Struct(&field); err != nil { if err := validate.Struct(&field); err != nil {
vo.Fail(constant.InvalidError, c) vo.FailValidation(
return field, fmt.Errorf(constant.InvalidError) "проверка данных не пройдена",
describeValidationErrors(err),
c,
)
return field, errors.New(constant.ErrCodeValidationFailed)
} }
return field, nil return field, nil
} }
// describeValidationErrors переводит отказ валидатора в список причин.
func describeValidationErrors(err error) []vo.FieldError {
var validationErrors validator.ValidationErrors
if !errors.As(err, &validationErrors) {
// InvalidValidationError означает ошибку программиста (в проверку
// передали не структуру), а не плохой вход оператора. Скрывать её за
// сообщением о поле нельзя: она никогда не чинится правкой формы.
return []vo.FieldError{{
Code: constant.ErrCodeValidationFailed,
Message: err.Error(),
}}
}
out := make([]vo.FieldError, 0, len(validationErrors))
for _, fieldErr := range validationErrors {
out = append(out, describeFieldError(fieldErr))
}
return out
}
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
// величину. Указатели валидатор к этому моменту уже разыменовал.
func isTextField(fieldErr validator.FieldError) bool {
return fieldErr.Kind() == reflect.String
}
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
field := fieldErr.Field()
param := fieldErr.Param()
described := vo.FieldError{Field: field}
switch fieldErr.Tag() {
case "required":
described.Code = constant.ErrCodeRequired
described.Message = fmt.Sprintf("поле %q обязательно", field)
case "min":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMinLength
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
break
}
described.Code = constant.ErrCodeMin
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
case "max":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMaxLength
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
break
}
described.Code = constant.ErrCodeMax
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
case "len":
described.Code = constant.ErrCodeLen
described.Params = map[string]string{"len": param}
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
case "oneof":
described.Code = constant.ErrCodeOneOf
described.Params = map[string]string{"values": param}
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
case "gt":
described.Code = constant.ErrCodeGreaterThan
described.Params = map[string]string{"gt": param}
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
case "peerName":
described.Code = constant.ErrCodePeerName
described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
"charset": service.PeerNameCharset,
}
described.Message = fmt.Sprintf(
"имя пира: от %d до %d символов из набора %s",
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}
+78
View File
@@ -0,0 +1,78 @@
package controller
import (
"strings"
"testing"
"hy2xs-admin/service"
)
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
// набор символов не ограничивает, и сужение правила означало бы, что установка
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestCredentialCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !credentialStrPattern.MatchString(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if credentialStrPattern.MatchString(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
}
}
}
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
// одну таблицу и не имеют права требовать разного.
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
accepted := []string{
"client-01",
"alpha1",
"bootstrap-admin-peer",
strings.Repeat("a", 6),
strings.Repeat("a", 32),
}
for _, name := range accepted {
if !service.IsValidPeerName(name) {
t.Errorf("имя %q отклонено, ожидался приём", name)
}
}
rejected := []string{
"",
" ",
"pc1",
strings.Repeat("a", 33),
"peer name",
"peer\nname",
"peer/name",
"peer:name",
"peer.name",
"пир-01",
}
for _, name := range rejected {
if service.IsValidPeerName(name) {
t.Errorf("имя %q принято, ожидался отказ", name)
}
}
}
+4
View File
@@ -16,11 +16,14 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
}); });
} }
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
// ей не нужен: он повторял бы то же самое вторым сигналом.
export function savePeerApi(data: PeerSaveDto): AxiosPromise { export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({ return request({
url: "/peers", url: "/peers",
method: "post", method: "post",
data, data,
skipErrorToast: true,
}); });
} }
@@ -44,6 +47,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
url: `/peers/${data.id}`, url: `/peers/${data.id}`,
method: "patch", method: "patch",
data, data,
skipErrorToast: true,
}); });
} }
+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 783 B

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 445 B

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg> <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+18 -9
View File
@@ -1,33 +1,42 @@
<template> <template>
<svg <svg
aria-hidden="true" aria-hidden="true"
focusable="false"
class="svg-icon" class="svg-icon"
:style="'width:' + size + ';height:' + size" :style="'width:' + size + ';height:' + size"
> >
<use :xlink:href="symbolId" :fill="color" /> <use :xlink:href="symbolId" />
</svg> </svg>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SYMBOL_PREFIX } from "./symbol";
/**
* Цвет иконке не передаётся — и это контракт, а не упущение.
*
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
* пользовался ни разу, а существование такого пропа приглашает чинить
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
* иконка обязана получать цвет ровно одним способом — наследованием
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
*
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
* объявлен рядом с кодом, который этот id создаёт.
*/
const props = defineProps({ const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: { iconClass: {
type: String, type: String,
required: false, required: false,
}, },
color: {
type: String,
},
size: { size: {
type: String, type: String,
default: "1em", default: "1em",
}, },
}); });
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`); const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
</script> </script>
<style scoped> <style scoped>
+6 -63
View File
@@ -20,9 +20,14 @@
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных * Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в * иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке. * сборке.
*
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
* релизным гейтом, а не только глазами на живой странице.
*/ */
const SYMBOL_PREFIX = "icon"; import { iconName, toSymbol } from "./symbol";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__"; const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых // eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
@@ -33,68 +38,6 @@ const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
eager: true, eager: true,
}); });
function iconName(filePath: string): string {
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
* тоже отбрасываются — размер задаёт компонент.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый плагин.
*/
function toSymbol(raw: string, name: string): string {
const withoutProlog = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/** /**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет * Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же id. * содержимое, а не добавляет второй элемент с теми же id.
@@ -0,0 +1,206 @@
/**
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
* обязан соответствовать.
*
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
* гейтом.
*
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
* этого не видела, потому что проверять было нечего: сам файл иконки под
* гейтом не был.
*/
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
export const SYMBOL_PREFIX = "icon";
/**
* Иконки, которые многоцветны НАМЕРЕННО.
*
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
* значениями fill.
*
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
* `currentColor`, и это единственный способ, которым иконка может получить
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
*/
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
"download",
"upload",
]);
/**
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
* монохромной иконке.
*
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
* штрихом, и `fill="none"` там обязателен.
*/
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
const PAINT_ATTRIBUTES = [
"fill",
"stroke",
"stop-color",
"flood-color",
"lighting-color",
];
/** `icons/log-system.svg` → `log-system`. */
export function iconName(filePath: string): string {
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
}
/**
* Убирает то, что внутри уже существующего документа не только бесполезно, но
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
*/
function stripProlog(raw: string): string {
return raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
* этот модуль обязан делать видимым. За соответствие отвечает
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый `vite-plugin-svg-icons`.
*/
export function toSymbol(raw: string, name: string): string {
const withoutProlog = stripProlog(raw);
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
export function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Проверяет ассет на соответствие контракту спрайта.
*
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
* бы с первым, и разошёлся бы молча.
*/
export function findIconContractViolations(raw: string, name: string): string[] {
const violations: string[] = [];
const source = stripProlog(raw);
const openTag = source.match(/<svg\b[^>]*>/i);
if (!openTag) {
return [`${name}: нет корневого <svg>`];
}
// Система координат: либо объявленный viewBox, либо пара width/height, из
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
// молча.
if (!resolveViewBox(openTag[0])) {
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
}
if (MULTICOLOR_ICONS.has(name)) {
return violations;
}
for (const attribute of PAINT_ATTRIBUTES) {
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
for (const match of source.matchAll(pattern)) {
const value = match[1].trim();
if (value === "") {
continue;
}
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
violations.push(
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
);
}
}
}
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
// такое же нарушение контракта, как литеральный атрибут.
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
const declarations = match[1].toLowerCase();
for (const attribute of PAINT_ATTRIBUTES) {
const property = declarations.match(
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
);
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
violations.push(
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
);
}
}
}
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
// способен покрасить что угодно, включая чужие иконки: селекторы там
// глобальные. Пустой блок остаётся от редакторов и безвреден.
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
if (match[1].trim() !== "") {
violations.push(`${name}: непустой <style> внутри ассета`);
}
}
// Растр внутри иконки не наследует цвет ничем и никогда.
if (/<image\b/i.test(source)) {
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
}
return violations;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Внутренние константы бренда.
*
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
* сборке.
*
* По той же причине оно объявлено один раз, а не написано в шаблоне
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
* проверить одним гейтом, ни изменить одной правкой.
*
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
*/
export const FLAMY_NAME = "Flamy" as const;
export const FLAMY_URL = "https://flamy.studio" as const;
+61 -5
View File
@@ -112,7 +112,63 @@ export default {
invalid: "Invalid value", invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully", switchLanguageSuccess: "Language switched successfully",
logoutConfirm: "Are you sure you want to log out?", logoutConfirm: "Are you sure you want to log out?",
sessionExpired: "Current session has expired, please log in again", sessionExpired: "Your session has expired. Sign in again to continue.",
signInRequired: "Signing in is required.",
signIn: "Sign in",
systemError: "System error",
networkError: "The server is not responding. Check the connection.",
},
error: {
field: {
name: "Peer name",
secret: "Secret",
remark: "Remark",
quotaBytes: "Quota",
expiresAt: "Expiry",
maxDevices: "Max devices",
disabled: "State",
bannedUntil: "Banned until",
file: "File",
id: "Identifier",
username: "Username",
pass: "Password",
oldPassword: "Old password",
newPassword: "New password",
key: "Setting key",
value: "Setting value",
numLine: "Line count",
pageNum: "Page number",
pageSize: "Page size",
},
code: {
required: "“{field}”: required",
min: "“{field}”: must not be less than {min}",
max: "“{field}”: must not be greater than {max}",
min_length: "“{field}”: at least {min} characters",
max_length: "“{field}”: at most {max} characters",
len: "“{field}”: length must be exactly {len}",
oneof: "“{field}”: allowed values are {values}",
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: contains characters that are not allowed",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid: "Request could not be parsed: check field formats and types",
peer_name_taken: "A peer with this name already exists",
peer_name_reserved: "This name is reserved for the installer peer",
peer_bootstrap_identity_locked:
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
invalid_credentials: "Wrong username or password",
import_file_extension: "Import accepts .json files only",
unauthorized: "Signing in is required",
session_expired: "Session expired",
token_invalid: "Session is not valid",
account_disabled: "Account is disabled",
},
},
sidebar: {
developedBy: "Made at {brand}",
}, },
info: { info: {
expireTime: "y-M-d H:m:s", expireTime: "y-M-d H:m:s",
@@ -128,12 +184,12 @@ export default {
remark: "Remark", remark: "Remark",
secret: "Secret", secret: "Secret",
form: { form: {
namePlaceholder: "e.g. ivan-laptop", namePlaceholder: "client-01",
nameHint: nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.", "Peer identifier: 6 to 32 characters, latin letters, digits and hyphens. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team", remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.", remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically", secretPlaceholder: "leave empty to generate one",
secretHint: secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.", "Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.", quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
+69 -5
View File
@@ -109,7 +109,71 @@ export default {
invalid: "Некорректное значение", invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён", switchLanguageSuccess: "Язык переключён",
logoutConfirm: "Выйти из системы?", logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова", sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
signInRequired: "Требуется вход в панель.",
signIn: "Войти",
systemError: "Системная ошибка",
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
},
// Причины отказа API.
//
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
// серверной.
error: {
field: {
name: "Имя пира",
secret: "Секрет",
remark: "Комментарий",
quotaBytes: "Квота",
expiresAt: "Срок действия",
maxDevices: "Лимит устройств",
disabled: "Состояние",
bannedUntil: "Блокировка до",
file: "Файл",
id: "Идентификатор",
username: "Логин",
pass: "Пароль",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
key: "Ключ настройки",
value: "Значение настройки",
numLine: "Число строк",
pageNum: "Номер страницы",
pageSize: "Размер страницы",
},
code: {
required: "«{field}»: поле обязательно",
min: "«{field}»: значение не может быть меньше {min}",
max: "«{field}»: значение не может быть больше {max}",
min_length: "«{field}»: не короче {min} символов",
max_length: "«{field}»: не длиннее {max} символов",
len: "«{field}»: длина должна быть ровно {len}",
oneof: "«{field}»: допустимые значения — {values}",
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
peer_name_taken: "Пир с таким именем уже существует",
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
peer_bootstrap_identity_locked:
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
invalid_credentials: "Неверный логин или пароль",
import_file_extension: "Импорт принимает только файлы .json",
unauthorized: "Требуется вход в панель",
session_expired: "Сессия истекла",
token_invalid: "Сессия недействительна",
account_disabled: "Учётная запись отключена",
},
},
sidebar: {
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
// строкой с одним подстановочным местом.
developedBy: "Разработано во {brand}",
}, },
info: { info: {
expireTime: "г-М-д Ч:м:с", expireTime: "г-М-д Ч:м:с",
@@ -124,12 +188,12 @@ export default {
remark: "Комментарий", remark: "Комментарий",
secret: "Секрет", secret: "Секрет",
form: { form: {
namePlaceholder: "например, ivan-laptop", namePlaceholder: "client-01",
nameHint: nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", "Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: апример, Ноутбук Ивана, отдел продаж", remarkPlaceholder: оутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически", secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint: secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.", "Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.", quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
defineProps({
collapse: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
<!--
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
обязана быть видна в обоих состояниях.
-->
<a
v-if="collapse"
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
<template #brand>
<a
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
</template>
</i18n-t>
</div>
</template>
<style lang="scss" scoped>
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
height: $sidebarFooterHeight;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
line-height: 1.2;
color: rgb(255 255 255 / 45%);
text-align: center;
white-space: nowrap;
background-color: var(--menuBg);
border-top: 1px solid rgb(255 255 255 / 6%);
}
.sidebar-footer.is-collapsed {
padding: 0 4px;
}
.sidebar-footer-brand {
color: var(--el-color-primary);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
</style>
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
import SidebarItem from "./SidebarItem.vue"; import SidebarItem from "./SidebarItem.vue";
import Logo from "./Logo.vue"; import Logo from "./Logo.vue";
import Footer from "./Footer.vue";
import { usePermissionStore } from "@/store/modules/permission"; import { usePermissionStore } from "@/store/modules/permission";
import { useAppStore } from "@/store/modules/app"; import { useAppStore } from "@/store/modules/app";
@@ -36,5 +37,6 @@ const route = useRoute();
/> />
</el-menu> </el-menu>
</el-scrollbar> </el-scrollbar>
<Footer :collapse="!appStore.sidebar.opened" />
</div> </div>
</template> </template>
+11 -1
View File
@@ -38,12 +38,22 @@
height: 100%; height: 100%;
} }
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
// длинном списке: им физически некуда.
&.has-logo { &.has-logo {
.el-scrollbar { .el-scrollbar {
height: calc(100% - 50px); height: calc(100% - 50px - #{$sidebarFooterHeight});
} }
} }
.sidebar-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.is-horizontal { .is-horizontal {
display: none; display: none;
} }
+7
View File
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
$sideBarWidth: 210px; $sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px; $sideBarCollapsedWidth: 54px;
// Высота подписи разработчика внизу бокового меню.
//
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
$sidebarFooterHeight: 34px;
+111
View File
@@ -0,0 +1,111 @@
/**
* Разбор структурированного отказа API.
*
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
* панель могла сделать, показать это слово тостом. Оператор, оставивший поле
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
* не имел ни одного способа узнать причину.
*
* Теперь у отказа есть код, а у отказа по полю ещё и имя поля. Панель
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
*/
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
export const API_CODE = {
success: 20000,
systemError: 50000,
validationFailed: 50001,
unauthorized: 50401,
forbidden: 50403,
} as const;
/**
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
*
* Перечислены только те, на которые панель реагирует по-разному. Остальные
* доезжают до оператора сообщением сервера.
*/
export const ERR_CODE = {
bodyInvalid: "body_invalid",
validationFailed: "validation_failed",
required: "required",
min: "min",
max: "max",
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
// разные фразы для оператора.
minLength: "min_length",
maxLength: "max_length",
len: "len",
oneOf: "oneof",
greaterThan: "gt",
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
sessionExpired: "session_expired",
tokenInvalid: "token_invalid",
accountDisabled: "account_disabled",
} as const;
export interface ApiFieldError {
code: string;
field?: string;
message: string;
params?: Record<string, string>;
}
export interface ApiErrorPayload {
code: number;
message?: string;
errors?: ApiFieldError[];
}
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
export class ApiError extends Error {
readonly code: number;
readonly errors: ApiFieldError[];
constructor(payload: ApiErrorPayload) {
super(payload.message || "Error");
this.name = "ApiError";
this.code = payload.code;
this.errors = payload.errors ?? [];
}
/** Причины, привязанные к полям формы. */
fieldErrors(): ApiFieldError[] {
return this.errors.filter((item) => !!item.field);
}
/** Первая причина без привязки к полю — отказ уровня операции. */
operationError(): ApiFieldError | undefined {
return this.errors.find((item) => !item.field);
}
hasCode(code: string): boolean {
return this.errors.some((item) => item.code === code);
}
get requiresSignIn(): boolean {
return this.code === API_CODE.unauthorized;
}
get sessionExpired(): boolean {
return (
this.hasCode(ERR_CODE.sessionExpired) ||
this.hasCode(ERR_CODE.accountDisabled)
);
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+73
View File
@@ -0,0 +1,73 @@
import i18n from "@/lang/index";
import { ApiError, ApiFieldError } from "@/utils/api-error";
/**
* Локализация причины отказа.
*
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
* человекочитаемое сообщение оно остаётся ответом для клиента без панели и
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
*
* Числа правил (границы длины, допустимые значения) приходят в `params`.
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
* границам.
*/
const t = i18n.global.t;
const te = i18n.global.te;
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
function fieldLabel(field: string): string {
const key = `error.field.${field}`;
return te(key) ? t(key) : field;
}
/** Сообщение по одной причине отказа. */
export function describeFieldError(error: ApiFieldError): string {
const key = `error.code.${error.code}`;
if (te(key)) {
return t(key, {
field: error.field ? fieldLabel(error.field) : "",
...(error.params ?? {}),
});
}
return error.message;
}
/** Причины по именам полей формы — для подстановки в el-form. */
export function fieldErrorMap(error: ApiError): Record<string, string> {
const result: Record<string, string> = {};
for (const item of error.fieldErrors()) {
// Первая причина по полю выигрывает: показывать в одном поле две строки
// некуда, а порядок ответа отражает порядок правил.
if (item.field && !(item.field in result)) {
result[item.field] = describeFieldError(item);
}
}
return result;
}
/**
* Одна строка, пригодная для тоста.
*
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
* перечисление «поле: причина» тост при этом остаётся вторым сигналом, а
* первым служит подсветка самих полей.
*/
export function describeApiError(error: ApiError): string {
const operation = error.operationError();
if (operation) {
return describeFieldError(operation);
}
const fields = error.fieldErrors();
if (fields.length > 0) {
return fields
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
.join("; ");
}
return error.message || t("common.systemError");
}
+105 -26
View File
@@ -1,6 +1,12 @@
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios"; import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { useAdminStoreHook } from "@/store/modules/admin"; import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index"; import i18n from "@/lang/index";
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
import { describeApiError } from "@/utils/api-message";
const dynamicBase = (window as any).__dynamic_base__ || ""; const dynamicBase = (window as any).__dynamic_base__ || "";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI: // Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
@@ -10,6 +16,19 @@ const dynamicBase = (window as any).__dynamic_base__ || "";
// ADMIN_API_BASE в оркестраторе. // ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api"; const API_BASE = "/api";
const t = i18n.global.t; const t = i18n.global.t;
/**
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит.
*/
declare module "axios" {
export interface AxiosRequestConfig {
skipErrorToast?: boolean;
}
}
// Создание axios instance // Создание axios instance
const service = axios.create({ const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`, baseURL: `${dynamicBase}${API_BASE}`,
@@ -31,38 +50,98 @@ service.interceptors.request.use(
} }
); );
/**
* Сессия кончилась под руками у оператора.
*
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
* аргумент interceptors.response.use) для отказов API не вызывался вовсе а
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
* Ключ common.sessionExpired существовал и был мёртвым.
*
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
* параллельных запросов страницы, и без этого оператор получил бы стопку
* одинаковых окон.
*/
let sessionPromptOpen = false;
function promptSignIn(expired: boolean): void {
if (sessionPromptOpen) {
return;
}
sessionPromptOpen = true;
const finish = () => {
sessionPromptOpen = false;
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
// есть заодно стирал выбранный оператором язык панели: при следующем входе
// интерфейс возвращался к значению по умолчанию без всякой причины.
useAdminStoreHook().resetToken();
const redirect = encodeURIComponent(
window.location.pathname + window.location.search
);
window.location.href = `/login?redirect=${redirect}`;
};
ElMessageBox.confirm(
expired ? t("common.sessionExpired") : t("common.signInRequired"),
t("common.warning"),
{
confirmButtonText: t("common.signIn"),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
type: "warning",
}
)
.then(finish)
.catch(finish);
}
// Response interceptor // Response interceptor
service.interceptors.response.use( service.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
const { code, message } = response.data; // Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
if (code === 20000) { // проверяться ДО обращения к его полям: у Blob их нет.
return response.data; if (
} response.data instanceof ArrayBuffer ||
// Обработка бинарного ответа при экспорте файлов response.data instanceof Blob
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) { ) {
return response; return response;
} }
ElMessage.error(message || "Системная ошибка"); const payload = response.data as ApiErrorPayload;
return Promise.reject(new Error(message || "Error")); if (payload?.code === API_CODE.success) {
}, return response.data;
(error: any) => {
if (error.response.data) {
const { code, msg } = error.response.data;
// Token истёк, нужен повторный вход
if (code === "A0230") {
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
type: "warning",
}).then(() => {
localStorage.clear();
window.location.href = "/";
});
} else {
ElMessage.error(msg || "Системная ошибка");
}
} }
return Promise.reject(error.message);
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
if (apiError.requiresSignIn) {
promptSignIn(apiError.sessionExpired);
return Promise.reject(apiError);
}
if (!response.config?.skipErrorToast) {
ElMessage.error(describeApiError(apiError));
}
return Promise.reject(apiError);
},
(error: AxiosError) => {
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
// самого error.response — то есть при обрыве соединения падал с
// TypeError и подменял настоящую причину отказом внутри обработчика.
const message = error.response
? t("common.systemError")
: t("common.networkError");
if (!error.config?.skipErrorToast) {
ElMessage.error(message);
}
return Promise.reject(error);
} }
); );
+175 -23
View File
@@ -184,21 +184,29 @@
:rules="rules" :rules="rules"
label-width="140px" label-width="140px"
> >
<el-form-item :label="$t('peer.name')" prop="name"> <el-form-item
:label="$t('peer.name')"
prop="name"
:error="serverErrors.name"
>
<el-input <el-input
v-model="dataForm.name" v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')" :placeholder="$t('peer.form.namePlaceholder')"
/> />
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div> <div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.remark')"> <el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input <el-input
v-model="dataForm.remark" v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')" :placeholder="$t('peer.form.remarkPlaceholder')"
/> />
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div> <div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret"> <el-form-item
:label="$t('peer.secret')"
prop="secret"
:error="serverErrors.secret"
>
<el-input <el-input
v-model="dataForm.secret" v-model="dataForm.secret"
show-password show-password
@@ -206,20 +214,24 @@
/> />
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div> <div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.quota')"> <el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
<el-input-number v-model="dataForm.quotaBytes" :min="-1" /> <el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div> <div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.expireTime')" <el-form-item
:label="$t('peer.expireTime')"
:error="serverErrors.expiresAt"
><el-date-picker ><el-date-picker
v-model="dataForm.expiresAt" v-model="dataForm.expiresAt"
type="datetime" type="datetime"
value-format="x" value-format="x"
/></el-form-item> /></el-form-item>
<el-form-item :label="$t('peer.maxDevices')" <el-form-item
:label="$t('peer.maxDevices')"
:error="serverErrors.maxDevices"
><el-input-number v-model="dataForm.maxDevices" :min="1" ><el-input-number v-model="dataForm.maxDevices" :min="1"
/></el-form-item> /></el-form-item>
<el-form-item :label="$t('peer.disabled')" <el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
><el-switch v-model="disabledBool" ><el-switch v-model="disabledBool"
/></el-form-item> /></el-form-item>
</el-form> </el-form>
@@ -342,7 +354,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import QrcodeVue from "qrcode.vue"; import QrcodeVue from "qrcode.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
@@ -369,6 +381,8 @@ import {
PeerVo, PeerVo,
} from "@/api/peer/types"; } from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus"; import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
/** /**
* Единственный переход от строки слота таблицы к модели пира. * Единственный переход от строки слота таблицы к модели пира.
@@ -430,6 +444,53 @@ const disabledBool = computed({
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0), set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
}); });
/**
* Причины отказа, присланные сервером, по именам полей формы.
*
* Сервер остаётся ЕДИНСТВЕННЫМ авторитетом: правила ниже лишь избавляют
* оператора от лишнего похода на сервер за очевидной ошибкой, а окончательный
* ответ всегда даёт он. Поэтому его причины подставляются прямо под поля, а не
* показываются тостом «Invalid», как было раньше.
*/
const serverErrors = reactive<Record<string, string>>({});
function clearServerErrors() {
for (const key of Object.keys(serverErrors)) {
delete serverErrors[key];
}
}
/**
* Правка поля снимает серверную причину с НЕГО.
*
* Проп `error` у `el-form-item` перекрывает внутреннее состояние проверки:
* оставленная под полем серверная причина висела бы там, пока оператор
* исправляет значение, и не исчезала бы даже когда локальные правила уже
* довольны. Снимается причина только с изменённого поля остальные отказы
* той же отправки всё ещё в силе, и убирать их означало бы скрыть работу,
* которую оператору ещё предстоит сделать.
*/
watch(
() => ({ ...dataForm }),
(next, previous) => {
if (!previous) {
return;
}
for (const key of Object.keys(serverErrors)) {
if (next[key as keyof typeof next] !== previous[key as keyof typeof previous]) {
delete serverErrors[key];
}
}
}
);
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
const rules = { const rules = {
name: [ name: [
{ {
@@ -437,6 +498,57 @@ const rules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
}),
trigger: ["change", "blur"],
},
],
secret: [
{
// Пустое поле законный ввод: секрет сгенерирует сервер. Проверяется
// только НЕПУСТОЕ значение.
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
const manual = (value ?? "").trim();
if (manual === "") {
callback();
return;
}
if (manual.length < SECRET_MIN_LENGTH) {
callback(
new Error(
t("error.code.min_length", {
field: t("error.field.secret"),
min: SECRET_MIN_LENGTH,
})
)
);
return;
}
if (manual.length > SECRET_MAX_LENGTH) {
callback(
new Error(
t("error.code.max_length", {
field: t("error.field.secret"),
max: SECRET_MAX_LENGTH,
})
)
);
return;
}
callback();
},
trigger: ["change", "blur"],
},
], ],
}; };
@@ -481,6 +593,7 @@ async function handleQuery() {
} }
function handleAdd() { function handleAdd() {
clearServerErrors();
Object.assign(dataForm, { Object.assign(dataForm, {
id: undefined, id: undefined,
name: "", name: "",
@@ -497,6 +610,7 @@ function handleAdd() {
} }
async function handleUpdate(row: PeerVo) { async function handleUpdate(row: PeerVo) {
clearServerErrors();
const { data } = await getPeerApi({ id: row.id }); const { data } = await getPeerApi({ id: row.id });
Object.assign(dataForm, data, { secret: "" }); Object.assign(dataForm, data, { secret: "" });
dialog.title = t("common.update"); dialog.title = t("common.update");
@@ -505,6 +619,8 @@ async function handleUpdate(row: PeerVo) {
} }
async function submitForm() { async function submitForm() {
clearServerErrors();
if (formRef.value) { if (formRef.value) {
const ok = await formRef.value.validate().catch(() => false); const ok = await formRef.value.validate().catch(() => false);
if (!ok) return; if (!ok) return;
@@ -516,25 +632,59 @@ async function submitForm() {
{ type: "warning" } { type: "warning" }
); );
} }
if (dialog.editId > 0) {
const payload: PeerUpdateDto = { try {
id: dialog.editId, if (dialog.editId > 0) {
name: dataForm.name, const payload: PeerUpdateDto = {
secret: dataForm.secret || undefined, id: dialog.editId,
quotaBytes: dataForm.quotaBytes, name: dataForm.name,
expiresAt: dataForm.expiresAt, // Пустой секрет при изменении означает «не менять», и сервер читает
maxDevices: dataForm.maxDevices, // его именно так. Отправлять undefined больше не требуется, но и вреда
disabled: dataForm.disabled, // в этом нет: оба состояния для него теперь одинаковы.
remark: dataForm.remark, secret: dataForm.secret || undefined,
}; quotaBytes: dataForm.quotaBytes,
await updatePeerApi(payload); expiresAt: dataForm.expiresAt,
} else { maxDevices: dataForm.maxDevices,
await savePeerApi(dataForm); disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
// Секрет отправляется как есть, включая пустую строку: автогенерация
// обязанность сервера, а не подстановка значения здесь.
await savePeerApi(dataForm);
}
} catch (error) {
applyServerErrors(error);
return;
} }
dialog.visible = false; dialog.visible = false;
await handleQuery(); await handleQuery();
} }
/**
* Раскладывает отказ сервера по полям формы.
*
* Если причина не относится ни к одному полю это отказ уровня операции
* (например, имя уже занято другим пиром при переименовании), и он
* показывается тостом. Диалог при этом остаётся открытым: закрывать форму,
* потерявшую введённое, из-за исправимой ошибки нельзя.
*/
function applyServerErrors(error: unknown) {
if (!isApiError(error)) {
// Транспортный отказ уже показан общим перехватчиком.
return;
}
const byField = fieldErrorMap(error);
Object.assign(serverErrors, byField);
if (Object.keys(byField).length === 0) {
ElMessage.error(describeApiError(error));
}
}
async function handleDelete(row: PeerVo) { async function handleDelete(row: PeerVo) {
await ElMessageBox.confirm( await ElMessageBox.confirm(
t("common.deleteConfirm", { username: row.name }), t("common.deleteConfirm", { username: row.name }),
@@ -615,7 +765,9 @@ async function downloadExport(includeSecrets: boolean) {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
ElMessage.success(t("common.downloadSuccess")); ElMessage.success(t("common.downloadSuccess"));
} catch { } catch {
ElMessage.error(t("common.invalid")); // Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
// общий разбор конверта: у Blob нет полей code и errors.
ElMessage.error(t("common.systemError"));
} }
} }
+3 -3
View File
@@ -12,18 +12,18 @@ func AdminHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claimsRaw, ok := c.Get("adminClaims") claimsRaw, ok := c.Get("adminClaims")
if !ok { if !ok {
vo.Fail(constant.UnauthorizedError, c) vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort() c.Abort()
return return
} }
claims, castOK := claimsRaw.(bo.AccountBo) claims, castOK := claimsRaw.(bo.AccountBo)
if !castOK { if !castOK {
vo.Fail(constant.IllegalTokenError, c) vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
if !util.ArrContain(claims.Roles, "admin") { if !util.ArrContain(claims.Roles, "admin") {
vo.Fail(constant.ForbiddenError, c) vo.FailForbidden(constant.ForbiddenError, c)
c.Abort() c.Abort()
return return
} }
+47 -6
View File
@@ -1,41 +1,62 @@
package middleware package middleware
import ( import (
"errors"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"strings"
) )
// Отказ аутентификации несёт КОД состояния сессии.
//
// Раньше все ветки здесь звали vo.Fail с человеческой строкой, а код ответа
// выводился в vo сравнением этой строки с тремя известными литералами. Под
// условия подходил только `unauthorized`; `token expired` и `authentication
// failed` уезжали к панели как обычная системная ошибка с кодом 50000.
//
// Следствие было видимым для оператора: истёкшая сессия на открытой странице
// давала голый тост «token expired», ветка «войдите заново» не срабатывала
// никогда, а перебросить на форму входа мог только переход по маршруту,
// которому потребовался бы getAdminInfo. Ключ локализации `common.sessionExpired`
// при этом существовал и был мёртвым.
func JWTHandler() gin.HandlerFunc { func JWTHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization") authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
vo.Fail(constant.UnauthorizedError, c) vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort() c.Abort()
return return
} }
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") { if !(len(parts) == 2 && parts[0] == "Bearer") {
vo.Fail(constant.IllegalTokenError, c) vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
myClaims, err := service.ParseToken(parts[1]) myClaims, err := service.ParseToken(parts[1])
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.FailUnauthorized(tokenErrorCode(err), err.Error(), c)
c.Abort() c.Abort()
return return
} }
admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id) admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id)
if err != nil { if err != nil {
// Это уже не состояние сессии, а отказ чтения учётной записи:
// сворачивать его в «войдите заново» значило бы отправлять
// оператора на форму входа при недоступной базе.
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
c.Abort() c.Abort()
return return
} }
if admin.Status != nil && *admin.Status != 1 { if admin.Status != nil && *admin.Status != 1 {
vo.Fail("this account has been disabled", c) vo.FailUnauthorized(
constant.ErrCodeAccountDisabled,
"this account has been disabled",
c,
)
c.Abort() c.Abort()
return return
} }
@@ -44,7 +65,9 @@ func JWTHandler() gin.HandlerFunc {
tokenVersion = *admin.TokenVersion tokenVersion = *admin.TokenVersion
} }
if myClaims.Admin.TokenVersion != tokenVersion { if myClaims.Admin.TokenVersion != tokenVersion {
vo.Fail(constant.IllegalTokenError, c) // Версия токена сменилась: пароль изменён или доступ отозван.
// Для оператора это неотличимо от истёкшей сессии — вход заново.
vo.FailUnauthorized(constant.ErrCodeSessionExpired, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
@@ -52,3 +75,21 @@ func JWTHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// tokenErrorCode различает истёкший токен и недействительный.
//
// Вопрос задаётся ЗНАЧЕНИЮ ошибки, а не её тексту: service.ParseToken
// возвращает объявленные значения, поэтому правка формулировки сообщения не
// может молча превратить истёкшую сессию в неизвестную ошибку.
//
// Отказ прочитать ключ подписи (недоступная база) сюда тоже приходит, и это
// НЕ состояние сессии. Отдельного кода он не получает намеренно: снаружи
// панели такой отказ неотличим от недействительного токена, и предлагать
// оператору войти заново — единственное осмысленное действие, которое ему
// доступно.
func tokenErrorCode(err error) string {
if errors.Is(err, service.ErrTokenExpired) {
return constant.ErrCodeSessionExpired
}
return constant.ErrCodeTokenInvalid
}
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Состояние сессии сообщается КОДОМ, а не текстом.
//
// Регрессия. Все отказы аутентификации звали vo.Fail с человеческой строкой, а
// код ответа выводился сравнением этой строки с тремя известными литералами.
// Под условия подходил только `unauthorized`; истёкший токен уезжал с кодом
// системной ошибки 50000, панель показывала оператору голый тост
// «token expired» и не понимала, что сессия кончилась. Ключ локализации
// common.sessionExpired существовал и был мёртвым, а вернуть оператора на
// форму входа мог только переход по маршруту, которому потребовался бы
// getAdminInfo.
type authResponse struct {
Code int `json:"code"`
Type string `json:"type"`
Errors []vo.FieldError `json:"errors"`
}
func callJWTHandler(t *testing.T, header string) authResponse {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/guarded", JWTHandler(), func(c *gin.Context) {
vo.Success(nil, c)
})
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
if header != "" {
request.Header.Set("Authorization", header)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var parsed authResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &parsed); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return parsed
}
func TestJWTHandlerReportsMissingCredentials(t *testing.T) {
response := callJWTHandler(t, "")
if response.Code != constant.CodeUnauthorizedError {
t.Fatalf("код ответа %d, ожидался %d", response.Code, constant.CodeUnauthorizedError)
}
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeUnauthorized {
t.Fatalf("неожиданное описание отказа: %+v", response.Errors)
}
}
func TestJWTHandlerReportsMalformedAuthorizationHeader(t *testing.T) {
for _, header := range []string{"token-without-scheme", "Basic dXNlcjpwYXNz"} {
response := callJWTHandler(t, header)
if response.Code != constant.CodeUnauthorizedError {
t.Errorf("заголовок %q: код ответа %d, ожидался %d",
header, response.Code, constant.CodeUnauthorizedError)
continue
}
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeTokenInvalid {
t.Errorf("заголовок %q: неожиданное описание отказа: %+v", header, response.Errors)
}
}
}
// Истёкшая сессия обязана быть отличима от недействительного токена: панель
// показывает оператору разные вещи и по-разному его возвращает на вход.
func TestTokenErrorCodeSeparatesExpiryFromInvalidity(t *testing.T) {
if code := tokenErrorCode(service.ErrTokenExpired); code != constant.ErrCodeSessionExpired {
t.Errorf("истёкший токен получил код %q, ожидался %q", code, constant.ErrCodeSessionExpired)
}
if code := tokenErrorCode(service.ErrTokenInvalid); code != constant.ErrCodeTokenInvalid {
t.Errorf("недействительный токен получил код %q, ожидался %q", code, constant.ErrCodeTokenInvalid)
}
}
+57
View File
@@ -12,3 +12,60 @@ const (
WrongPassword string = "wrong password" WrongPassword string = "wrong password"
ConfigNotExist string = "config not exist" ConfigNotExist string = "config not exist"
) )
// Коды структурированных ошибок.
//
// Зачем они есть. Раньше единственным машиночитаемым признаком ошибки был
// числовой `code` ответа, а всё остальное жило в человеческом тексте: слой vo
// выбирал HTTP-семантику СРАВНЕНИЕМ строки сообщения, а панель показывала
// оператору голое «invalid» на любую ошибку любого поля формы. Оба места
// разбирали прозу — то есть договор между сервером и панелью держался на
// совпадении литералов, которое ничто не проверяло.
//
// Теперь у ошибки есть код и — там, где ошибка относится к полю, — имя поля.
// Панель выбирает по коду свою локализованную строку и не разбирает текст;
// `message` остаётся человекочитаемым ответом для клиента без UI и запасным
// вариантом для кода, которого панель ещё не знает.
//
// Коды — часть публичного контракта API: их значения не меняются вместе с
// формулировками сообщений.
const (
// ErrCodeBodyInvalid — тело запроса не разобралось: не JSON, не тот тип
// поля, сломанная query-строка. Это отказ ДО проверки правил.
ErrCodeBodyInvalid string = "body_invalid"
// ErrCodeValidationFailed — общий код ответа, у которого есть errors[].
ErrCodeValidationFailed string = "validation_failed"
// Коды правил. Совпадают с именами тегов валидатора: одно правило — один
// код, и никакого второго словаря соответствий.
ErrCodeRequired string = "required"
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один. Оператору это разные фразы: «не меньше 1
// устройства» и «не короче 6 символов», — и панель обязана уметь их
// различить, не заводя у себя таблицу «какое поле какого рода».
ErrCodeMin string = "min"
ErrCodeMax string = "max"
ErrCodeMinLength string = "min_length"
ErrCodeMaxLength string = "max_length"
ErrCodeLen string = "len"
ErrCodeOneOf string = "oneof"
ErrCodeGreaterThan string = "gt"
ErrCodePeerName string = "peer_name"
ErrCodeCredentialStr string = "credential_format"
ErrCodeRuleUnknown string = "rule_violated"
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
ErrCodePeerNameTaken string = "peer_name_taken"
ErrCodePeerNameReserved string = "peer_name_reserved"
ErrCodePeerBootstrapLocked string = "peer_bootstrap_identity_locked"
ErrCodeInvalidCredentials string = "invalid_credentials"
ErrCodeImportFileExtension string = "import_file_extension"
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
// во втором случае оператор находится на рабочей странице, и молча
// выбрасывать его на форму входа без объяснения нельзя.
ErrCodeUnauthorized string = "unauthorized"
ErrCodeSessionExpired string = "session_expired"
ErrCodeTokenInvalid string = "token_invalid"
ErrCodeAccountDisabled string = "account_disabled"
)
+10
View File
@@ -7,6 +7,16 @@ type BaseDto struct {
EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // Время окончания EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // Время окончания
} }
// Normalize: нулевая отметка времени — это отсутствие фильтра.
//
// Правило `omitempty,gt=0` на указателе не пропускается (см. normalize.go),
// поэтому пришедший `startTime=0` отказывал бы вместо того, чтобы означать
// «без ограничения снизу».
func (d *BaseDto) Normalize() {
zeroToNil(&d.StartTime)
zeroToNil(&d.EndTime)
}
type IdDto struct { type IdDto struct {
Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ
} }
+5
View File
@@ -4,6 +4,11 @@ type LogDto struct {
NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"` NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"`
} }
// Normalize: «показать 0 строк» — это не запрос, а пропущенный параметр.
func (d *LogDto) Normalize() {
zeroToNil(&d.NumLine)
}
type LogExportDto struct { type LogExportDto struct {
Option *int `json:"option" form:"option" validate:"required,oneof=0 1"` Option *int `json:"option" form:"option" validate:"required,oneof=0 1"`
} }
+81
View File
@@ -0,0 +1,81 @@
package dto
import "strings"
// Приведение входа к каноничному виду ДО проверки правил.
//
// Зачем это нужно. В go-playground/validator тег `omitempty` НЕ пропускает
// правило, если поле объявлено указателем, а указатель не nil. Помощник
// `hasValue` (baked_in.go) устроен так:
//
// if fl.(*validate).fldIsPointer && getValue(field) != nil {
// return true
// }
//
// Для `*string`, указывающего на пустую строку, это возвращает true, то есть
// «значение есть». В результате `omitempty,min=6` на поле `Secret` срабатывало
// именно тогда, когда оператор НИЧЕГО не ввёл: панель отправляла `secret: ""`,
// правило `min=6` применялось к пустой строке и отказывало. Панель при этом
// писала под полем «оставьте пустым — сгенерируем автоматически», а сервер
// умел это сделать: генерация в CreatePeer существовала и была недостижима.
//
// Чинить это тегом на одном поле бессмысленно: ловушка одинаково стоит на
// фильтре списка пиров (очищенный `el-input` шлёт `?name=`, правило `min=1`
// отказывает поиску), на необязательных отметках времени и на всяком будущем
// необязательном поле-указателе. Поэтому нормализация — общий шаг конвейера, а
// не особый случай «если пусто, подставь строку».
//
// Правило формулируется ПОФАКТИЧЕСКИ, для каждого поля отдельно, и это
// сознательно. Пустая строка не везде означает «не задано»: у `remark` она
// означает «очистить пометку», и общее «пусто → nil» молча лишило бы оператора
// возможности её убрать. Ноль у `disabled` и `quotaBytes` — законное значение,
// а не пропуск.
// Normalizable — DTO, приводящее свой вход к каноничному виду.
//
// Вызывается слоем контроллеров между разбором тела и проверкой правил, то
// есть ровно один раз и для всех дверей одинаково.
type Normalizable interface {
Normalize()
}
// blankToNil: «пусто или одни пробелы» становится «не задано».
//
// Применяется к полям, у которых отсутствие значения — законный вход.
func blankToNil(field **string) {
if *field == nil {
return
}
trimmed := strings.TrimSpace(**field)
if trimmed == "" {
*field = nil
return
}
*field = &trimmed
}
// trimValue убирает окружающие пробелы, сохраняя само поле заданным.
//
// Применяется к обязательным полям и к тем, у которых пустая строка — это
// значение, а не пропуск. Пустой ввод после тримминга остаётся пустым и
// получит внятный отказ от `required`, а не молча превратится в «не задано».
func trimValue(field *string) {
if field == nil {
return
}
*field = strings.TrimSpace(*field)
}
// zeroToNil: ноль у необязательного числового поля означает «не задано».
//
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
// «показать 0 строк журнала» и «время начала — 1 января 1970 года» — это
// пропуск фильтра, а не запрос.
func zeroToNil[T int | int64](field **T) {
if *field == nil {
return
}
if **field == 0 {
*field = nil
}
}
+122
View File
@@ -0,0 +1,122 @@
package dto
import "testing"
func strPtr(v string) *string { return &v }
func i64Ptr(v int64) *int64 { return &v }
func intPtr(v int) *int { return &v }
// Граница проходит по КАЖДОМУ полю отдельно, и это главное свойство
// нормализации.
//
// Общее правило «пусто → не задано» выглядит соблазнительно и молча ломает
// смысл: у комментария пустая строка означает «убрать пометку», у флага
// disabled ноль — «включён», у квоты ноль — «нулевая квота». Тест закрепляет,
// что эти три случая не попали под общий гребень.
func TestPeerSaveNormalizeTreatsBlankSecretAsAbsent(t *testing.T) {
for _, blank := range []string{"", " ", "\t", "\n", " \t\n "} {
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(blank)}
d.Normalize()
if d.Secret != nil {
t.Errorf("секрет %q не приведён к «не задано»: %q", blank, *d.Secret)
}
}
}
func TestPeerSaveNormalizeKeepsManualSecretTrimmed(t *testing.T) {
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(" s3cret-value ")}
d.Normalize()
if d.Secret == nil {
t.Fatal("заданный секрет потерян")
}
if *d.Secret != "s3cret-value" {
t.Fatalf("секрет не обрезан по краям: %q", *d.Secret)
}
}
func TestPeerSaveNormalizeKeepsBlankRemarkAsValue(t *testing.T) {
d := PeerSaveDto{Name: strPtr("client-01"), Remark: strPtr(" ")}
d.Normalize()
if d.Remark == nil {
t.Fatal("пустая пометка превращена в «не задано»: очистить комментарий станет нечем")
}
if *d.Remark != "" {
t.Fatalf("пометка не обрезана: %q", *d.Remark)
}
}
func TestPeerUpdateNormalizeTreatsBlankIdentityFieldsAsAbsent(t *testing.T) {
d := PeerUpdateDto{Name: strPtr(" "), Secret: strPtr("")}
d.Normalize()
if d.Name != nil {
t.Error("пустое имя при изменении обязано означать «не менять»")
}
if d.Secret != nil {
t.Error("пустой секрет при изменении обязан означать «не менять»")
}
}
func TestPeerUpdateNormalizeKeepsZeroValuedFlags(t *testing.T) {
d := PeerUpdateDto{
Disabled: i64Ptr(0),
QuotaBytes: i64Ptr(0),
MaxDevices: i64Ptr(1),
}
d.Normalize()
if d.Disabled == nil || *d.Disabled != 0 {
t.Error("disabled=0 означает «включён», а не «не задано»")
}
if d.QuotaBytes == nil || *d.QuotaBytes != 0 {
t.Error("quotaBytes=0 означает нулевую квоту, а не «не задано»")
}
}
// Регрессия: очищенный крестиком фильтр отправлялся как `?name=` и отказывал
// правилом длины, то есть список пиров ломался в один клик.
func TestPeerPageNormalizeDropsClearedFilters(t *testing.T) {
d := PeerPageDto{Name: strPtr(""), Remark: strPtr(" ")}
d.Normalize()
if d.Name != nil || d.Remark != nil {
t.Fatalf("очищенный фильтр не снят: name=%v remark=%v", d.Name, d.Remark)
}
}
func TestBaseNormalizeDropsZeroTimestamps(t *testing.T) {
d := BaseDto{StartTime: i64Ptr(0), EndTime: i64Ptr(0)}
d.Normalize()
if d.StartTime != nil || d.EndTime != nil {
t.Fatal("нулевая отметка времени означает отсутствие фильтра")
}
kept := BaseDto{StartTime: i64Ptr(1), EndTime: i64Ptr(2)}
kept.Normalize()
if kept.StartTime == nil || kept.EndTime == nil {
t.Fatal("заданные отметки времени потеряны")
}
}
func TestLogNormalizeDropsZeroLineCount(t *testing.T) {
d := LogDto{NumLine: intPtr(0)}
d.Normalize()
if d.NumLine != nil {
t.Fatal("«показать 0 строк» — это пропущенный параметр, а не запрос")
}
}
// Все нормализуемые DTO обязаны реализовывать интерфейс: слой контроллеров
// вызывает Normalize через него, и забытая реализация означала бы молча
// пропущенный шаг.
func TestNormalizableIsImplemented(t *testing.T) {
var _ Normalizable = (*PeerSaveDto)(nil)
var _ Normalizable = (*PeerUpdateDto)(nil)
var _ Normalizable = (*PeerPageDto)(nil)
var _ Normalizable = (*BaseDto)(nil)
var _ Normalizable = (*LogDto)(nil)
}
+61 -7
View File
@@ -1,31 +1,85 @@
package dto package dto
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
// длину.
//
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
// внутри одного правила, чтобы такого расхождения больше не было.
type PeerPageDto struct { type PeerPageDto struct {
BaseDto BaseDto
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32"` Name *string `json:"name" form:"name" validate:"omitempty,max=32"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"` Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: очищенный фильтр — это отсутствие фильтра.
//
// Регрессия, которую это закрывает: `el-input` с крестиком очистки ставит
// пустую строку, axios сериализует её как `?name=`, и поиск пиров отказывал с
// «invalid» после нажатия на крестик.
func (d *PeerPageDto) Normalize() {
d.BaseDto.Normalize()
blankToNil(&d.Name)
blankToNil(&d.Remark)
} }
type PeerSaveDto struct { type PeerSaveDto struct {
Name *string `json:"name" form:"name" validate:"required,min=1,max=32,validateStr"` Name *string `json:"name" form:"name" validate:"required,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"` Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"` QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"` ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"` MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"` Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: пустой секрет означает «сгенерируй сам».
//
// Именно это обещает подпись под полем, и именно это умеет CreatePeer. Пустая
// пометка при этом остаётся пустой пометкой — «нет комментария» и «не менять
// комментарий» не одно и то же.
func (d *PeerSaveDto) Normalize() {
trimValue(d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
} }
type PeerUpdateDto struct { type PeerUpdateDto struct {
IdDto // Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32,validateStr"` // НЕОБЯЗАТЕЛЕН.
//
// Раньше сюда встраивался IdDto с правилом `required,gt=0`, и тело запроса
// обязано было повторять идентификатор, уже указанный в адресе. Панель его
// повторяла, поэтому расхождение не проявлялось; любой другой клиент,
// сделавший PATCH /peers/7 без `"id": 7` в теле, получал отказ «поле id
// обязательно» — при том, что значение из тела всё равно затирается
// значением из пути.
Id *int64 `json:"id" form:"id" validate:"omitempty,gt=0"`
Name *string `json:"name" form:"name" validate:"omitempty,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"` Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"` QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"` ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"` MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"` Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: при изменении пустое имя и пустой секрет означают «не менять».
//
// Ровно так их и читает service.UpdatePeer (`!= nil && != ""`), поэтому
// приведение здесь не добавляет поведения, а убирает расхождение: без него
// правила отказывали на входе, который сервис считает законным.
//
// `remark` и `disabled` намеренно не трогаются: пустая пометка и ноль — это
// значения, которые оператор устанавливает осознанно.
func (d *PeerUpdateDto) Normalize() {
blankToNil(&d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
} }
type PeerKickDto struct { type PeerKickDto struct {
+78 -16
View File
@@ -1,16 +1,31 @@
package vo package vo
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"net/http"
) )
// FieldError — одна причина отказа.
//
// `Field` заполняется, когда причина относится к конкретному полю формы, и
// пуст для отказов уровня операции. `Params` несёт числа правила (границы
// длины, допустимые значения), чтобы панель могла составить точную фразу, не
// заводя у себя вторую копию этих чисел.
type FieldError struct {
Code string `json:"code"`
Field string `json:"field,omitempty"`
Message string `json:"message"`
Params map[string]string `json:"params,omitempty"`
}
type result struct { type result struct {
Code int `json:"code"` Code int `json:"code"`
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data"` Errors []FieldError `json:"errors,omitempty"`
Data interface{} `json:"data"`
} }
const ( const (
@@ -26,21 +41,68 @@ func Success(data interface{}, c *gin.Context) {
}) })
} }
func Fail(message string, c *gin.Context) { // FailWith — единственное место, где формируется ответ об ошибке.
var code int //
if constant.UnauthorizedError == message { // Код передаётся аргументом. Раньше он ВЫВОДИЛСЯ здесь сравнением текста
code = constant.CodeUnauthorizedError // сообщения с тремя известными строками:
} else if constant.ForbiddenError == message { //
code = constant.CodeForbiddenError // if constant.UnauthorizedError == message { code = ... }
} else if constant.InvalidError == message { //
code = constant.CodeInvalidError // Это тот же антипаттерн, который запрещён панели, только на сервере: смысл
} else { // ответа определялся совпадением литерала. Следствие было не теоретическим —
code = constant.CodeSysError // истёкший токен возвращал `token expired`, под условия не подходил и уезжал
} // как обычная системная ошибка с кодом 50000. Панель показывала оператору
// голый тост и не понимала, что сессия кончилась: ветка входа заново не
// срабатывала никогда.
func FailWith(code int, message string, fieldErrors []FieldError, c *gin.Context) {
c.JSON(http.StatusOK, result{ c.JSON(http.StatusOK, result{
Code: code, Code: code,
Type: TypeError, Type: TypeError,
Message: message, Message: message,
Errors: fieldErrors,
Data: nil, Data: nil,
}) })
} }
// Fail — отказ уровня операции: правила соблюдены, выполнить нельзя.
func Fail(message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, nil, c)
}
// FailDomain — тот же отказ, но с машиночитаемым кодом причины.
func FailDomain(code string, message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, []FieldError{{
Code: code,
Message: message,
}}, c)
}
// FailField — отказ уровня операции, привязанный к полю формы.
func FailField(code string, field string, message string, c *gin.Context) {
FailWith(constant.CodeSysError, message, []FieldError{{
Code: code,
Field: field,
Message: message,
}}, c)
}
// FailValidation — вход не прошёл проверку правил.
func FailValidation(message string, fieldErrors []FieldError, c *gin.Context) {
FailWith(constant.CodeInvalidError, message, fieldErrors, c)
}
// FailUnauthorized — вход требуется или сессия больше не действует.
//
// Причина передаётся кодом: панель по-разному ведёт себя, когда токена нет
// вовсе и когда он только что истёк под руками у оператора.
func FailUnauthorized(code string, message string, c *gin.Context) {
FailWith(constant.CodeUnauthorizedError, message, []FieldError{{
Code: code,
Message: message,
}}, c)
}
// FailForbidden — вход выполнен, но прав недостаточно.
func FailForbidden(message string, c *gin.Context) {
FailWith(constant.CodeForbiddenError, message, nil, c)
}
+17 -1
View File
@@ -25,13 +25,29 @@ func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
return claims, castOK return claims, castOK
} }
// ErrInvalidCredentials — логин или пароль не подошли.
//
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
// превращается в способ проверять существование имён администраторов, а
// панель слушает только localhost именно потому, что вход — самая ценная
// дверь продукта.
//
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
func Login(username string, plainPassword string) (string, bool, error) { func Login(username string, plainPassword string) (string, bool, error) {
admin, err := dao.GetAdminUser("username = ? and status = 1", username) admin, err := dao.GetAdminUser("username = ? and status = 1", username)
if err != nil { if err != nil {
if dao.IsNotFound(err) {
return "", false, ErrInvalidCredentials
}
return "", false, err return "", false, err
} }
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) { if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
return "", false, errors.New(constant.WrongPassword) return "", false, ErrInvalidCredentials
} }
tokenVersion := int64(1) tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 { if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
+21 -3
View File
@@ -70,6 +70,24 @@ func GenToken(accountBo bo.AccountBo) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
} }
// Отказ разбора токена — это ЗНАЧЕНИЕ, а не свежая ошибка с текстом внутри.
//
// Различие существенно для вызывающего: middleware обязан отличить истёкшую
// сессию от недействительного токена, потому что оператору это показывается
// по-разному — «сессия истекла, войдите заново» против «войдите». Раньше
// единственным способом задать этот вопрос было сравнение err.Error() с
// константой, то есть разбор человеческого текста; такое сравнение молча
// перестаёт работать при первой же правке формулировки.
//
// Тексты сохранены прежними: они уезжают в ответ панели.
var (
// ErrTokenExpired — токен разобран, но его срок истёк.
ErrTokenExpired = errors.New(constant.TokenExpiredError)
// ErrTokenInvalid — токен не разобран, подписан не тем ключом или не
// содержит ожидаемых утверждений.
ErrTokenInvalid = errors.New(constant.IllegalTokenError)
)
func ParseToken(tokenString string) (*MyClaims, error) { func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret() secret, err := jwtSecret()
if err != nil { if err != nil {
@@ -89,14 +107,14 @@ func ParseToken(tokenString string) (*MyClaims, error) {
) )
if err != nil { if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) { if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError) return nil, ErrTokenExpired
} }
return nil, errors.New(constant.IllegalTokenError) return nil, ErrTokenInvalid
} }
claims, ok := token.Claims.(*MyClaims) claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid { if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError) return nil, ErrTokenInvalid
} }
return claims, nil return claims, nil
} }
+9 -2
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -119,8 +120,14 @@ func TestParseTokenRejectsExpiredToken(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("истёкший токен принят") t.Fatal("истёкший токен принят")
} }
if err.Error() != constant.TokenExpiredError { // Вопрос задаётся значению, а не тексту: middleware различает истёкшую
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err) // сессию и недействительный токен именно через errors.Is, и проверка здесь
// обязана закреплять тот же способ.
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("истечение срока должно сообщаться отдельным значением, получено: %v", err)
}
if errors.Is(err, ErrTokenInvalid) {
t.Error("истёкший токен не должен выглядеть как недействительный")
} }
} }
+20 -22
View File
@@ -63,39 +63,36 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
// результат которых виден в списке пиров. Bootstrap-пир после установки // результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный // остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ. // неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf( var errBootstrapPeerIdentity = &PeerError{
"пир %q принадлежит установщику: его имя и секрет продублированы в "+ Code: constant.ErrCodePeerBootstrapLocked,
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+ Message: fmt.Sprintf(
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать", "пир %q принадлежит установщику: его имя и секрет продублированы в "+
ReservedBootstrapPeerName, "/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
) "Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
),
}
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" { if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError) return vo.PeerVo{}, ErrPeerNameRequired
} }
// Имя зарезервировано за установщиком даже когда сам пир уже удалён: // Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под // иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему. // защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) return vo.PeerVo{}, ErrPeerNameReserved
} }
taken, err := ExistPeerName(*peerDto.Name, 0) taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil { if err != nil {
return vo.PeerVo{}, err return vo.PeerVo{}, err
} }
if taken { if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name) return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
} }
secret := "" secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
if peerDto.Secret != nil && *peerDto.Secret != "" { if err != nil {
secret = *peerDto.Secret return vo.PeerVo{}, err
} else {
generated, err := util.RandomString(24)
if err != nil {
return vo.PeerVo{}, err
}
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
} }
authId, err := util.RandomString(18) authId, err := util.RandomString(18)
if err != nil { if err != nil {
@@ -178,7 +175,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
return err return err
} }
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName { if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) return ErrPeerNameReserved
} }
} }
@@ -218,7 +215,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
// //
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом // Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже // владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md). // недействующее значение (см. docs/admin/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) { func GetPeerVo(id int64) (vo.PeerVo, error) {
@@ -428,11 +425,12 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
entry.createDigest = digest entry.createDigest = digest
entry.createCipher = cipher entry.createCipher = cipher
} else { } else {
generated, err := util.RandomString(24) // Генерация одна на весь продукт: импорт без секрета обязан давать
// пира, неотличимого от созданного через форму.
createSecret, err := GeneratePeerSecret(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
createSecret := fmt.Sprintf("%s.%s", name, generated)
digest, err := PeerSecretDigest(createSecret) digest, err := PeerSecretDigest(createSecret)
if err != nil { if err != nil {
return nil, err return nil, err
+64
View File
@@ -0,0 +1,64 @@
package service
import (
"fmt"
"hy2xs-admin/model/constant"
)
// Доменные отказы пиров несут КОД, а не только текст.
//
// Раньше причина отказа существовала исключительно в виде человеческой фразы:
// `fmt.Errorf("name %s already exists", …)`. Слой контроллеров отдавал её
// панели как есть, панель показывала её тостом, и всё, что могло бы захотеть
// отреагировать на причину — подсветить нужное поле формы, перевести фразу на
// язык оператора, — было вынуждено сравнивать текст. Такое сравнение молча
// ломается при первой же правке формулировки.
//
// Код и имя поля объявляются здесь, рядом с местом, которое отказ порождает.
// PeerError — отказ операции над пиром с машиночитаемой причиной.
type PeerError struct {
// Code — причина из constant.ErrCode*.
Code string
// Field — поле формы, к которому относится отказ; пусто, если отказ
// относится к операции целиком.
Field string
// Message — человекочитаемое объяснение для клиента без UI.
Message string
}
func (e *PeerError) Error() string { return e.Message }
// ErrPeerNameRequired — имя пира не передано.
//
// Правило `required` в DTO ловит это раньше, но CreatePeer вызывается и из
// тестов, и потенциально из будущих внутренних путей, поэтому проверка
// остаётся, а не превращается в предположение.
var ErrPeerNameRequired = &PeerError{
Code: constant.ErrCodeRequired,
Field: "name",
Message: "имя пира обязательно",
}
// ErrPeerNameReserved — имя принадлежит пиру установщика.
//
// Имя зарезервировано и когда сам пир уже удалён: иначе обычный пир занял бы
// его и оказался под защитой, предназначенной не ему.
var ErrPeerNameReserved = &PeerError{
Code: constant.ErrCodePeerNameReserved,
Field: "name",
Message: fmt.Sprintf(
"имя %q зарезервировано за пиром установщика",
ReservedBootstrapPeerName,
),
}
// PeerNameTakenError — имя уже занято другим пиром.
func PeerNameTakenError(name string) *PeerError {
return &PeerError{
Code: constant.ErrCodePeerNameTaken,
Field: "name",
Message: fmt.Sprintf("пир с именем %q уже существует", name),
}
}
+36 -3
View File
@@ -37,9 +37,39 @@ const MaxPeerImportItems = 5000
// константы рано или поздно разошлись бы. // константы рано или поздно разошлись бы.
const ReservedBootstrapPeerName = dao.BootstrapPeerName const ReservedBootstrapPeerName = dao.BootstrapPeerName
// Тот же набор символов, что и у validateStr в слое контроллеров. // Правило имени пира — ОДНО на весь продукт.
//
// В таблицу пиров ведут две двери: обычное создание через PeerSaveDto и импорт
// выгрузки. Правило у них обязано быть одним, и оно уже расходилось: слой
// контроллеров нёс собственную копию
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// с комментарием «тот же набор символов». Набор был другим — дефис внутри
// класса не экранирован, поэтому `+-=` образует диапазон и впускает
// `, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое
// импорт того же пира отклонял, хотя имя уезжает во fragment клиентской ссылки
// и в автогенерируемый секрет.
//
// Теперь правило объявлено здесь один раз, а controller.validatePeerName зовёт
// IsValidPeerName. Границы длины и человекочитаемый набор экспортируются, чтобы
// сообщение об отказе не заводило собственную копию тех же чисел.
const (
PeerNameMinLength = 6
PeerNameMaxLength = 32
// PeerNameCharset — набор в том виде, в каком его показывают оператору.
PeerNameCharset = `a-z A-Z 0-9 !@#$%^&*()_+-=`
)
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`) var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
// IsValidPeerName сообщает, пригодно ли имя пира. Пробелы по краям к этому
// моменту уже сняты нормализацией DTO; здесь они снимаются повторно, потому
// что импорт приходит не через DTO.
func IsValidPeerName(name string) bool {
return peerNamePattern.MatchString(strings.TrimSpace(name))
}
// authId генерируется через util.RandomString и участвует в HTTP-обмене с // authId генерируется через util.RandomString и участвует в HTTP-обмене с
// Hysteria, поэтому здесь набор ещё уже. // Hysteria, поэтому здесь набор ещё уже.
var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`) var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`)
@@ -70,8 +100,11 @@ func ValidatePeerImportBatch(items []bo.PeerExport) error {
if name == "" { if name == "" {
return peerImportError(i, "пустое имя") return peerImportError(i, "пустое имя")
} }
if !peerNamePattern.MatchString(name) { if !IsValidPeerName(name) {
return peerImportError(i, fmt.Sprintf("недопустимое имя %q: 6-32 символа из [a-zA-Z0-9!@#$%%^&*()_+-=]", name)) return peerImportError(i, fmt.Sprintf(
"недопустимое имя %q: от %d до %d символов из набора %s",
name, PeerNameMinLength, PeerNameMaxLength, PeerNameCharset,
))
} }
if name == ReservedBootstrapPeerName { if name == ReservedBootstrapPeerName {
return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name)) return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name))
+49
View File
@@ -8,6 +8,8 @@ import (
"hy2xs-admin/util" "hy2xs-admin/util"
) )
// Секреты пиров: генерация, отпечаток, шифрование.
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново. // Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
// //
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и // Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
@@ -22,6 +24,53 @@ import (
// из-за которого каждая первая загрузка печатала в журнал // из-за которого каждая первая загрузка печатала в журнал
// `duplicated key not allowed` уровня error на здоровом старте. // `duplicated key not allowed` уровня error на здоровом старте.
// generatedSecretRandomLength — длина случайной части автогенерируемого
// секрета.
//
// 24 символа из алфавита util.RandomString (62 символа) дают примерно 143 бита
// энтропии. Источник — crypto/rand с отбрасыванием смещённых байтов, то есть
// тот же генератор, которым создаются JWT_SECRET и ключи шифрования секретов.
const generatedSecretRandomLength = 24
// GeneratePeerSecret создаёт секрет подключения пира.
//
// Владелец автогенерации — сервисный слой, и это существенно. Панель обещает
// оператору «оставьте пустым — сгенерируем автоматически», и то же обещание
// обязано действовать для прямого вызова API, для импорта и для будущих
// клиентов. Генерация во frontend означала бы, что обещание выполняется ровно
// для одной двери из четырёх, а остальные тихо получают пустое значение.
//
// Имя пира входит в секрет префиксом: в клиенте секрет виден оператору, и
// узнать по нему, какому пиру он принадлежит, полезнее, чем скрыть эту связь.
// Стойкость от этого не страдает — она обеспечивается случайной частью, а имя
// пира и так публично известно из ссылки.
func GeneratePeerSecret(peerName string) (string, error) {
generated, err := util.RandomString(generatedSecretRandomLength)
if err != nil {
return "", err
}
if peerName == "" {
return generated, nil
}
return fmt.Sprintf("%s.%s", peerName, generated), nil
}
// resolvePeerSecret возвращает секрет, который следует сохранить: заданный
// оператором либо сгенерированный.
//
// К этому моменту нормализация DTO уже привела «поле отсутствует», «пустая
// строка» и «одни пробелы» к одному состоянию — nil. Повторный TrimSpace здесь
// нужен для вызовов мимо слоя DTO (тесты, внутренние пути): «сгенерировать»
// обязано означать одно и то же на всех входах.
func resolvePeerSecret(peerName string, provided *string) (string, error) {
if provided != nil {
if manual := strings.TrimSpace(*provided); manual != "" {
return manual, nil
}
}
return GeneratePeerSecret(peerName)
}
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира. // GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) { func GetPeerSecretKey() (string, error) {
return dao.GetOrCreatePeerSecretDigestKey() return dao.GetOrCreatePeerSecretDigestKey()
File diff suppressed because it is too large Load Diff
+39 -15
View File
@@ -47,25 +47,49 @@
persistent path. У мутирующей фазы ровно один владелец — оркестратор: persistent path. У мутирующей фазы ровно один владелец — оркестратор:
`install.sh` проверяет и передаёт управление, не изменяя ничего сам. `install.sh` проверяет и передаёт управление, не изменяя ничего сам.
Очистка предыдущей установки — отдельная явная операция оператора, Очистка предыдущей установки — отдельная явная операция оператора,
см. [14-legacy-cleanup.md](14-legacy-cleanup.md). см. [operations/14-legacy-cleanup.md](operations/14-legacy-cleanup.md).
8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline. 8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline.
## Состав документов ## Состав документов
1. [01-architecture-baseline.md](01-architecture-baseline.md) Документы разложены по слою, к которому относятся. Двузначный префикс в имени —
2. [02-build-layer-and-package.md](02-build-layer-and-package.md) стабильный идентификатор документа: под ним на него ссылаются CHANGELOG,
3. [03-server-hysteria2.md](03-server-hysteria2.md) релизные гейты и сообщения оркестратора, поэтому при переносе в каталоги он
4. [04-admin-panel.md](04-admin-panel.md) сохранён.
5. [05-client-and-access-scope.md](05-client-and-access-scope.md)
6. [06-speed-limits-and-congestion.md](06-speed-limits-and-congestion.md) ### Архитектура и рамки
7. [07-systemd-and-firewall.md](07-systemd-and-firewall.md)
8. [08-orchestrator-spec.md](08-orchestrator-spec.md) - [architecture/01-architecture-baseline.md](architecture/01-architecture-baseline.md) — baseline-модель двух слоёв
9. [09-post-install-env.md](09-post-install-env.md) - [architecture/03-server-hysteria2.md](architecture/03-server-hysteria2.md) — серверный транспорт
10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md) - [architecture/05-client-and-access-scope.md](architecture/05-client-and-access-scope.md) — граница клиента
11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md) - [architecture/06-speed-limits-and-congestion.md](architecture/06-speed-limits-and-congestion.md) — ограничения скорости
12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.md) - [architecture/10-access-layer-out-of-scope.md](architecture/10-access-layer-out-of-scope.md) — что вне baseline
13. [13-production-runbook.md](13-production-runbook.md)
14. [14-legacy-cleanup.md](14-legacy-cleanup.md) ### Сборка
- [build/02-build-layer-and-package.md](build/02-build-layer-and-package.md) — builder layer, состав пакета, требования к сборочной машине
### Runtime на target
- [runtime/08-orchestrator-spec.md](runtime/08-orchestrator-spec.md) — спецификация оркестратора
- [runtime/07-systemd-and-firewall.md](runtime/07-systemd-and-firewall.md) — systemd и nftables
- [runtime/09-post-install-env.md](runtime/09-post-install-env.md) — post-install состояние
### Панель
- [admin/04-admin-panel.md](admin/04-admin-panel.md) — HY2XS admin
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, атрибуция
### Эксплуатация
- [operations/13-production-runbook.md](operations/13-production-runbook.md) — production runbook
- [operations/12-operations-and-troubleshooting.md](operations/12-operations-and-troubleshooting.md) — операции и разбор отказов
- [operations/14-legacy-cleanup.md](operations/14-legacy-cleanup.md) — очистка установки предыдущего поколения
### Проверки
- [testing/](testing/README.md) — набор проверок по слоям (бывший `11-testing-and-acceptance.md`)
- [acceptance/](acceptance/README.md) — отчёты о фактических прогонах приёмки
История изменений проекта — в [CHANGELOG.md](../CHANGELOG.md). История изменений проекта — в [CHANGELOG.md](../CHANGELOG.md).
@@ -0,0 +1,674 @@
# HY2XS 1.0.0-rc1 — отчёт build/host acceptance
**Дата прогона:** 2026-09-01
**Вердикт:** `RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES`
Прогон выполнялся не как статический аудит исходного кода, а как фактическая
release/host acceptance RC-сборки: сборка артефакта, установка на
переустановленный Debian 13 и проверка работающего сервера.
Release candidate:
```text
Version: 1.0.0-rc1
Source commit: a1f0db22c2f6c0b0436789b64e82f53bfa314327
Artifact: hy2xs-install-1.0.0.tar.gz
SHA-256: 7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
Target: Debian 13 / amd64
Hysteria: v2.12.2
Default obfs: gecko
Fallback obfs: salamander
```
> Публичный IPv4 тестового хоста в отчёте заменён на `198.51.100.10`
> (RFC 5737, документационный диапазон). Доменное имя и номер SSH-порта
> оставлены: без них шаги прогона невоспроизводимы.
---
## 1. Что проверялось
* воспроизводимость release build;
* соответствие version contract;
* тесты оркестратора;
* typecheck и сборка frontend;
* тесты Go;
* гейты уязвимостей зависимостей;
* разрешение и фиксация актуального stable Hysteria;
* upstream SHA-256 Hysteria;
* совместимость сгенерированной production-конфигурации с Gecko и Salamander;
* содержимое готового release archive;
* внутренние контрольные суммы пакета;
* clean-host boundary;
* read-only PHASE 0;
* отказ установки поверх HY2XS 0.x;
* фактическая чистая установка на переустановленный Debian 13;
* systemd;
* nftables takeover;
* firewall rollback guard;
* ACME;
* runtime Hysteria;
* runtime админки;
* install-state;
* `status`;
* read-only `doctor`;
* доступ к admin UI исключительно через SSH local forwarding;
* базовые функциональные операции admin UI.
Полный внешний Hysteria/Gecko dataplane через пользовательский desktop-клиент
сознательно отложен до готовности собственного C#/sing-box клиента HY2XS. Это
не подменяется server-side self-test — см. раздел 12.
---
## 2. Build acceptance
Финальная release-сборка выполнена из `a1f0db22c2f6c0b0436789b64e82f53bfa314327`.
Рабочее дерево перед сборкой было чистым.
Orchestrator:
```text
399 pass
0 fail
932 expect() calls
18 test files
```
Hysteria:
```text
Resolved stable: v2.12.2
Tag: app/v2.12.2
Published: 2026-08-23
```
Upstream SHA-256:
```text
6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
```
Скачанный бинарь совпал с upstream `hashes.txt`.
Compatibility gate:
```text
Gecko PASS
Salamander PASS
```
Сгенерированная production-конфигурация HY2XS принята Hysteria `v2.12.2`.
Frontend:
```text
vue-tsc --noEmit PASS
vite production PASS
```
Go:
```text
go test PASS
```
Security:
```text
govulncheck v1.7.0 PASS
reachable vulns 0
pnpm audit high+ PASS
high/critical vulns 0
```
Полный release acceptance дошёл до:
```text
[hy2xs-build] Built dist/hy2xs-install-1.0.0.tar.gz
```
Проверки release pipeline включают в том числе read-only installer boundary,
семантику отката, firewall guard, сериализацию операций, транзакционный импорт
пиров, редактирование секретов, гигиену зависимостей и обязательность
test/security-гейтов.
### 2.1. Требование к памяти build-хоста
Первый `govulncheck` был убит Linux OOM killer на машине с:
```text
RAM: ~1.9 GiB
Swap: 0
```
После подключения временного swap 4 GiB полный security gate прошёл.
Это не runtime-дефект HY2XS, но требование к сборочной машине: около 2 GiB RAM
без swap может быть недостаточно для `govulncheck`. 4 GiB swap здесь — не
формально доказанный минимум, а подтверждённая рабочая конфигурация данного
прогона. См. [docs/build/02-build-layer-and-package.md](../build/02-build-layer-and-package.md).
---
## 3. Исправления release verifier, сделанные во время приёмки
Приёмка выявила несколько ошибок не продукта, а самого release verifier. Они
были исправлены до формирования принятого RC.
### 3.1. Ранний выход matcher'а и `pipefail`
Обнаружен антипаттерн вида `printf … | grep -q …` при `set -o pipefail`. На
достаточно большом выводе продюсера раннее завершение `grep -q` способно
привести продюсера к `SIGPIPE`, и статус всей конструкции становится 141 —
ненулевым именно тогда, когда совпадение НАЙДЕНО.
56 проверок переведены на форму без опасного pipeline.
Кроме того, исправлена более существенная проблема: прежнее
`2>/dev/null || true` превращало ошибку или опечатку в пути файла в пустой
ввод, а отрицательная проверка после этого получала ложный PASS. Теперь
отсутствие ожидаемого исходного файла — ошибка приёмки.
Примечание: единичное первоначальное падение на LICENSE нельзя доказанно
объяснить этим механизмом — размер LICENSE был ниже воспроизведённого порога
буфера канала. После исправлений содержимое LICENSE, его копия в архиве и
контрольные суммы подтверждены отдельно.
### 3.2. Проверки кода против комментариев
Выявлены три ложных совпадения: `virtual:svg-icons-register`, прежние имена
раннеров, `cancelFirewallRollback`. Все они находились в комментариях и прозе,
а приёмка трактовала присутствие строки как возвращение исполняемого кода.
Семантика гейтов исправлена: SVG проверяется по реальному runtime/build
contract; определение раннера учитывает форму идентификатора;
`cancelFirewallRollback` проверяется как declaration/call form, а не как любое
упоминание строки.
Наивный общий разбор `/* … */` намеренно не добавлен: неполный лексер может
удалить настоящее содержимое внутри строкового или регулярного литерала и
создать уже опасный ложный PASS.
### 3.3. Устаревший gate reconfigure
Приёмка ожидала прежний вызов `classifyReconfigureFailure(ownership)` после
того, как фактический контракт стал `classifyReconfigureFailure(ownership, error)`.
Новая архитектура:
```text
обычные ошибки -> классификация по ownership
FirewallGuardFired -> типизированное исключение
текст error.message -> не участвует
```
Gate приведён к фактическому контракту.
---
## 4. Artifact acceptance
Release archive `hy2xs-install-1.0.0.tar.gz`, SHA-256:
```text
7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
```
Хеш независимо пересчитан после копирования архива на Windows и совпал с
серверным.
Metadata пакета:
```text
name=HY2XS
license=AGPL-3.0-only
version=1.0.0
release_line=1
config_schema_version=2
source_git_commit=a1f0db22c2f6
dirty_tree=false
build_profile=production
dependency_security_gate=true
tests_gate=true
hysteria_source=official-upstream
hysteria_version=v2.12.2
hysteria_sha_source=upstream-hashes
hysteria_channel=stable
hysteria_resolution=latest-stable
hysteria_compat_gate=true
```
Полный `sha256sum -c metadata/checksums.txt` для распакованного пакета
завершился без ошибок.
RC опубликован отдельным tag/release `v1.0.0-rc1`.
---
## 5. D0 — установка поверх legacy HY2XS
До переустановки ОС RC был запущен на действующем сервере HY2XS 0.x.
Ожидаемое поведение:
```text
PHASE 0
→ обнаружить legacy markers
→ завершиться до первой persistent mutation
```
Фактический результат: `RC=1`.
Installer обнаружил старые:
```text
/etc/hy2xs
/etc/hysteria
/var/lib/hy2xs
/var/lib/hy2xs-admin
/var/lib/hysteria
/usr/local/lib/hy2xs
/usr/local/bin/hysteria
/usr/local/bin/hy2xs-orchestrator
/etc/nftables.d/hy2xs.nft
systemd units
admin installation
```
и сообщил:
```text
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
Ни один файл на сервере не изменён.
```
Контрольный before/after diff показал только изменение активной базы SQLite
legacy-админки. Отдельный idle-тест без installer подтвердил, что `h_ui.db`
сама меняет hash и mtime примерно каждые 20 секунд при работающем legacy
`hy2xs-admin`.
Следовательно:
```text
D0 legacy detection PASS
D0 fail-before-apply PASS
D0 zero product mutation PASS
```
---
## 6. Baseline чистого хоста
Для дальнейшей проверки ОС была переустановлена.
```text
Debian GNU/Linux 13
amd64
kernel 6.12.85+deb13-amd64
RAM ~1.9 GiB
Swap 0
```
Сеть:
```text
198.51.100.10/24
fi.api.withen.pro -> 198.51.100.10
```
До установки:
```text
HY2XS paths absent
Hysteria paths absent
HY2XS units absent
Hysteria unit absent
nft ruleset empty
UDP 443 free
TCP 80 free
TCP 443 free
```
Единственный ожидаемый внешний listener — SSH :2323.
Clean-host contract подтверждён фактическим составом хоста.
---
## 7. Чистая установка
Установка выполнялась непосредственно из ранее созданного и проверенного RC
artifact. Пакет не пересобирался на target-сервере.
Production profile:
```text
schema 2
domain fi.api.withen.pro
public host fi.api.withen.pro
public port 443
SSH 2323
firewall mode takeover
staged firewall true
admin bind 127.0.0.1
admin port 8080
admin public access false
TLS ACME
ACME challenge HTTP
ACME email admin@withen.pro
Hysteria port 443/udp
obfs gecko
IPv6 disabled
DNS AAAA policy strict
public endpoint policy strict
```
Результат: `INSTALL_RC=0`.
Фактически прошли:
```text
PHASE 0
package checksums
clean-host preflight
operation lock
orchestrator bootstrap
system dependencies
capability preflight
filesystem
runtime env
bundled admin
Hysteria download
Hysteria SHA verification
config generation
systemd installation
firewall staged apply
rollback guard
post-install env
bootstrap admin secret
smoke
firewall guard disarm
durable install commit
rollback cleanup
```
---
## 8. Firewall
До применения firewall создан rollback snapshot. Candidate-конфигурация
проверена до активации:
```text
nft -c -f hy2xs.nft.candidate
nft -c -f nftables.conf.candidate
```
После этого создан transient rollback timer:
```text
deadline: 45 s
AccuracySec: 1 s
RemainAfterElapse=no
```
Firewall применён только после успешного arm guard.
После smoke:
```text
guard disarmed
installed state durably committed
rollback files removed
```
Post-install:
```text
rollback_guard_active=false
rollback_guard_state=quiescent
```
Transient guard units отсутствуют. Candidate-файлы отсутствуют. В
`/run/hy2xs/rollback` остался только пустой родительский каталог.
Действующие правила:
```text
table inet hy2xs
input policy drop
allow loopback
allow established/related
allow TCP/2323 IPv4
allow TCP/80 IPv4
allow UDP/443 IPv4
allow ICMP echo-request
```
---
## 9. Состояние runtime
Hysteria:
```text
v2.12.2
active
enabled
UDP 0.0.0.0:443
trafficStats 127.0.0.1:36712
```
Админка:
```text
active
enabled
TCP 127.0.0.1:8080
```
nftables: `active`, `enabled`.
Let's Encrypt ACME:
```text
authorization valid
certificate obtained successfully
```
Install state:
```text
product=hy2xs
release_line=1
config_schema_version=2
product_version=1.0.0
installed=true
phase=installed
last_error=""
```
Секретные runtime-файлы имеют ограниченные permissions.
`/usr/local/bin/hy2xs-orchestrator` является symlink, конечный исполняемый файл
имеет `0755 root:root`.
---
## 10. `status` и `doctor`
С корректным runtime `--package-dir`:
```text
STATUS_RC=0
DOCTOR_RC=0
```
`status` подтвердил:
```text
services active
firewall valid
firewall entrypoint hy2xs-managed
install generation current
operation none
rollback guard quiescent
runtime state running
install state installed
```
`doctor` прошёл preflight и smoke.
Отдельно проверен read-only contract `doctor` — по фактическим PID процессов,
а не только статическим тестом:
```text
до doctor: hysteria PID = 5041, admin PID = 5042
после doctor: hysteria PID = 5041, admin PID = 5042
doctor Hysteria restart NO
doctor admin restart NO
```
Post-install состояние systemd/firewall/install-state соответствует ожидаемому
контракту.
---
## 11. Admin UI — ручная функциональная проверка
Доступ:
```text
SSH local forwarding
127.0.0.1:8080
```
Публичный admin listener отсутствует.
Проверены: вход, дашборд, список пиров, создание пира, генерация share URI.
Дашборд получает данные CPU/RAM/disk/runtime.
Создание пира фактически работает **при ручном указании секрета** — см. UX-02 в
[перечне дефектов](2026-09-01-v1.0.0-rc1-ux-findings.md).
Share URI формируется в ожидаемом production-формате:
```text
hysteria2://<peer-secret>@<host>:443/
?insecure=0
&obfs=gecko
&obfs-password=<server-obfs-secret>
&sni=<host>
#<peer-name>
```
Реальный секрет из тестовой URI в документацию не переносится.
**Незакрытое действие среды:** одна тестовая URI была выведена за пределы admin
UI, поэтому соответствующий тестовый пир перед дальнейшим использованием среды
следует удалить или пересоздать с новым секретом.
---
## 12. Отложенная проверка Gecko E2E
Полноценный внешний client E2E на этом прогоне не выполнялся.
Причина не в обнаруженном server-side дефекте. Целевой пользовательский клиент
HY2XS ещё разрабатывается:
```text
C#
sing-box core
HY2XS desktop shell
```
Практически пригодных сторонних клиентов с необходимой Gecko-поддержкой
недостаточно для того, чтобы считать их корректной reference implementation.
При этом уже подтверждено:
```text
Hysteria v2.12.2 Gecko config compatibility PASS
Hysteria v2.12.2 Salamander compatibility PASS
server startup PASS
ACME PASS
UDP/443 listener PASS
peer auth/control-plane smoke PASS
share URI generator PASS
```
Статус внешнего Gecko E2E:
```text
DEFERRED — WAITING FOR HY2XS DESKTOP CLIENT
```
Он обязателен для окончательной ecosystem acceptance «server + client», но
отсутствие стороннего Gecko-клиента не следует трактовать как отказ текущего
server/orchestrator/admin RC.
---
## 13. Итоговый статус прогона
```text
SOURCE AUDIT PASS
BUILD PASS
TEST GATE PASS
SECURITY GATE PASS
ARTIFACT INTEGRITY PASS
LEGACY D0 PASS
CLEAN HOST PASS
FRESH INSTALL PASS
ACME PASS
SYSTEMD PASS
NFTABLES PASS
FIREWALL ROLLBACK GUARD PASS
INSTALL STATE PASS
STATUS PASS
DOCTOR PASS
DOCTOR READ-ONLY PASS
ADMIN LOGIN PASS
ADMIN DASHBOARD PASS
PEER CREATE PARTIAL / UX DEFECT
SHARE URI GENERATION PASS
EXTERNAL GECKO CLIENT E2E DEFERRED
EXTERNAL SALAMANDER E2E DEFERRED
FINAL v1.0.0 NOT YET ACCEPTED
```
`v1.0.0-rc1` сохраняется как успешно прошедший server/install RC.
Что требуется до финального `v1.0.0` — см.
[перечень дефектов и план закрытия](2026-09-01-v1.0.0-rc1-ux-findings.md).
---
## 14. Замечания по шуму в логах прогона
Две записи в журнале прогона к HY2XS отношения не имеют:
* `ystemctl` — опечатка в shell;
* пустой `journalctl -u hysteria-server --since '-2 min'` — ожидаемо, поскольку
реального внешнего клиента в этот момент не подключали.
@@ -0,0 +1,285 @@
# 1.0.0-rc1 — дефекты приёмки и их закрытие
Относится к прогону
[2026-09-01, `v1.0.0-rc1`](2026-09-01-v1.0.0-rc1-host-acceptance.md).
Ни один из перечисленных дефектов не является P0 safety blocker и не
дискредитирует пройденную server acceptance. Все они заметно ухудшают работу
оператора и закрыты до финального `v1.0.0`.
Раздел «Найдено сверх отчёта» описывает дефекты того же класса, обнаруженные
при разборе корневых причин: искали причину одного отказа — нашли механизм,
порождавший несколько.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| UX-01 | Некорректный цвет SVG-иконок | P1 | закрыт |
| UX-02 | Необязательный секрет пира фактически обязателен | P1 | закрыт |
| UX-03 | Сообщение `Invalid` неинформативно | P1 | закрыт |
| UX-04 | Плейсхолдеры слишком персонализированы | P2 | закрыт |
| UX-05 | Нет атрибуции Flamy в боковом меню | P1 | закрыт |
| EX-01 | Фильтр списка пиров ломается после очистки | P1 | закрыт |
| EX-02 | Правила имени пира противоречили друг другу | P1 | закрыт |
| EX-03 | Набор символов имени пира допускал `/ : ; . ,` | P1 | закрыт |
| EX-04 | Истечение сессии не обрабатывалось | P1 | закрыт |
| EX-05 | `id` требовался и в пути, и в теле запроса | P2 | закрыт |
| EX-06 | Обработчик транспортных ошибок падал сам | P2 | закрыт |
---
## UX-01 — некорректный цвет SVG-иконок
**Наблюдалось:** иконки логина и бокового меню отображались почти чёрными и не
соответствовали теме.
**Корневая причина.** Контракт `currentColor` в панели УЖЕ существовал —
`fill: currentcolor` объявлен и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`.
Он не действовал, потому что восемь из семнадцати ассетов несли литеральный
атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления перебивает
унаследованное CSS-свойство. Под это попали ВСЕ семь иконок бокового меню
(`report`, `users`, `hysteria`, `setting`, `error`, `log-system`,
`log-hysteria`) на фоне `--menuBg: #181818`, а также `user` на форме входа.
Соседняя `password` литерального цвета не несёт и рисовалась белой — отсюда и
ощущение, что иконки не соответствуют друг другу.
Ни одна существующая проверка этого не видела: гейт приёмки проверял у ассетов
только наличие системы координат.
**Как закрыто.**
1. Литеральный цвет убран из монохромных ассетов: они несут `fill="currentColor"`.
2. Многоцветные ассеты (`download`, `upload`) объявлены явным списком
`MULTICOLOR_ICONS` и под проверку цвета не попадают — их палитра является
частью ассета.
3. Преобразование файла в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`: без Vite и DOM, поэтому проверяются тестом и гейтом, а
не только глазами на живой странице.
4. У `SvgIcon` убран проп `color` и атрибут `fill` на `<use>` — он приглашал
чинить цвет точечно в обход общего контракта.
5. Цвета в рантайме НЕ переписываются: источник истины — файл. Молчаливая
нормализация скрывала бы ровно тот дефект, который контракт обязан делать
видимым.
**Чем закреплено:** `tools/test/frontend-sprite.test.ts` (контракт всех
ассетов, обе ветки нормализации, наличие обеих половин контракта — ассета и
CSS, запрет CSS-фильтров и селекторов по имени иконки) и соответствующие гейты
приёмки в `tools/build/lib/acceptance.sh`.
**Что проверяется вручную** (машина этого не докажет): фактический цвет на
светлой и тёмной теме, в состояниях hover и active, в свёрнутом меню.
---
## UX-02 — необязательный секрет фактически обязателен
**Наблюдалось:** подпись под полем обещает «оставьте пустым — сгенерируем
автоматически», пустое поле блокирует создание пира и выдаёт `Invalid`.
**Корневая причина.** Не отсутствие автогенерации: `service.CreatePeer` умел
генерировать секрет и делал это. Запрос до неё не доходил.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil. Помощник `hasValue` (`baked_in.go`):
```go
if fl.(*validate).fldIsPointer && getValue(field) != nil {
return true
}
```
Для `*string`, указывающего на пустую строку, это возвращает «значение есть».
Панель отправляет `secret: ""`, правило `min=6` применяется к пустой строке и
отказывает.
**Как закрыто.** Не тегом на одном поле, а механизмом: между разбором тела и
проверкой правил добавлен шаг нормализации DTO (`dto.Normalizable`). Он
приводит «поле отсутствует», «пустая строка» и «одни пробелы» к одному
состоянию для тех полей, где отсутствие значения законно.
Граница проходит по каждому полю ОТДЕЛЬНО и это существенно: у `remark` пустая
строка означает «убрать пометку», у `disabled` ноль означает «включён», у
`quotaBytes` ноль — нулевую квоту. Общее правило «пусто → не задано» молча
сломало бы все три.
Генерация названа явным шагом сервисного слоя — `service.GeneratePeerSecret` на
базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот
же вызов используется импортом: пир, созданный формой, и пир, импортированный
без секрета, теперь неотличимы.
**Чем закреплено:** матрица «отсутствует / пусто / пробелы / перевод строки →
генерируется», границы `5 → отказ, 6 → приём, 128 → приём, 129 → отказ`,
неповторяемость сгенерированных секретов и — главное — проверка того, что
сгенерированный секрет НЕМЕДЛЕННО аутентифицирует пира через
`service.Hysteria2Auth`. То, что секрет записан, ничего не значит, пока по нему
не проходит доступ.
---
## UX-03 — сообщение `Invalid` неинформативно
**Корневая причина.** `validateField` схлопывал любую ошибку любого поля в
`constant.InvalidError = "invalid"`, а `vo.Fail` определял HTTP-семантику
СРАВНЕНИЕМ текста сообщения с тремя известными литералами — тот же антипаттерн,
который запрещён панели, только на сервере.
**Как закрыто.**
* Ответ об ошибке несёт `errors: [{code, field, message, params}]`.
* Отказ разбора тела (`body_invalid`) отделён от нарушения правила.
* Коды правил различают границы числа и границы длины строки
(`min` / `min_length`): оператору это разные фразы.
* Доменные отказы получили коды: `peer_name_taken`, `peer_name_reserved`,
`peer_bootstrap_identity_locked`, `invalid_credentials`.
* `vo` больше не выводит код из текста — код передаётся аргументом.
* Панель выбирает локализованную фразу ПО КОДУ и подставляет причины под
соответствующие поля формы; текст сервера остаётся ответом для клиента без UI
и запасным вариантом для неизвестного кода.
* Числа правил приходят в `params`, поэтому второй копии границ в панели нет.
Отдельно: отказ входа кодируется как `invalid_credentials`, но НЕ уточняется —
«такого администратора нет» и «пароль не тот» остаются неразличимы снаружи,
иначе форма входа становится способом проверять существование имён. Отказ базы
при этом остаётся системной ошибкой: выдавать «неверный логин или пароль» при
недоступной SQLite значит отправить оператора искать несуществующую опечатку.
---
## UX-04 — плейсхолдеры слишком персонализированы
Заменено на нейтральный компактный baseline:
| Поле | Было | Стало |
| --- | --- | --- |
| Имя | `например, ivan-laptop` | `client-01` |
| Комментарий | `например, Ноутбук Ивана, отдел продаж` | `ноутбук` |
Префикс «например,» убран: плейсхолдер и так является примером. Подсказки под
полями остались подробными; подсказка имени теперь называет фактические границы
(6-32 символа).
---
## UX-05 — атрибуция Flamy в боковом меню
Внизу бокового меню добавлена подпись «Разработано во **Flamy**», где `Flamy`
ссылка на `https://flamy.studio` фирменным цветом, с
`target="_blank"` и `rel="noopener noreferrer"`.
Адрес объявлен ОДИН раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни
из таблицы `config`, ни из настроек панели. Оператор HY2XS не должен иметь
возможности переназначить, куда ведёт подпись разработчика.
Вёрстка: высота области прокрутки меню вычитает `$sidebarFooterHeight`, поэтому
пункты меню не могут наехать на подпись даже при длинном списке — им физически
некуда. В свёрнутом меню (54 px) остаётся только имя-ссылка; на узком экране
меню уходит в off-canvas на полную ширину.
**Чем закреплено:** `tools/test/frontend-contract.test.ts` — единственность
адреса в исходниках панели, отсутствие его в операторских поверхностях, наличие
футера в меню, учёт его высоты, атрибуты безопасности внешней ссылки. Плюс
гейты приёмки.
---
# Найдено сверх отчёта
## EX-01 — фильтр списка пиров ломался после очистки
`el-input` с крестиком очистки ставит пустую строку, axios сериализует её как
`?name=`, и та же ловушка `omitempty` на указателе (см. UX-02) отказывала
поиску пиров с `invalid`. Список пиров ломался в один клик по крестику.
Закрыто тем же механизмом нормализации; закреплено регрессионным тестом.
## EX-02 — правила имени пира противоречили друг другу
На поле стояли `min=1,max=32` И `validateStr`, требовавший 6-32 символа. Имя из
трёх символов проходило одно правило и отказывалось на другом, а оператор видел
`invalid` рядом с подсказкой «короткий идентификатор пира».
Длина перенесена внутрь одного правила `peerName`. Действующая граница — 6-32,
то есть та, которая уже была задокументирована и закреплена тестами импорта.
## EX-03 — набор символов имени пира допускал `/ : ; . ,`
Слой контроллеров нёс собственную копию правила:
```text
^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
```
с комментарием «тот же набор символов, что и у импорта». Набор был другим:
дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
`, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое импорт
того же пира отклонял, — при том что имя пира уезжает во fragment клиентской
ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора при этом сознательно НЕ сужен: он
записан явно, но повторяет прежнее фактическое множество. Имя администратора
приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение
правила означало бы, что установка с логином вроде `admin.ops` перестаёт
пускать оператора в панель. Это закреплено отдельным тестом, чтобы попытка
«навести порядок» роняла сборку, а не вход на живом сервере.
## EX-04 — истечение сессии не обрабатывалось
Ветка «сессия истекла, войдите заново» была недостижима в двух местах сразу.
Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для
отказов API не вызывался вовсе — а ветка сессии жила именно там. Условие в ней
проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было.
Ключ локализации `common.sessionExpired` существовал и был мёртвым.
Вдобавок истёкший токен уезжал с кодом системной ошибки: `vo` не узнавал
`token expired` среди трёх известных строк.
Закрыто: `ParseToken` возвращает объявленные значения ошибок вместо свежих
строк, middleware различает истечение и недействительность через `errors.Is`,
ответ несёт код `session_expired`, панель показывает диалог и возвращает на
форму входа. Диалог показывается один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
Побочно: сброс сессии больше не зовёт `localStorage.clear()`, который заодно
стирал выбранный оператором язык панели.
## EX-05 — `id` требовался и в пути, и в теле
`PeerUpdateDto` встраивал `IdDto` с правилом `required`, поэтому тело запроса
обязано было повторять идентификатор из адреса. Панель его повторяла, поэтому
расхождение не проявлялось; любой другой клиент, сделавший `PATCH /peers/7` без
`"id": 7` в теле, получал отказ — при том что значение из тела всё равно
затирается значением из пути.
Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор
из тела: она вызывала разбор тела, которое обработчик читает следом второй раз,
а gin его не буферизует. То есть запасной путь не сработал бы ровно тогда,
когда понадобился бы.
## EX-06 — обработчик транспортных ошибок падал сам
Обработчик читал `error.response.data`, не проверив `error.response`. При
обрыве соединения или таймауте он падал с `TypeError` и подменял настоящую
причину отказом внутри себя. Теперь сетевой отказ отличается от отказа сервера
и сообщается отдельной фразой.
---
## Что осталось сделать до финального v1.0.0
1. пересобрать `rc2` и повторить build/security acceptance;
2. установить `rc2` на тестовый хост;
3. проверить визуально: цвет иконок в обеих темах, hover/active, свёрнутое меню,
подпись Flamy на узком экране;
4. проверить создание пира с пустым секретом и работоспособность его share URI
на живом сервере;
5. выполнить reconfigure/fault matrix D1-D1h на `rc2`;
6. выполнить reboot acceptance;
7. отдельно проверить Salamander fallback;
8. после готовности HY2XS Desktop — внешний Gecko E2E;
9. удалить или пересоздать тестовый пир, чья URI была выведена за пределы
admin UI во время прогона `rc1`.
+33
View File
@@ -0,0 +1,33 @@
# Реестр прогонов приёмки
Здесь лежат отчёты о ФАКТИЧЕСКИХ прогонах приёмки: какая сборка, на каком
хосте, что прошло и что нет. Описание самих проверок — в
[docs/testing/](../testing/README.md).
Разделение намеренное. Документ проверок переживает релизы и правится по мере
развития продукта; отчёт о прогоне относится к одному артефакту и одному хосту
и после публикации релиза не редактируется — иначе он перестаёт быть
свидетельством.
## Правила ведения
1. Один прогон — один файл `ГГГГ-ММ-ДД-<версия>-<вид>.md`.
2. Отчёт фиксирует только то, что действительно выполнялось. Отложенная
проверка отмечается как `DEFERRED` с причиной, а не опускается.
3. Реальные секреты, ключи и клиентские ссылки в отчёт не переносятся.
Публичные IP-адреса тестовых хостов обезличиваются; доменное имя и номер
порта SSH остаются, потому что без них шаги прогона невоспроизводимы.
4. Найденные дефекты живут в отдельном файле рядом с отчётом и закрываются
ссылками на коммиты, а не правкой самого отчёта.
## Прогоны
| Дата | Версия | Коммит источника | Вид | Вердикт |
| --- | --- | --- | --- | --- |
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
## Открытые дефекты приёмки
| Прогон | Дефекты |
| --- | --- |
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
@@ -1,5 +1,10 @@
# Admin panel: HY2XS admin # Admin panel: HY2XS admin
> Контракты панели, закреплённые тестами и релизными гейтами — отрисовка
> иконок, структурированные ошибки, необязательные поля и генерация секретов,
> атрибуция — вынесены в отдельный документ:
> [15-ui-contracts.md](15-ui-contracts.md).
## Цель документа ## Цель документа
Зафиксировать модель работы с admin-панелью: HY2XS admin является **штатным компонентом HY2XS**, а не внешней зависимостью, которую target server где-то добывает во время установки. Зафиксировать модель работы с admin-панелью: HY2XS admin является **штатным компонентом HY2XS**, а не внешней зависимостью, которую target server где-то добывает во время установки.
@@ -79,7 +84,7 @@ ingress/reverse-proxy, а не возвращаться к модели «пан
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках
их словаря нет. Всё, что нужно для распознавания и удаления старой установки, — их словаря нет. Всё, что нужно для распознавания и удаления старой установки, —
в [14-legacy-cleanup.md](14-legacy-cleanup.md). в [14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
## Пространства имён HTTP API ## Пространства имён HTTP API
+142
View File
@@ -0,0 +1,142 @@
# Контракты панели
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
потому ломались молча. Каждое из них закреплено тестом
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
Общее описание панели — [04-admin-panel.md](04-admin-panel.md).
---
## 1. Отрисовка иконок
**Правило.** Монохромная UI-иконка получает цвет ровно одним способом —
наследованием `currentColor` от компонента и темы. Ассет не содержит
литеральных цветов; цвет объявляется в CSS один раз, на `.svg-icon`.
**Запрещено:**
* литеральный `fill` / `stroke` / `stop-color` в монохромном ассете;
* цвет в инлайновом `style` внутри ассета;
* непустой `<style>` внутри ассета — его селекторы глобальны и красят чужие
иконки;
* растровое `<image>` — оно не подчиняется `currentColor` никогда;
* CSS-фильтр на `.svg-icon`;
* селектор по имени конкретной иконки (`[icon-class="…"]`);
* передача цвета параметром компонента.
**Многоцветные ассеты** объявляются явным списком `MULTICOLOR_ICONS` в
`SvgIcon/symbol.ts`. Их палитра — часть ассета, и проверка цвета к ним не
применяется. «Многоцветность» обязана быть решением, а не следствием того, что
иконку скачали с готовыми значениями `fill`.
**Система координат.** У каждого ассета обязан быть `viewBox` либо пара
`width`/`height`, из которой он синтезируется. Без неё `<use>` рисует иконку в
натуральную величину и обрезает её по размеру родительского `<svg>`.
**Почему цвета не переписываются в рантайме.** Источник истины — файл. Молчаливая
нормализация при сборке спрайта скрывала бы ровно тот дефект, который контракт
обязан делать видимым: добавленная с чёрным `fill` иконка выглядела бы
правильно и оставалась бы сломанной в исходниках.
**Что машина не докажет.** Фактический цвет на экране. Визуальная проверка
светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной
и фиксируется в отчёте приёмки.
---
## 2. Структурированные ошибки
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
— ещё и имя поля.
Форма ответа:
```json
{
"code": 50001,
"type": "no",
"message": "проверка данных не пройдена",
"errors": [
{ "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
],
"data": null
}
```
* `code` — числовой код ответа (`model/constant/code.go`);
* `errors[].code` — причина (`model/constant/error.go`, `ErrCode*`);
* `errors[].field` — имя поля из JSON-тега; пусто для отказов уровня операции;
* `errors[].params` — числа правила, чтобы панель не заводила их вторую копию;
* `message` — человекочитаемый ответ для клиента без UI и запасной вариант для
кода, которого панель ещё не знает.
Границы числа и границы длины строки различаются кодом (`min` против
`min_length`), хотя тег валидатора у них один: оператору это разные фразы.
**Запрещено:**
* выводить код ответа сравнением текста сообщения;
* отдавать обобщённое `invalid` вместо описания полей;
* разбирать сообщение сервера на стороне панели.
**Локализация** строится по ключу `error.code.<код>` с параметрами правила.
Наборы ключей `ru` и `en` обязаны совпадать: забытый ключ не ломает ни типы, ни
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
вместо фразы.
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
показывает диалог и возвращает на форму входа.
**Вход администратора.** Неверные учётные данные всегда дают один код
`invalid_credentials`: «такого администратора нет» и «пароль не тот» обязаны
быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой —
выдавать «неверный логин или пароль» при недоступной базе значит отправить
оператора искать несуществующую опечатку.
---
## 3. Необязательные поля и генерация секретов
**Правило.** Поле, объявленное необязательным, обязано принимать три
неразличимых состояния: отсутствует, пустая строка, одни пробелы.
Это НЕ следует автоматически из тега `omitempty`. В `go-playground/validator`
он не пропускает правило, если поле объявлено указателем и указатель не nil —
`hasValue` считает указатель на пустую строку «значением». Поэтому DTO,
у которых есть необязательные строковые поля, реализуют `dto.Normalizable`, и
слой контроллеров вызывает `Normalize()` между разбором тела и проверкой
правил.
**Граница проходит по каждому полю отдельно.** Общее правило «пусто → не
задано» молча ломает смысл:
| Поле | Пустое значение означает |
| --- | --- |
| `secret` при создании | сгенерировать |
| `secret` при изменении | не менять |
| `name` при изменении | не менять |
| `remark` | убрать пометку |
| `disabled = 0` | включён |
| `quotaBytes = 0` | нулевая квота |
**Генерация секрета принадлежит серверу.** Панель, подставляющая значение в
пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной
двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты.
Единственная реализация — `service.GeneratePeerSecret`, на базе
`util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот же
вызов используется импортом.
**Имя пира** проверяется одним правилом `service.IsValidPeerName` на весь
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
---
## 4. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.
@@ -28,6 +28,25 @@ Production builder работает на отдельном build host:
Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step. Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step.
### Память build-хоста
Самый требовательный шаг сборки — не компиляция, а `govulncheck`: он строит
граф достижимости по всему модулю вместе со stdlib.
На прогоне `v1.0.0-rc1` машина с ~1.9 GiB RAM и **нулевым swap** получила
`govulncheck`, убитый Linux OOM killer. После подключения временного swap 4 GiB
полный security gate прошёл.
4 GiB swap — не формально доказанный минимум, а подтверждённая рабочая
конфигурация того прогона; см.
[отчёт приёмки](../acceptance/2026-09-01-v1.0.0-rc1-host-acceptance.md).
Практическое следствие: сборочная машина примерно с 2 GiB RAM без swap может
оказаться недостаточной, и отказ выглядит как убитый процесс, а не как
внятная ошибка инструмента.
Это требование к сборочной машине, а не к target-серверу: `govulncheck` в
runtime-артефакт не попадает.
## Что хранится в репозитории проекта ## Что хранится в репозитории проекта
Минимум: Минимум:
@@ -138,7 +157,7 @@ bundle из кода, который не проходит проверку ти
Закрыто это не подавлением, а границей: `api/config/hysteriaViewModel.ts` Закрыто это не подавлением, а границей: `api/config/hysteriaViewModel.ts`
превращает ответ сервера в модель, где присутствие каждой секции — свойство превращает ответ сервера в модель, где присутствие каждой секции — свойство
типа. Подробности — в [docs/04](04-admin-panel.md). типа. Подробности — в [docs/04](../admin/04-admin-panel.md).
Контракт теперь читается так: Контракт теперь читается так:
@@ -41,7 +41,7 @@ HY2XS v1 не поддерживает установку поверх и не
- /etc/hysteria/post-install.env (post-install.env предыдущей установки HY2XS) - /etc/hysteria/post-install.env (post-install.env предыдущей установки HY2XS)
- hy2xs-admin.service (systemd-юнит админки HY2XS) - hy2xs-admin.service (systemd-юнит админки HY2XS)
Очистите сервер и установите HY2XS заново: см. docs/14-legacy-cleanup.md Очистите сервер и установите HY2XS заново: см. docs/operations/14-legacy-cleanup.md
``` ```
Если вы видите этот текст — сервер в том же состоянии, в котором был до запуска. Если вы видите этот текст — сервер в том же состоянии, в котором был до запуска.
@@ -120,7 +120,7 @@ clean-host. Ко второму вызову на диске лежал собс
пути, которые раньше приходилось исключать, теперь создаются после проверки. пути, которые раньше приходилось исключать, теперь создаются после проверки.
Полный список маркеров чужой установки и порядок очистки — Полный список маркеров чужой установки и порядок очистки —
[14-legacy-cleanup.md](14-legacy-cleanup.md). [14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
### Раннеры подпроцессов: два набора, а не один ### Раннеры подпроцессов: два набора, а не один
+49
View File
@@ -0,0 +1,49 @@
# Как запускать тесты
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
## Цель набора
Зафиксировать checklist для двухслойной схемы «builder layer + runtime/target layer».
## Команды
```bash
# Юнит-тесты и типы оркестратора
cd orchestrator && bun install --frozen-lockfile && bun run check && bun test
# Тесты и статический анализ HY2XS admin
cd apps && go vet ./... && go test ./...
# Проверка типов и сборка frontend
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
# Контракты панели: спрайт иконок, словари локализации, коды ошибок, атрибуция
bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts
# Сверка среды разработки с versions.env (ничего не меняет)
./tools/dev/doctor.sh
# Полный E2E с реальным клиентом Hysteria (Debian 13 amd64; нужен Go)
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
# Production-сборка: прогоняет тесты, резолвер и compatibility gate
./tools/build/build.sh
```
`build.sh` останавливается, если падают тесты оркестратора, контрактные тесты
панели, тесты админки или compatibility gate.
## Почему контракты панели проверяет Bun, а не vitest
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
выполнить вне браузера уже закреплённым в `versions.env` Bun.
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
ВСЕМУ lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
дополнительной гарантии.
Визуальная часть остаётся ручной и фиксируется в отчёте приёмки, см.
[docs/acceptance/README.md](../acceptance/README.md).
+714
View File
@@ -0,0 +1,714 @@
# 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/<op_id>` из runbook не существовал;
- команда взведения guard проверяется как **значение**, а не грепом по
исходнику: `buildArmGuardArgv` возвращает готовый argv, и тест сверяет его
целиком — имя юнита с явным суффиксом, `--on-active=45s`,
`--timer-property=AccuracySec=1s`, `--timer-property=RemainAfterElapse=no`.
Небезопасный ключ операции отвергается до запуска: shell в этой команде не
участвует, поэтому единственная защита — отказ;
- барьер покоя проверяется **поведенчески**, с подставляемым наблюдателем
systemd (`SystemdUnitProbe`), без systemd и без Linux:
- взведённый таймер (`active/waiting`) и идущий прямо сейчас откат
(`activating`) запрещают операцию с `PendingRecoveryError`;
- отказ `list-units` **и** отказ `show` на любом отдельном юните дают
`GuardStateUnknownError`: отсутствие ответа systemd — отсутствие
наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное
(`return [];` в тексте функции) и потому пережил инверсию смысла: строка
была на месте, а решение стало неверным;
- оба отказа имеют общего предка `OperationBarrierError`;
- покой — ровно `inactive` и `failed`; `maintenance`, `refreshing` и любое
незнакомое состояние блокируют операцию, потому что политика перечисляет
безопасные состояния, а не опасные;
- `*.timer` в `SubState=elapsed` считается покоем: `TIMER_ELAPSED` в systemd
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер
блокировал бы `repair` навсегда. У сервиса тот же `SubState` ничего не
значит;
- разбор вывода `list-units` находит имя юнита и когда первой колонкой идёт
маркер `●` (состояние `failed`) — иначе терялся бы именно аварийно
сработавший guard.
## A5f. Взаимное исключение операций (unit)
`orchestrator/test/operation-lock.test.ts`:
- второй захват при живом держателе отказывает, и отказ называет держателя —
команду, PID и время начала;
- замок снимается в `finally` и после отказа операции: иначе первая же неудачная
установка заблокировала бы сервер до перезагрузки;
- замок мёртвого держателя переиспользуется, временный файл переиспользования не
остаётся на диске;
- непонятое содержимое замка **не** снимается автоматически: оно не доказывает
отсутствие операции, и сомнение трактуется в пользу отказа;
- `readLockHolder` отличает «замка нет» от «замок нечитаем»;
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
до включения guard, и проверка существует, чтобы перенос захвата внутрь
читающей фазы отказал громко, а не записал файл молча;
- барьер покоя вызывается **дважды** — до захвата и уже под замком, — а отказ
второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт:
он защищает production paths, пока жив держатель, а rollback guard переживает
свой процесс;
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
об идущей операции, а `preflight-install` отказывает до собственных проверок.
## A5b. Долговечная запись маркера (unit)
`orchestrator/test/atomic-write.test.ts`:
- содержимое заменяется целиком, а не дописывается поверх прежнего;
- при отказе записи по целевому пути остаётся **прежний полный** документ;
- временный файл не выживает ни при успехе, ни при отказе подстановки;
- права выставляются точно, независимо от umask (`0600`, `0644`);
- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir`
этого не делает, поэтому «создать» и «права такие, как объявлено» — два
разных действия;
- и запись, и создание каталога проходят через read-only guard;
- `persistInstallState` под guard'ом отказывает: единственный писатель маркера
обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
файла, по которому clean-host принимает решение.
- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель
синхронизируется только при создании, иначе долговечность записи `hy2xs` в
`/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть
весь каталог вместе с маркером.
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них `rename()` даёт
атомарность видимости без долговечности.
## A6. Редактирование секретов (unit)
`orchestrator/test/redaction.test.ts`:
- machine token не переживает редакцию серверного конфига — регрессия на
построчное правило `auth:`, оставлявшее нетронутым `auth.http.url`;
- obfs-пароль не переживает редакцию;
- результат остаётся валидным YAML;
- несекретные поля сохраняются: диагностика должна оставаться полезной;
- неизвестное поле с секретоподобным именем вырезается;
- `acme.dns.config` вырезается целиком;
- невалидный YAML не роняет редакцию и всё равно чистится;
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
(`HY2_AUTH_URL`);
- URL под **произвольным** именем ключа теряет встроенные учётные данные и
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
- `redactLogText` вырезает machine token из строки journald, сохраняя host,
port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
редактировались env и YAML, а `journal-admin.log` копировался как есть.
## A7. Machine token в журналах (unit)
`apps/middleware/log_test.go` — запрос
`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`:
- sentinel **не появляется** в журнале ни в каком виде;
- в журнале есть `reqPath`, поля `reqUri` нет;
- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет;
- пустой список параметров в журнал не пишется;
- то же правило действует на операторских маршрутах, а не только на машинном.
`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал
Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт
`access_token`, который upstream волен упомянуть в сообщении об ошибке.
## A8. Инвариант публичного endpoint (unit)
`orchestrator/test/network-endpoint.test.ts` — проба подменяет и DNS, и список
локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины
разработчика.
| Сценарий | Результат |
| --- | --- |
| A-запись == текущий публичный IPv4 | PASS |
| A-запись == старый IPv4 | FAIL, в тексте оба адреса |
| A-запись отсутствует | FAIL |
| A == текущий + чужой | FAIL |
| у сервера 2 публичных IP, DNS использует один | PASS |
| `PUBLIC_HOST` — правильный IPv4-литерал | PASS |
| `PUBLIC_HOST` — устаревший IPv4-литерал | FAIL |
| `DOMAIN` совпадает, отдельный `PUBLIC_HOST` устарел | FAIL |
| `PUBLIC_HOST` совпадает, отдельный TLS-домен устарел | FAIL |
| нет ни одного локального публичного IPv4 | FAIL |
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
| отсутствие A-записи при любой политике | FAIL |
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные**
диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются
публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются
публичными.
Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN`
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как
«has no A-record», и при сломанном резолвере оператор шёл править запись,
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
а не «выполнена с замечанием».
## A9. Регистрация маршрутов (unit)
`apps/router/router_test.go` — единственное место, где ошибка проявляется
**паникой при старте сервиса**, а не ответом с кодом. Конфликт с
wildcard-маршрутом фронтенда или дублирующая регистрация обнаружились бы иначе
только на живом сервере.
- контур маршрутов собирается без паники;
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
- операторский и auth API — под `constant.AdminAPIBase`;
- ни один маршрут не начинается со старого пространства имён;
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
вернулись;
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
новый обязан быть добавлен в тест осознанно;
- `/healthz` на месте.
## A9a. Доступ к таблице `config` (unit)
`apps/model/constant/config_test.go` — allowlist как структура, а не как
соглашение:
- ни один внутренний ключ не читается и не записывается через API;
- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и
`HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними;
- пользовательские настройки остаются доступными;
- множество записываемых ключей — подмножество читаемых;
- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы
сразу публичным.
`apps/controller/config_test.go` — то же на уровне HTTP:
- чтение и запись каждого секрета отклоняются;
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
что обработчик не падает, это и доказывает);
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`;
- удалённые ключи (`HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`,
`HYSTERIA2_TRAFFIC_TIME`, `HYSTERIA2_CONFIG_REMARK`) отклоняются как
неизвестные — проверка идёт по строковым литералам, потому что соответствующих
констант в коде уже нет и появиться они не должны.
Атомарность партии проверяется на **настоящей** SQLite: без базы утверждение
«партия не применилась частично» бессмысленно, поскольку предметом утверждения
является именно состояние базы.
- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение
первого ключа в базе **не изменилось**;
- невалидное cron-выражение → отказ, значение в базе не изменилось;
- один ключ дважды в партии → отказ (какое из двух значений считать намерением
оператора, определить нельзя);
- корректная партия → значение сохранено, расписание применено к планировщику,
число его записей не выросло.
Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ
**первым** и до второго элемента не доходила, поэтому проходила и на реализации,
которая проверяла и записывала настройки в одном цикле.
## A9b. Планировщик (unit)
`apps/service/cron_scheduler_test.go` — планировщик как собственность процесса:
- четыре последовательные смены расписания **не увеличивают** число записей
планировщика (главная регрессия: раньше каждая смена добавляла целый
дублирующий набор джоб, а старое расписание продолжало работать);
- пустое выражение снимает джобу сброса, непустое возвращает её — без
перезапуска процесса;
- невалидное выражение не меняет планировщик и не снимает действующую джобу;
- набор валидных и невалидных выражений проверяется тем же парсером, что и
runtime: то, что `cron` умеет, обязано приниматься, остальное — отклоняться;
- невалидное значение в базе **не роняет старт**: на панели висит
`/internal/hysteria/auth`, и отказ старта из-за строки расписания положил бы
подключения пользователей. Фиксированные джобы поднимаются, сброс отключён,
в журнале ERROR, и настройка чинится через API без перезапуска;
- второй `InitCron` поверх работающего отклоняется;
- `StopCron` идемпотентен и оставляет планировщик пустым.
## A9c. Токены и пароли (unit)
`apps/service/jwt_test.go`:
- round-trip: claims, включая `token_version`, переживают выписку и разбор;
- токен, подписанный **другим** HMAC-алгоритмом тем же ключом, отклоняется
(прежний `keyfunc` не смотрел на `token.Method` вовсе);
- токен без `exp` отклоняется, истёкший отклоняется отдельным сообщением;
- токен с чужим `issuer` отклоняется даже при совпадении ключа;
- пустой `JWT_SECRET` — отказ и на выписку, и на разбор, а не подпись ключом
нулевой длины.
`apps/util/encrypt_test.go`:
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
- любая не-bcrypt строка в поле хеша отклоняется.
`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` отклоняются;
- корректный одиночный документ доходит до базы и создаёт пира.
## A7. Контракт версий (build)
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
создания tarball при рассинхроне `versions.env` с `PACKAGE_VERSION`,
`packageManager` обоих `package.json`, схемой в `package/config/hy2xs.env`,
константами, скомпилированными в оркестратор, директивой `go` в `apps/go.mod`,
metadata пакета и версией, которую сообщает собранный `hy2xs-admin`.
Разбор upstream `hashes.txt` покрыт `orchestrator/test/hysteria-release.test.ts`:
реальный формат релиза, отсутствие путаницы `hysteria-linux-amd64` с
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
противоречивые записи, отсутствие нужной строки.
## A11. Проверка зависимостей на уязвимости (build)
`tools/build/lib/security.sh` — обязательный шаг между тестами админки и записью
metadata. Подробности в [docs/02](../build/02-build-layer-and-package.md); здесь важно
поведение при отказе:
| Код `govulncheck` | Трактовка |
| --- | --- |
| `0` | чисто |
| `3` | найдены **вызываемые** уязвимости → сборка падает |
| иное | отказ самого инструмента → сборка падает отдельным сообщением |
Последняя строка существенна: ненулевой код неизвестной природы нельзя
трактовать как «уязвимостей нет». По той же причине недоступность реестра npm
для `pnpm audit` — это отказ проверки, а не её отрицательный результат.
`pnpm audit` проверяет **весь** lock-граф frontend, а не production-подграф:
build tooling исполняется на build-машине и порождает production-бандл, поэтому
уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы `--prod` не
вернулся в гейт.
## A11a. Обязательные тесты (build)
Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет,
результат виден по готовому артефакту.
| Шаг сборки | Что запускается |
| --- | --- |
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
| `bundle_ui` | `pnpm run typecheck` до сборки bundle |
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
Приёмка проверяет:
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
(место для истории — `CHANGELOG.md`);
- `metadata/package.env` содержит `tests_gate=true`;
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
- `write_metadata` отказывается писать метаданные, если хотя бы один из двух
прогонов не подтверждён.
## A12. Приёмка проверяет код, а не упоминания
Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на
корректном коде**, и оба — по одной причине: они искали подстроку там, где
подстрока обязана присутствовать.
| Проверка | Что ловила на самом деле |
| --- | --- |
| `verify_api_namespace_contract` | `grep -rlF '/hui'` возвращал `apps/router/router_test.go` (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам `versions.sh`, где эта строка стоит в тексте проверки |
| «peer import не выходит за транзакцию» | `source.slice(start)` брал файл от начала `applyPeerImportEntry` **и до конца**, захватывая `ExistPeerName` и `UpdatePeerLastConnectionAt` — обычные операции вне импорта, которым глобальное соединение положено |
Первая падала на шаге versions contract — шестым из четырнадцати, до резолва
Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.
Отсюда правило и помощники `code_without_comments` / `code_mentions_in` в
`acceptance.sh`: проверка смотрит на код, а комментарий, объясняющий, почему
чего-то больше нет, обязан называть это по имени и не должен ломать сборку.
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом.
+174
View File
@@ -0,0 +1,174 @@
# B, C. Установка на target и runtime
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
## B. Target install tests
### На чистом Debian 13 проверяем
1. пакет запускается без ручной сборки на сервере
2. Hysteria2 скачивается с official upstream
3. bundled HY2XS admin раскладывается локально из пакета
4. создаются нужные каталоги
5. создаются systemd unit-файлы
6. создаются `hy2xs.env` и `post-install.env` с правами `0600 root:root`
7. baseline firewall применяется корректно через staged mode
8. SSH остаётся доступным
9. `reconfigure --dry-run` выводит план изменений
10. `reconfigure --apply` применяет изменения и проходит smoke
## C. Runtime tests
1. `hysteria-server` active
2. `hy2xs-admin` active
3. Hysteria слушает только IPv4 (`0.0.0.0:<udp_port>`)
4. HY2XS admin слушает ожидаемый `HY2XS_UI_BIND_HOST:<ui_port>`
5. тестовый совместимый клиент подключается
6. идёт реальный трафик
7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
8. reboot не ломает baseline
9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
10. нет IPv6 listen (`[::]`) для Hysteria/HY2XS admin
11. `trafficStats.secret` не равен `JWT_SECRET`
12. bootstrap admin secret существует и имеет `0600`
13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется
14. TLS mode в `config.yaml` соответствует runtime env (`acme|file|self_signed_dev`)
15. при `HY2XS_TLS_MODE=acme` в `config.yaml` выставлен `acme.type` из `HY2XS_ACME_TYPE`
16. direct `hysteria2://` node URL в API/QR формируется по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`; subscription delivery endpoint отключён в baseline и не входит в acceptance
17. `nft -c -f /etc/nftables.conf` проходит после apply
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
19. остановка/рестарт UI не останавливает `hysteria-server`
20. traffic accounting/kick ориентируются на systemd status; ключа `HYSTERIA2_ENABLE` в базе больше нет
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
23. смена расписания сброса трафика применяется **без** перезапуска `hy2xs-admin`, и число джоб планировщика не растёт
24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
25. `systemctl restart hy2xs-admin` завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет `database is closed`
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
## C1. Семантический smoke конфига
Недостаточно `grep` по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок.
Smoke разбирает `/etc/hysteria/config.yaml` и сверяет с production-профилем:
```text
effective Hysteria version == версия из metadata пакета
obfs:
type == HY2XS_HYSTERIA_OBFS_TYPE
ровно один подблок, соответствующий type
password непустой
для gecko: minPacketSize == 512, maxPacketSize == 1200
bandwidth:
up/down == runtime env
disableLossCompensation == false
congestion:
type == bbr
bbrProfile == standard
quic:
disableStatelessReset == false
окна, maxIncomingStreams, disablePathMTUDiscovery == baseline
maxIdleTimeout == 30s
trafficStats:
listen == runtime env
secret непустой
auth:
type == http
url == http://127.0.0.1:<UI_PORT>/internal/hysteria/auth?access_token=<machine token>
insecure == (tlsMode == self_signed_dev)
TLS:
acme-режим не содержит секции tls
acme: type/email/ca/dir/listenHost/первый домен == профиль
file-режим не содержит секции acme
верхний уровень:
нет секций вне production-профиля
```
`maxIdleTimeout` присутствовал в профиле, но не проверялся: конфиг с уехавшим
idle timeout проходил семантическую проверку. Точно так же `auth.http.url`
раньше сверялся только на наличие подстроки `access_token=`, из-за чего
уехавший порт или путь остались бы незамеченными — а это единственный канал
допуска пиров.
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл. Это закреплено отдельным тестом.
## C2. End-to-end с реальным клиентом
`tools/test/e2e-hysteria.sh`, отдельно для Gecko и Salamander:
1. сервер принимает сгенерированный конфиг и стартует;
2. TLS handshake;
3. handshake с обфускацией;
4. HTTP auth HY2XS: разрешённый пир принят;
5. HTTP auth HY2XS: неразрешённый пир отклонён;
6. клиент подключается **именно по ссылке, которую выдаёт production-код**;
7. TCP forwarding;
8. UDP forwarding;
9. `trafficStats` с валидным secret;
10. `trafficStats` с невалидным secret отклоняется;
11. per-peer accounting содержит аутентифицированного пира;
12. перезапуск сервера;
13. быстрое переподключение клиента (поведение stateless reset).
Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.
### Одна реализация URI, а не две
Ссылка берётся из production-генератора через `apps/tools/share-uri`, который
вызывает ту же `service.BuildHysteria2ShareURI`, что и панель.
Раньше внутри e2e жила **вторая** реализация URI на bash. Go-юнит-тесты
проверяли production-генератор, e2e проверял свою функцию — и дрейф любой из
них оставлял обе группы тестов зелёными.
Единственное расхождение с пользовательской ссылкой — `insecure=1`: e2e
работает на самоподписанном сертификате. Это расхождение ограничено с двух
сторон:
- e2e отдельно печатает и проверяет **production-вариант** ссылки
(`insecure=0`, корректные `obfs` и `sni`);
- Go-тест `TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam`
доказывает, что кроме этого параметра ссылки совпадают побайтово;
- Go-тест `TestBuildHysteria2Url_ProductionPathNeverDisablesVerification`
фиксирует, что production-путь никогда не передаёт `insecure=1`.
Для запуска e2e нужен Go (`GO_BIN`).
## C3. Share URI (unit)
`apps/service/hysteria2_api_test.go`:
- Gecko URI содержит `obfs=gecko` и `obfs-password`;
- Salamander URI содержит `obfs=salamander` и `obfs-password`;
- конфиг без обфускации даёт ссылку без `obfs`;
- неизвестный тип обфускации в ссылку не попадает;
- обфускация без пароля в ссылку не попадает;
- SNI: ACME-домен → `HY2XS_DOMAIN``HY2XS_PUBLIC_HOST`, IP не используется;
- спецсимволы в credentials и obfs-пароле переживают round-trip: `+`, пробел, `#`, `@`, `/`, `?`, `&`, `=`, `%`, кириллица;
- литеральный `+` кодируется как `%2B` и не схлопывается с пробелом (регрессия на upstream-баг 2.9.3).
## C4. Экспорт конфига (unit)
`apps/service/hysteria2_export_test.go`:
- неизвестные upstream-секции переживают экспорт целиком, включая вложенные карты и списки;
- операционные поля остаются читаемыми;
- вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound;
- вырезается **неизвестное** поле с секретным именем;
- URL под произвольным именем ключа (`endpoint:`) теряет учётные данные и
`access_token`, но сохраняет адрес; то же для URL внутри списка;
- не-URL скаляры (`50 mbps`, `0.0.0.0:443`, `10.0.0.1:1080`, `30s`, числа)
проходят санитайзер без изменений;
- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
граница определяется значением, а не именем ключа.
+386
View File
@@ -0,0 +1,386 @@
# D0-D2. Живой сервер и fault injection
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
## D0. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
1. `install.sh` завершается отказом на PHASE 0;
2. `/usr/local/lib/hy2xs` **не создан и не изменён**;
3. `/var/lib/hy2xs/install-state.json` не перезаписан;
4. `hysteria-server` и `hy2xs-admin` остались `active`;
5. в тексте отказа перечислены найденные маркеры и указан
`docs/operations/14-legacy-cleanup.md`;
6. после `tools/legacy/purge-v0.sh --apply --yes-i-know` установка проходит.
Пункты 2–4 — прямая регрессия: прежний установщик успевал переписать
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
выключал работающие службы старой установки.
## D1. Отказ сразу после успешной PHASE 0 (fault injection)
Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая
мутирующая операция упала» — место, где установщик раньше врал.
1. PHASE 0 проходит успешно;
2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий
или временно испорченный `/etc/apt/sources.list.d/`);
3. установка завершается отказом;
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
`phase: failed` с текстом ошибки;
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package`
всё, что операция действительно создала;
7. diagnostics-бандл собран;
8. `hy2xs-orchestrator status` не заявляет установку успешной.
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением.
**Тайминг здесь — часть сценария, и его легко испортить.**
`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
другой путь — обычный `fatal_post_apply` на ранней стадии.
Порядок строго такой:
1. запустить установку и дождаться в журнале `step=firewall status=done`;
2. **только теперь**, во втором терминале:
```bash
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
```
3. вызвать искусственный отказ следующего шага установки.
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
через отказ на более длинном шаге (`smoke`), где времени заметно больше:
дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из
сервисов, чтобы smoke не сошёлся.
Сценарий:
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
взведён, правила применены);
2. следующий шаг ломается искусственно;
3. запись `phase: failed` в маркер падает по `ENOSPC`;
4. в журнале есть `failed to persist failure state, continuing with the
mandatory rollback`;
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
развёрнутые этой операцией юниты останавливаются и выключаются;
6. SSH остаётся доступным;
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла **исходная** ошибка операции, а не `ENOSPC`.
До исправления шаги 4–6 давали противоположный результат: бросок из записи
состояния уносил управление наружу, и сервер оставался с применённым firewall
неудавшейся установки.
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
`chattr +i` между применением firewall и отказом), восстановление конфигов и
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
`rollback stage "…" failed, continuing with the remaining stages` и итоговое
`rollback finished with N failed stage(s)`.
## D1c. Данные отката переживают отказ фиксации успеха
Проверяется на чистом хосте. Это второй сценарий того же класса, но на
противоположном конце операции: отказывает не промежуточный шаг, а **запись
успеха**.
1. установка доходит до успешного `smoke`, в маркере появляется
`phase: smoke_ok`;
2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же
`tmpfs`, смонтированный по появлению `step=smoke checks status=done`);
3. запись `phase: installed` падает;
4. `/run/hy2xs/rollback/<op>/prepared` и обе резервные копии firewall **всё ещё
существуют** — это и есть проверяемое свойство;
5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему
содержимому, развёрнутые юниты останавливаются;
6. в журнале **нет** строки `no HY2XS rollback markers found`.
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому
откат запускался, но откатывать ему было нечем.
Обратная проверка — успешный путь: после нормально завершённой установки
`/run/hy2xs/rollback/` пуст, а `phase: installed` записан.
## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
Проверяется на рабочей установке.
1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или
заполненный `tmpfs`);
2. запускается `reconfigure --apply`;
3. операция отказывает на шаге `backup` с сообщением про несозданную копию;
4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не
изменены**, сервисы не перезапускались.
Отдельно проверяется привязка копии к операции: после успешного `reconfigure`
в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с
`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с
`"present": false`.
## D1e. Guard доходит до дедлайна — фиксация успеха запрещена
Проверяется на чистом хосте. Это сценарий гонки между автоматическим откатом
firewall и успешным smoke.
Окно guard — 45 секунд, и оно намеренно короче худшего случая smoke: на
медленном, но исправном сервере retry-бюджеты дают заметно больше. Раньше это
означало, что автоматический откат мог вернуть прежний firewall, пока smoke
продолжает идти, а единственной проверкой firewall в smoke был `nft -c` — разбор
текущего файла, каким бы он ни был. Прежний валидный ruleset проходил её
зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall.
Сценарий:
1. установка доходит до шага `firewall`, в журнале появляется
`firewall rollback guard armed: … fires in 45s (timer accuracy 1s)`.
Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно
обязано быть контрактом systemd, а не намерением:
```bash
systemctl show hy2xs-fw-rollback-<op-id>.timer \
-p ActiveState -p SubState -p AccuracyUSec -p RemainAfterElapse
```
Ожидается `ActiveState=active`, `SubState=waiting`, `AccuracyUSec=1s`,
`RemainAfterElapse=no`. Без явной точности systemd вправе сработать в окне
`[45s; 45s + AccuracySec]`, а умолчание `AccuracySec=` — одна минута, то
есть реальное окно было бы 45–105 секунд;
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
из сервисов — например, добавить в `hy2xs-admin.service` временный
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
установки;
3. guard срабатывает: в journal появляется юнит
`hy2xs-fw-rollback-<op-id>.service`, а на диске —
`/run/hy2xs/rollback/<op-id>/auto-rollback-fired`;
4. установка **обязана** завершиться отказом, даже если smoke успел сойтись;
5. в маркере установки стоит `phase: firewall_guard_fired`, а не
`installed`, и не `smoke_failed`;
6. `installed: true` не записан;
7. выполняется обычный откат операции: firewall возвращается к прежнему
состоянию, развёрнутые этой операцией юниты останавливаются;
8. SSH остаётся доступным.
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
Если на рабочей установке подменить `/etc/nftables.d/hy2xs.nft` на прежний
валидный ruleset и выполнить `hy2xs-orchestrator doctor`, диагностика обязана
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
`nft -c`.
## D1f. Конкурентная операция отказывает до первой мутации
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит
**до** снятия резервной копии и до первой мутации, а не в середине транзакции.
1. запускается длинный `reconfigure --apply` (например, с задержкой в
`ExecStartPre`, как в D1e);
2. во втором терминале, пока первый идёт, запускается второй
`reconfigure --apply`;
3. второй отказывает сразу, с текстом
`another HY2XS operation is already in progress: reconfigure (pid …)`;
4. `/etc/hy2xs/backups/` **не** пополнился каталогом второй операции;
5. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` изменены
ровно один раз — первой операцией;
6. `/run/hy2xs/rollback/` содержит каталог только первой операции.
Те же проверки для пар:
```text
install идёт -> doctor отказывает
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
reconfigure идёт -> repair отказывает
```
И обратная проверка — наблюдающие команды не блокируются:
```text
reconfigure идёт -> hy2xs-orchestrator status
→ выполняется
→ в отчёте operation_in_progress = "reconfigure (pid …)"
→ human_status предупреждает, что это снимок незавершённой транзакции
reconfigure идёт -> diagnostics collect
→ выполняется
→ в stderr есть note об идущей операции
```
Отдельно проверяется, что замок не переживает своего держателя. **Важно:**
прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий
D1h, а не этот.
1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation`
замок снят, следующий `reconfigure` проходит;
2. процесс убивается `kill -9` на том же шаге, после чего следующая операция
сообщает `is held by … which is no longer running; reclaiming it` и
продолжает;
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
## D1h. Аварийно умершая операция с вооружённым guard
Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его
закрытия каждый из них по отдельности работал правильно, а вместе они
оставляли дыру.
Замок защищает production paths, пока **жив процесс-держатель**. Rollback guard
firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому:
```text
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд
A аварийно умирает
B берёт замок (снятый обработчиком сигнала либо переиспользованный)
B начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
```
Уникальные `op-id` здесь не помогают: каталоги копий разные, а
`/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и ruleset в ядре — общие.
Сценарий:
1. `reconfigure --apply` доводится до появления в журнале
`firewall rollback guard armed`;
2. процесс убивается `kill -9` (замок остаётся устаревшим) — и, отдельным
прогоном, `kill -TERM` (замок снимается обработчиком, то есть его вообще не
будет; это и есть случай, который проверка живости держателя не ловит);
3. **до истечения 45 секунд** запускается `repair` или `reconfigure --apply`;
4. новая операция обязана отказать:
```text
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
```
5. отказ происходит **до** снятия резервной копии и до первой мутации;
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
7. после срабатывания guard транзиентный таймер выгружается
(`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не
ожиданием на глаз:
```bash
systemctl show hy2xs-fw-rollback-<op-id>.timer -p LoadState -p ActiveState
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
```
Ожидается, что таймера в списке больше нет; оставшийся `.service` в
состоянии `failed` (частичное восстановление) операцию не блокирует.
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
Отдельная проверка того же барьера — недоказуемое состояние. Барьер обязан
различать «guard вооружён» и «спросить не удалось»: это разные утверждения, и
оператору по ним нужны разные действия.
1. на рабочей установке без вооружённого guard делается недоступным запрос к
systemd — проще всего временно подложить в `PATH` оркестратора `systemctl`,
завершающийся ненулевым кодом;
2. любая операция жизненного цикла (`repair`, `reconfigure --apply`, `doctor`,
`install.sh` на PHASE 0) обязана отказать:
```text
unable to verify firewall rollback guard state; systemd query failed,
refusing to start a lifecycle operation
```
3. отказ происходит **до** первой мутации, и тип ошибки —
`GuardStateUnknownError`, а не `PendingRecoveryError`: ждать окна отката
здесь бессмысленно;
4. `hy2xs-orchestrator status` при этом **не** падает: он замок не берёт и
существует в том числе для сломанного хоста, поэтому сообщает
`rollback_guard_state: "unknown"` и `firewall_state: "guard_unknown"`;
5. после возврата рабочего `systemctl` операция проходит без дополнительных
действий.
Смысл проверки — в том, что прежнее поведение было противоположным: отказ
запроса давал пустой список guard'ов, барьер считал систему спокойной и
пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже
посреди неё.
## D1g. Успешная установка не оставляет следов транзакции
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
её завершение.
После `installed`:
```text
systemctl list-units --all --plain 'hy2xs-fw-rollback-*' → пусто
ls /run/hy2xs/rollback/ → пусто
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
ls /etc/nftables.conf.candidate → отсутствует
ls /etc/nftables.d/hy2xs.nft.candidate → отсутствует
```
и `/var/lib/hy2xs/install-state.json` содержит `phase: installed`,
`installed: true`, а `op_id` в нём совпадает с именем каталога, который лежал в
`/run/hy2xs/rollback/` во время установки.
`/etc/nftables.conf.candidate` — прямая регрессия: он не удалялся вообще, и
успешная установка оставляла его на сервере навсегда.
## D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
посторонняя установка»;
3. установка доходит до `installed`.
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
оставляла сервер наполовину настроенным. Структурно закреплено в
`orchestrator/test/install-sequence.test.ts`.
## D2. Устаревший DNS после смены IPv4 провайдером
Проверяется на рабочей установке.
```text
сервер: текущий публичный IPv4 = B
DNS: A-запись = A (старый адрес)
hy2xs-orchestrator doctor
→ FAIL
→ в выводе присутствуют и A, и B
обновить A-запись на B, дождаться TTL
hy2xs-orchestrator doctor
→ PASS
```
Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан
отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`.
+230
View File
@@ -0,0 +1,230 @@
# 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`
## 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. `namedotcom` удалён, актуальные ACME-провайдеры отражены
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`.
+24
View File
@@ -0,0 +1,24 @@
# Проверки и приёмка HY2XS
Набор проверок разложен по слоям, на которых они выполняются. Раньше он был
одним файлом на 117 КБ и 57 разделов; ориентироваться в нём приходилось
поиском по строке.
Нумерация `11-*` сохранена: это стабильный идентификатор документа, под
которым на него ссылаются CHANGELOG и релизные гейты.
| Документ | Слой | Что закрывает |
| --- | --- | --- |
| [11-1-how-to-run.md](11-1-how-to-run.md) | — | команды запуска всех наборов |
| [11-2-builder-layer.md](11-2-builder-layer.md) | builder | резолвер Hysteria, контракт версий, юнит-тесты оркестратора и админки, гейты сборки |
| [11-3-target-and-runtime.md](11-3-target-and-runtime.md) | target | установка на чистый хост, состояние сервисов, конфиг, share URI |
| [11-4-fault-injection.md](11-4-fault-injection.md) | target | D0 на живом сервере, D1a-D1h — отказы и откат, D2 — устаревший DNS |
| [11-5-negative-and-matrix.md](11-5-negative-and-matrix.md) | target | негативные сценарии, production-матрица, критерии приёмки |
## Отчёты о фактических прогонах
Проверки описывают, ЧТО должно выполняться. Результаты конкретных прогонов на
конкретных сборках лежат отдельно — см. [docs/acceptance/](../acceptance/README.md).
Разделение намеренное: документ проверок переживает релизы, а отчёт о прогоне
относится к одному артефакту и одному хосту и после релиза не редактируется.
+1 -1
View File
@@ -340,7 +340,7 @@ async function ensureInstallStateForOperation(options: ReconfigureOptions): Prom
if (!state) { if (!state) {
throw new Error( throw new Error(
`install state marker is missing: ${INSTALL_STATE_PATH}. ` + `install state marker is missing: ${INSTALL_STATE_PATH}. ` +
"HY2XS v1 требует чистой установки; см. docs/14-legacy-cleanup.md" "HY2XS v1 требует чистой установки; см. docs/operations/14-legacy-cleanup.md"
); );
} }
+1 -1
View File
@@ -178,7 +178,7 @@ function normalizeConfigSchemaVersion(value: string | undefined): number {
"HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации.\n" + "HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации.\n" +
"Похоже на конфигурацию предыдущего поколения (0.x) или на неизвестный формат.\n" + "Похоже на конфигурацию предыдущего поколения (0.x) или на неизвестный формат.\n" +
`HY2XS v1 понимает только схему ${HY2XS_CONFIG_SCHEMA_VERSION} и не выполняет миграцию на месте.\n` + `HY2XS v1 понимает только схему ${HY2XS_CONFIG_SCHEMA_VERSION} и не выполняет миграцию на месте.\n` +
"Очистите старую установку и установите HY2XS v1 с нуля: см. docs/14-legacy-cleanup.md" "Очистите старую установку и установите HY2XS v1 с нуля: см. docs/operations/14-legacy-cleanup.md"
); );
} }
const parsed = Number(raw); const parsed = Number(raw);
+1 -1
View File
@@ -110,7 +110,7 @@ export function renderGenerationFailure(
"Расхождения:", "Расхождения:",
detail, detail,
"", "",
"Требуется чистая переустановка: см. docs/14-legacy-cleanup.md" "Требуется чистая переустановка: см. docs/operations/14-legacy-cleanup.md"
].join("\n"); ].join("\n");
} }
+1 -1
View File
@@ -200,7 +200,7 @@ export function renderLegacyFailure(found: readonly LegacyMarker[]): string {
"Найденные маркеры:", "Найденные маркеры:",
list, list,
"", "",
"Очистите сервер и установите HY2XS заново: см. docs/14-legacy-cleanup.md", "Очистите сервер и установите HY2XS заново: см. docs/operations/14-legacy-cleanup.md",
"или запустите tools/legacy/purge-v0.sh из репозитория." "или запустите tools/legacy/purge-v0.sh из репозитория."
].join("\n"); ].join("\n");
} }
+1 -1
View File
@@ -157,6 +157,6 @@ describe("clean-host контракт", () => {
expect(message).toContain("/etc/hy2xs/hy2xs.env"); expect(message).toContain("/etc/hy2xs/hy2xs.env");
expect(message).toContain("hy2xs-admin.service"); expect(message).toContain("hy2xs-admin.service");
expect(message).toContain("Ни один файл на сервере не изменён"); expect(message).toContain("Ни один файл на сервере не изменён");
expect(message).toContain("docs/14-legacy-cleanup.md"); expect(message).toContain("docs/operations/14-legacy-cleanup.md");
}); });
}); });
+1 -1
View File
@@ -71,7 +71,7 @@ describe("идентификация поколения установки", ()
const state = currentState({ release_line: 0 }); const state = currentState({ release_line: 0 });
const message = renderGenerationFailure(detectGenerationProblems(state), state); const message = renderGenerationFailure(detectGenerationProblems(state), state);
expect(message).toContain("release_line"); expect(message).toContain("release_line");
expect(message).toContain("docs/14-legacy-cleanup.md"); expect(message).toContain("docs/operations/14-legacy-cleanup.md");
}); });
}); });
+1 -1
View File
@@ -52,5 +52,5 @@ HY2XS v1 **не устанавливается поверх** предыдуще
``` ```
Очистка предыдущей установки — отдельная явная операция оператора, она описана Очистка предыдущей установки — отдельная явная операция оператора, она описана
в `docs/14-legacy-cleanup.md` репозитория проекта и выполняется скриптом в `docs/operations/14-legacy-cleanup.md` репозитория проекта и выполняется скриптом
`tools/legacy/purge-v0.sh`. Установщик её никогда не запускает сам. `tools/legacy/purge-v0.sh`. Установщик её никогда не запускает сам.
+6
View File
@@ -43,6 +43,12 @@ main() {
log_step "Checking orchestrator contracts" log_step "Checking orchestrator contracts"
run_orchestrator_tests run_orchestrator_tests
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
# артефактов означало бы платить минутами за ошибку, видимую сразу.
log_step "Checking HY2XS admin frontend contracts"
run_frontend_tests
log_step "Resolving upstream Hysteria and running compatibility gate" log_step "Resolving upstream Hysteria and running compatibility gate"
resolve_and_verify_hysteria resolve_and_verify_hysteria
+139 -23
View File
@@ -219,7 +219,7 @@ run_fix20_acceptance_subset() {
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing" grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
log_step "Acceptance: docs matrix markers" log_step "Acceptance: docs matrix markers"
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing" grep -q 'Fix20 production matrix' docs/testing/11-5-negative-and-matrix.md || fail "acceptance: fix20 matrix section missing"
log_step "Acceptance: machine auth URL in templates" log_step "Acceptance: machine auth URL in templates"
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
@@ -295,30 +295,128 @@ run_fix20_acceptance_subset() {
# разрешается резолвером, и сборка bundle падает — то есть проверяется # разрешается резолвером, и сборка bundle падает — то есть проверяется
# исполняемый импорт, а не совпадение подстроки. # исполняемый импорт, а не совпадение подстроки.
# Каждая иконка обязана давать symbol с viewBox. # Контракт ассета проверяется ОБЩЕЙ функцией, а не второй её копией здесь.
# #
# Без viewBox `<use>` не знает систему координат и рисует иконку в натуральную # Раньше в этом месте лежала самостоятельная реализация проверки viewBox —
# величину, обрезая её по размеру родительского svg. Три иконки из # построчный двойник кода из sprite.ts. Пока проверялась одна вещь, это
# семнадцати его не объявляют — для них viewBox синтезируется из width/height, # выглядело безобидно; с добавлением проверки цвета две копии правил
# как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка, # разошлись бы так же, как разошлись две копии правила имени пира. Теперь
# добавленная без обоих способов задать координаты, иначе сломала бы # findIconContractViolations живёт рядом с кодом, который строит symbol, и
# отрисовку молча. # зовётся отсюда.
#
# Проверяется: система координат (без viewBox `<use>` рисует иконку в
# натуральную величину и обрезает её), отсутствие литеральных цветов у
# монохромных иконок, отсутствие инлайнового style и непустого <style>,
# отсутствие растра.
"$BUN_BIN" -e ' "$BUN_BIN" -e '
const fs = require("node:fs"); const fs = require("node:fs");
const { findIconContractViolations, iconName } =
await import("./apps/frontend/src/components/SvgIcon/symbol.ts");
const dir = "apps/frontend/src/assets/icons"; const dir = "apps/frontend/src/assets/icons";
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg")); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg"));
if (files.length === 0) throw new Error("каталог локальных иконок пуст"); if (files.length === 0) throw new Error("каталог локальных иконок пуст");
const broken = []; const broken = [];
for (const file of files) { for (const file of files) {
const raw = fs.readFileSync(`${dir}/${file}`, "utf8"); broken.push(...findIconContractViolations(fs.readFileSync(`${dir}/${file}`, "utf8"), iconName(file)));
const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").match(/<svg\b[^>]*>/i);
if (!openTag) { broken.push(`${file}: нет корневого <svg>`); continue; }
const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]);
const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]);
if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`);
} }
if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n")); if (broken.length) throw new Error("иконки не соответствуют контракту спрайта:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не даёт корректный <symbol> для спрайта" ' || fail "acceptance: локальная иконка не соответствует контракту спрайта"
log_step "Acceptance: icon colour is inherited, not patched per icon"
# Прямой запрет из требований к исправлению: маскировать дефект конвейера
# отрисовки фильтром или перекрашивать иконку по её имени нельзя.
#
# Ищется СИНТАКСИЧЕСКАЯ ФОРМА: правило для .svg-icon с filter и селектор по
# атрибуту icon-class. Комментарий, объясняющий, почему их нет, обязан
# называть их по имени.
# Правило может занимать несколько строк, поэтому проверка читает файл
# целиком, а не построчно: grep здесь дал бы ложный PASS на любом
# отформатированном CSS.
"$BUN_BIN" -e '
const fs = require("node:fs");
const files = fs.readdirSync("apps/frontend/src/styles")
.map((f) => `apps/frontend/src/styles/${f}`)
.concat(["apps/frontend/src/components/SvgIcon/index.vue"]);
const offenders = [];
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
if (/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i.test(source)) {
offenders.push(`${file}: цвет иконок маскируется CSS-фильтром`);
}
if (/\[icon-class[~^*$|]?=/i.test(source)) {
offenders.push(`${file}: цвет задаётся по имени конкретной иконки`);
}
}
if (offenders.length) throw new Error(offenders.join("\n"));
' || fail "acceptance: дефект отрисовки иконок замаскирован вместо исправления"
# Наследование обязано быть объявлено: ассет без литерального цвета сам по
# себе цвета не даёт, он его получает от компонента.
grep -q 'fill: currentcolor' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon больше не наследует цвет через currentColor"
# Проп цвета у компонента приглашал чинить отрисовку точечно, в обход общего
# контракта. Проверяется форма: атрибут fill на элементе use.
! grep -qE '<use[^>]*\bfill\s*=' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon снова принимает цвет параметром"
log_step "Acceptance: peer secret generation belongs to the server"
# «Оставьте пустым — сгенерируем автоматически» обязано выполняться для всех
# дверей: панели, прямого вызова API, импорта и будущих клиентов. Генерация
# во frontend выполняла бы обещание ровно для одной из них.
code_has apps/service/peer_secret.go -F -- 'func GeneratePeerSecret' \
|| fail "acceptance: генерация секрета пира не объявлена на сервисном слое"
code_has apps/service/peer_secret.go -F -- 'util.RandomString' \
|| fail "acceptance: генерация секрета пира не использует общий крипто-генератор"
# Импорт без секрета обязан давать пира, неотличимого от созданного формой.
code_has apps/service/peer.go -F -- 'GeneratePeerSecret(name)' \
|| fail "acceptance: импорт пиров генерирует секрет собственным способом"
local frontend_rng_hits
frontend_rng_hits="$(grep -rlE 'crypto\.getRandomValues|Math\.random' apps/frontend/src || true)"
[ -z "$frontend_rng_hits" ] \
|| fail "acceptance: панель генерирует случайные значения сама; секреты пиров создаёт сервер. Найдено:
$frontend_rng_hits"
log_step "Acceptance: validation failures name the field and the rule"
# Раньше и разбор тела, и нарушение любого правила любого поля превращались в
# одно слово `invalid`, а слой vo выбирал HTTP-семантику СРАВНЕНИЕМ текста
# сообщения с тремя литералами. Панель не могла ни подсветить поле, ни
# локализовать причину, не разбирая прозу.
code_has apps/controller/validator.go -F -- 'vo.FailValidation' \
|| fail "acceptance: отказ валидации перестал быть структурированным"
code_has apps/controller/validator.go -F -- 'describeValidationErrors' \
|| fail "acceptance: причины отказа валидации не раскладываются по полям"
code_has apps/model/vo/result.go -F -- 'Errors []FieldError' \
|| fail "acceptance: ответ об ошибке больше не несёт машиночитаемых причин"
# Классификация по тексту сообщения не должна вернуться: ищется форма
# сравнения, а не упоминание — комментарий выше в result.go обязан называть
# убранный приём по имени.
! code_has apps/model/vo/result.go -E -- 'constant\.[A-Za-z]+Error *== *message' \
|| fail "acceptance: код ответа снова выводится сравнением текста сообщения"
local generic_invalid_hits
generic_invalid_hits="$(code_mentions_in 'vo.Fail(constant.InvalidError' apps/controller apps/middleware)"
[ -z "$generic_invalid_hits" ] \
|| fail "acceptance: обобщённый отказ «invalid» вернулся в: $generic_invalid_hits"
# Панель обязана выбирать фразу по коду, а не по тексту ответа.
code_has apps/frontend/src/utils/api-message.ts -F -- 'error.code.' \
|| fail "acceptance: панель не локализует причины отказа по коду"
log_step "Acceptance: Flamy attribution is application-owned, not operator config"
# Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
# подпись разработчика: ни через панель, ни через hy2xs.env, ни через таблицу
# `config`.
[ -f apps/frontend/src/constants/branding.ts ] \
|| fail "acceptance: внутренние константы бренда отсутствуют"
code_has apps/frontend/src/constants/branding.ts -F -- 'https://flamy.studio' \
|| fail "acceptance: адрес атрибуции не объявлен в константах бренда"
local flamy_carriers
flamy_carriers="$(grep -rl 'flamy\.studio' apps/frontend/src || true)"
[ "$flamy_carriers" = "apps/frontend/src/constants/branding.ts" ] \
|| fail "acceptance: адрес атрибуции размазан по исходникам панели: $flamy_carriers"
local flamy_config_hits
flamy_config_hits="$(grep -ril 'flamy' \
package/config package/templates orchestrator/src \
apps/model/constant apps/dao || true)"
[ -z "$flamy_config_hits" ] \
|| fail "acceptance: адрес атрибуции стал операторской настройкой. Найдено в: $flamy_config_hits"
log_step "Acceptance: frontend i18n does not touch Pinia at module import" log_step "Acceptance: frontend i18n does not touch Pinia at module import"
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store" ! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
@@ -631,7 +729,7 @@ run_clean_install_acceptance() {
log_step "Acceptance: legacy cleanup is a separate, explicit helper" log_step "Acceptance: legacy cleanup is a separate, explicit helper"
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing" [ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing" [ -f docs/operations/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
! grep -q 'purge-v0' "$package_dir/install.sh" \ ! grep -q 'purge-v0' "$package_dir/install.sh" \
|| fail "acceptance: the installer must never run destructive cleanup on its own" || fail "acceptance: the installer must never run destructive cleanup on its own"
@@ -852,11 +950,14 @@ $piped_matcher"
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом" || fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \ grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов админки не фиксируется результатом" || fail "acceptance: прогон тестов админки не фиксируется результатом"
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
"$BUN_BIN" -e ' "$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8"); const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [ for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""], ["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""] ["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
]) { ]) {
const start = source.indexOf(fn); const start = source.indexOf(fn);
if (start < 0) throw new Error("не найдена функция " + fn); if (start < 0) throw new Error("не найдена функция " + fn);
@@ -1785,14 +1886,29 @@ run_legacy_account_acceptance() {
done done
log_step "Acceptance: v1 docs carry no previous-generation vocabulary" log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
# docs/14 — единственное место, где эти имена обозначают реальные объекты # Руководство по очистке предыдущего поколения — единственное место, где эти
# для удаления. В обычных docs их быть не должно. # имена обозначают реальные объекты для удаления. В остальных docs их быть не
# должно.
#
# Обход РЕКУРСИВНЫЙ. Раньше здесь стоял плоский `docs/*.md`, и это работало,
# пока документы лежали одной кучей в корне docs. После разнесения по
# тематическим каталогам такой шаблон совпадал бы ровно с одним файлом —
# docs/README.md, — то есть проверка отчитывалась бы зелёным, не заглянув
# почти никуда.
local doc local doc
for doc in docs/*.md; do while IFS= read -r doc; do
[ -n "$doc" ] || continue
case "$doc" in case "$doc" in
docs/14-legacy-cleanup.md) continue ;; docs/operations/14-legacy-cleanup.md) continue ;;
esac esac
! grep -q 'H_UI_' "$doc" \ ! grep -q 'H_UI_' "$doc" \
|| fail "acceptance: previous-generation config keys leaked into $doc" || fail "acceptance: previous-generation config keys leaked into $doc"
done done <<EOF
$(find docs -type f -name '*.md' | sort)
EOF
# Каталог обязан быть непустым: `find` по опечатке в пути вернул бы пустой
# список, а пустой список для проверки «такого здесь нет» означает успех.
[ "$(find docs -type f -name '*.md' | wc -l)" -ge 10 ] \
|| fail "acceptance: обход документации нашёл подозрительно мало файлов"
} }
+23
View File
@@ -63,6 +63,27 @@ run_orchestrator_tests() {
export ORCHESTRATOR_TESTS_PASSED export ORCHESTRATOR_TESTS_PASSED
} }
run_frontend_tests() {
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
# ошибок серверным константам, единственность адреса атрибуции.
#
# Исполняет их Bun, уже закреплённый в versions.env, а не vitest. Причина не
# в удобстве: jsdom не вычисляет currentColor и визуальной корректности всё
# равно не доказал бы, зато vitest привёл бы в граф `pnpm audit` — а его
# порог считается по ВСЕМУ lock-файлу frontend — сотню транзитивных
# зависимостей ради нулевой дополнительной гарантии.
#
# Проверяемые модули (SvgIcon/symbol.ts, constants/branding.ts, словари)
# намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно выполнить вне
# браузера.
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|| fail "HY2XS admin frontend contract tests failed"
FRONTEND_TESTS_PASSED="true"
export FRONTEND_TESTS_PASSED
}
run_admin_tests() { run_admin_tests() {
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта # `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
# функция должна вызываться только после bundle_ui. # функция должна вызываться только после bundle_ui.
@@ -177,6 +198,8 @@ write_metadata() {
|| fail "write_metadata: контракты оркестратора не проверялись; тесты обязательны для релизного пакета" || fail "write_metadata: контракты оркестратора не проверялись; тесты обязательны для релизного пакета"
[ "${ADMIN_TESTS_PASSED:-false}" = "true" ] \ [ "${ADMIN_TESTS_PASSED:-false}" = "true" ] \
|| fail "write_metadata: контракты админки не проверялись; тесты обязательны для релизного пакета" || fail "write_metadata: контракты админки не проверялись; тесты обязательны для релизного пакета"
[ "${FRONTEND_TESTS_PASSED:-false}" = "true" ] \
|| fail "write_metadata: контракты панели не проверялись; тесты обязательны для релизного пакета"
{ {
printf 'name=HY2XS\n' printf 'name=HY2XS\n'
+1 -1
View File
@@ -217,7 +217,7 @@ verify_api_namespace_contract() {
|| fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}" || fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
# Старое пространство имён не имеет права вернуться ни в один компонент. # Старое пространство имён не имеет права вернуться ни в один компонент.
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в # Историческое имя допустимо только в docs/operations/14-legacy-cleanup.md и в
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
# #
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет. # Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
+243
View File
@@ -0,0 +1,243 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { ERR_CODE, API_CODE } from "../../apps/frontend/src/utils/api-error";
import {
FLAMY_NAME,
FLAMY_URL,
} from "../../apps/frontend/src/constants/branding";
import ru from "../../apps/frontend/src/lang/package/ru";
import en from "../../apps/frontend/src/lang/package/en";
/**
* Контракты панели, которые нельзя проверить ни типами, ни сборкой bundle.
*
* Все три жили на честном слове: словари локализации расходились молча,
* коды ошибок существовали в двух местах без связи между ними, а адрес
* атрибуции ничто не удерживало от расползания по шаблонам.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...sourceFiles(full));
continue;
}
if (/\.(ts|vue|scss|js)$/i.test(entry.name)) {
out.push(full);
}
}
return out;
}
/** Коды причин, объявленные сервером в constant.ErrCode*. */
function serverErrorCodes(): Set<string> {
const source = fs.readFileSync(
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
"utf8"
);
const codes = new Set<string>();
for (const match of source.matchAll(
/ErrCode[A-Za-z]+\s+string\s*=\s*"([^"]+)"/g
)) {
codes.add(match[1]);
}
return codes;
}
function leafKeys(value: unknown, prefix = ""): string[] {
if (typeof value !== "object" || value === null) {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, v]) =>
leafKeys(v, prefix ? `${prefix}.${key}` : key)
);
}
describe("локализация", () => {
// Ключ, забытый в одном словаре, не ломает ни типы, ни сборку: vue-i18n
// молча отдаёт сам ключ, и оператор видит `error.code.min_length` вместо
// фразы. Единственное место, где это может быть замечено заранее, — здесь.
test("наборы ключей ru и en совпадают", () => {
const ruKeys = new Set(leafKeys(ru));
const enKeys = new Set(leafKeys(en));
expect([...ruKeys].filter((key) => !enKeys.has(key)).sort()).toEqual([]);
expect([...enKeys].filter((key) => !ruKeys.has(key)).sort()).toEqual([]);
});
// Панель выбирает фразу по коду ответа. Код без фразы доезжает до оператора
// серверным сообщением — это работает, но на языке сервера, а не панели.
test("у каждого известного кода ошибки есть фраза в обоих словарях", () => {
const missing: string[] = [];
for (const code of Object.values(ERR_CODE)) {
for (const [locale, dictionary] of [
["ru", ru],
["en", en],
] as const) {
const messages = (dictionary as any).error?.code ?? {};
if (typeof messages[code] !== "string") {
missing.push(`${locale}: error.code.${code}`);
}
}
}
expect(missing).toEqual([]);
});
test("коды ответа совпадают с серверными константами", () => {
const codeSource = fs.readFileSync(
path.join(REPO_ROOT, "apps", "model", "constant", "code.go"),
"utf8"
);
const declared = new Map<string, number>();
for (const match of codeSource.matchAll(
/(Code[A-Za-z]+)\s+int\s*=\s*(\d+)/g
)) {
declared.set(match[1], Number(match[2]));
}
expect(declared.get("CodeSuccess")).toBe(API_CODE.success);
expect(declared.get("CodeSysError")).toBe(API_CODE.systemError);
expect(declared.get("CodeInvalidError")).toBe(API_CODE.validationFailed);
expect(declared.get("CodeUnauthorizedError")).toBe(API_CODE.unauthorized);
expect(declared.get("CodeForbiddenError")).toBe(API_CODE.forbidden);
});
// Коды причин объявлены на сервере; панель обязана знать их под теми же
// именами. Расхождение здесь тихо отключает локализацию для целого класса
// отказов.
//
// Проверяются ОБА направления. Одного мало: направление «панель → сервер»
// ловит выдуманный код, но не ловит серверный код, о котором панель не
// знает, — а именно так добавляется новое правило. Ровно это здесь и
// случилось: коды min_length/max_length появились на сервере после того, как
// карта кодов панели была написана, и проверка в одну сторону молчала.
test("коды причин совпадают с серверными константами", () => {
const serverCodes = serverErrorCodes();
expect(serverCodes.size).toBeGreaterThan(0);
const unknownToServer = Object.values(ERR_CODE).filter(
(code) => !serverCodes.has(code)
);
expect(unknownToServer).toEqual([]);
const known = new Set<string>(Object.values(ERR_CODE));
expect([...serverCodes].filter((code) => !known.has(code)).sort()).toEqual(
[]
);
});
// Серверный код без фразы доезжает до оператора сообщением сервера — это
// работает, но на языке сервера, а не панели.
test("у каждого серверного кода есть фраза в обоих словарях", () => {
const missing: string[] = [];
for (const code of serverErrorCodes()) {
for (const [locale, dictionary] of [
["ru", ru],
["en", en],
] as const) {
const messages = (dictionary as any).error?.code ?? {};
if (typeof messages[code] !== "string") {
missing.push(`${locale}: error.code.${code}`);
}
}
}
expect(missing).toEqual([]);
});
});
describe("атрибуция Flamy", () => {
test("адрес объявлен один раз и ведёт на flamy.studio", () => {
expect(FLAMY_URL).toBe("https://flamy.studio");
expect(FLAMY_NAME).toBe("Flamy");
const carriers = sourceFiles(FRONTEND_SRC).filter((file) =>
fs.readFileSync(file, "utf8").includes("flamy.studio")
);
expect(carriers.map((file) => path.relative(REPO_ROOT, file))).toEqual([
path.join("apps", "frontend", "src", "constants", "branding.ts"),
]);
});
// Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
// подпись разработчика. Проверяются все каналы, через которые значение
// могло бы стать настраиваемым.
test("адрес не является операторской настройкой", () => {
const operatorSurfaces = [
"package/config/hy2xs.env",
"package/templates/env/post-install.env.tpl",
"apps/model/constant/config.go",
];
for (const relative of operatorSurfaces) {
const source = fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
expect(source.toLowerCase()).not.toContain("flamy");
}
});
test("футер отрисован в боковом меню и учтён в его высоте", () => {
const sidebar = fs.readFileSync(
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "index.vue"),
"utf8"
);
expect(sidebar).toContain("<Footer");
// Высота области прокрутки обязана вычитать высоту футера, иначе пункты
// меню наезжают на подпись при длинном списке.
const styles = fs.readFileSync(
path.join(FRONTEND_SRC, "styles", "sidebar.scss"),
"utf8"
);
expect(styles).toContain("$sidebarFooterHeight");
const variables = fs.readFileSync(
path.join(FRONTEND_SRC, "styles", "variables.scss"),
"utf8"
);
expect(variables).toMatch(/\$sidebarFooterHeight:\s*\d+px/);
});
test("внешняя ссылка открывается безопасно", () => {
const footer = fs.readFileSync(
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"),
"utf8"
);
const anchors = [...footer.matchAll(/<a\b[\s\S]*?>/g)].map((m) => m[0]);
expect(anchors.length).toBeGreaterThan(0);
for (const anchor of anchors) {
expect(anchor).toContain('target="_blank"');
expect(anchor).toContain('rel="noopener noreferrer"');
}
});
});
describe("секрет пира", () => {
// Прямой запрет из требований: генерация секрета принадлежит серверу.
// Панель, подставляющая значение в пустое поле, выполняла бы обещание
// «сгенерируем автоматически» ровно для одной двери из четырёх.
test("панель не генерирует секрет сама", () => {
const form = fs.readFileSync(
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
"utf8"
);
expect(form).not.toMatch(/crypto\.getRandomValues/);
expect(form).not.toMatch(/Math\.random/);
// Подстановка значения в пустой секрет перед отправкой — тот же обход
// другими средствами.
expect(form).not.toMatch(/secret\s*=\s*dataForm\.secret\s*\|\|\s*["'`]/);
});
test("пустой секрет уезжает на сервер как есть", () => {
const form = fs.readFileSync(
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
"utf8"
);
expect(form).toContain("await savePeerApi(dataForm)");
});
});
+312
View File
@@ -0,0 +1,312 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import {
MULTICOLOR_ICONS,
SYMBOL_PREFIX,
findIconContractViolations,
iconName,
toSymbol,
} from "../../apps/frontend/src/components/SvgIcon/symbol";
/**
* Контракт спрайта локальных иконок.
*
* Почему тест лежит здесь, а не в apps/frontend. Во frontend нет тестового
* рантайма, и заводить его ради этой проверки не нужно: vitest с jsdom не
* вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
* зато привёл бы в граф `pnpm audit` (порог high по ВСЕМУ lock-файлу) сотню
* транзитивных зависимостей. Проверяемый модуль `SvgIcon/symbol.ts` чистый,
* поэтому его исполняет уже закреплённый в versions.env Bun тот же, которым
* проверяется оркестратор.
*
* Тест не претендует на доказательство визуальной корректности: цвет на
* экране проверяется человеком и фиксируется в отчёте приёмки. Здесь
* закрепляется то, что машина проверить может, что ни один ассет не задаёт
* цвет мимо `currentColor` и что обе половины контракта (ассет и CSS) на
* месте.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
const ICONS_DIR = path.join(FRONTEND_SRC, "assets", "icons");
function iconFiles(): string[] {
return fs
.readdirSync(ICONS_DIR)
.filter((file) => file.toLowerCase().endsWith(".svg"))
.sort();
}
function readIcon(file: string): string {
return fs.readFileSync(path.join(ICONS_DIR, file), "utf8");
}
function readSource(relative: string): string {
return fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
}
describe("ассеты иконок", () => {
test("каталог иконок не пуст", () => {
expect(iconFiles().length).toBeGreaterThan(0);
});
// Регрессия. Восемь ассетов несли литеральный `fill="#000000"` на <path>:
// атрибут представления перебивает унаследованное CSS-свойство, поэтому
// объявленный в двух местах `fill: currentcolor` не действовал, и все семь
// иконок бокового меню рисовались чёрным по фону #181818.
test("каждый ассет соответствует контракту спрайта", () => {
const violations: string[] = [];
for (const file of iconFiles()) {
violations.push(
...findIconContractViolations(readIcon(file), iconName(file))
);
}
expect(violations).toEqual([]);
});
test("монохромные ассеты не содержат литеральных цветов", () => {
const offenders: string[] = [];
for (const file of iconFiles()) {
const name = iconName(file);
if (MULTICOLOR_ICONS.has(name)) {
continue;
}
if (/#[0-9a-f]{3,8}\b/i.test(readIcon(file))) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
// Список многоцветных — закрытое решение, а не свалка. Устаревшая запись в
// нём молча снимала бы проверку цвета с иконки, которой уже нет.
test("в списке многоцветных нет записей без ассета", () => {
const present = new Set(iconFiles().map(iconName));
for (const name of MULTICOLOR_ICONS) {
expect(present.has(name)).toBe(true);
}
});
// Многоцветный ассет обязан сохранять СВОИ цвета: общая нормализация к нему
// не применяется, и это утверждение проверяется на настоящем файле.
test("многоцветный ассет сохраняет собственную палитру", () => {
const download = readIcon("download.svg");
expect(download).toContain('fill="#00C97C"');
expect(findIconContractViolations(download, "download")).toEqual([]);
});
});
describe("контракт ассета", () => {
const OPEN = '<svg viewBox="0 0 24 24">';
test("литеральный fill у монохромной иконки — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("мимо currentColor");
});
test("тот же ассет в списке многоцветных нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"download"
)
).toEqual([]);
});
test("currentColor, none и transparent разрешены", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="none" stroke="currentColor" d="M0 0"/>` +
`<rect fill="transparent"/></svg>`,
"mono"
)
).toEqual([]);
});
test("цвет в инлайновом style — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path style="fill:#191919;opacity:.5" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("инлайновый style");
});
test("непустой <style> внутри ассета — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<style>.a{fill:red}</style><path d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<style>");
});
test("пустой <style> от редактора нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<defs><style type="text/css"></style></defs>` +
`<path d="M0 0"/></svg>`,
"mono"
)
).toEqual([]);
});
test("растровое <image> — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<image href="data:image/png;base64,AA"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<image>");
});
test("ассет без системы координат — нарушение", () => {
const violations = findIconContractViolations(
'<svg><path d="M0 0"/></svg>',
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("viewBox");
});
test("отсутствие корневого <svg> — нарушение", () => {
expect(findIconContractViolations("не svg", "mono").length).toBe(1);
});
});
describe("сборка symbol", () => {
test("symbol получает id с общим префиксом и viewBox", () => {
const symbol = toSymbol(
'<svg viewBox="0 0 32 32"><path d="M0 0"/></svg>',
"user"
);
expect(symbol.startsWith(`<symbol id="${SYMBOL_PREFIX}-user"`)).toBe(true);
expect(symbol).toContain('viewBox="0 0 32 32"');
expect(symbol).toContain('<path d="M0 0"/>');
});
test("viewBox синтезируется из width/height, когда не объявлен", () => {
expect(
toSymbol('<svg width="128" height="128"><path/></svg>', "eye")
).toContain('viewBox="0 0 128 128"');
});
test("пролог, DOCTYPE и комментарии не уезжают в документ", () => {
const symbol = toSymbol(
'<?xml version="1.0"?><!DOCTYPE svg><!-- заметка -->' +
'<svg viewBox="0 0 24 24"><path/></svg>',
"report"
);
expect(symbol).not.toContain("<?xml");
expect(symbol).not.toContain("<!DOCTYPE");
expect(symbol).not.toContain("заметка");
});
test("каждый ассет даёт symbol с системой координат", () => {
for (const file of iconFiles()) {
const symbol = toSymbol(readIcon(file), iconName(file));
expect(symbol).toContain(`id="${SYMBOL_PREFIX}-${iconName(file)}"`);
expect(symbol).toContain("viewBox=");
}
});
});
describe("иконки, которые запрашивает приложение", () => {
// Имя иконки вычисляется в рантайме, поэтому опечатка в meta.icon или в
// icon-class не ломает ни типы, ни сборку: `<use>` просто не находит symbol
// и рисует пустоту.
test("каждое запрошенное имя существует как ассет", () => {
const present = new Set(iconFiles().map(iconName));
const requested = new Set<string>();
const router = readSource("apps/frontend/src/router/index.ts");
for (const match of router.matchAll(/\bicon:\s*"([^"]+)"/g)) {
requested.add(match[1]);
}
const vueFiles = [
"apps/frontend/src/views/login/index.vue",
"apps/frontend/src/layout/components/Navbar.vue",
"apps/frontend/src/components/LangSelect/index.vue",
];
for (const file of vueFiles) {
const source = readSource(file);
// Литеральное имя: `icon-class="user"`. Двоеточие впереди исключается —
// `:icon-class` содержит выражение, а не имя, и разбирается ниже.
for (const match of source.matchAll(/(?<![:\w-])icon-class="([^"]+)"/g)) {
requested.add(match[1]);
}
// Тернарный выбор имени: :icon-class="a ? 'x' : 'y'".
for (const match of source.matchAll(
/:icon-class="[^"]*?'([^']+)'\s*:\s*'([^']+)'/g
)) {
requested.add(match[1]);
requested.add(match[2]);
}
}
expect(requested.size).toBeGreaterThan(0);
expect([...requested].filter((name) => !present.has(name))).toEqual([]);
});
});
describe("вторая половина контракта — CSS", () => {
// Ассет без литерального цвета сам по себе цвета не даёт: он его
// НАСЛЕДУЕТ. Если правило `fill: currentcolor` исчезнет, иконки станут
// чёрными по инициальному значению SVG, и ни одна проверка выше этого не
// заметит.
test("SvgIcon объявляет fill: currentcolor", () => {
expect(
readSource("apps/frontend/src/components/SvgIcon/index.vue")
).toMatch(/fill:\s*currentcolor/i);
});
test("боковое меню не задаёт иконкам собственный цвет", () => {
expect(readSource("apps/frontend/src/styles/sidebar.scss")).toMatch(
/\.svg-icon\s*\{[^}]*fill:\s*currentcolor/i
);
});
// Прямой запрет из требований к исправлению: маскировать дефект pipeline
// фильтром или перекрашивать иконку по её имени нельзя.
test("нет CSS-фильтров и правил на имя иконки", () => {
const styleFiles = fs
.readdirSync(path.join(FRONTEND_SRC, "styles"))
.map((file) => `apps/frontend/src/styles/${file}`);
for (const file of [
...styleFiles,
"apps/frontend/src/components/SvgIcon/index.vue",
]) {
const source = readSource(file);
expect(source).not.toMatch(/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i);
expect(source).not.toMatch(/\[icon-class[~^*$|]?=/i);
}
});
// Проп цвета убран сознательно: он приглашал чинить цвет точечно, в обход
// общего контракта.
//
// Проверяется СИНТАКСИЧЕСКАЯ ФОРМА, а не подстрока. Комментарий в самом
// компоненте обязан называть убранное по имени — иначе он бесполезен, — и
// скан по тексту падал бы ровно на объяснении выполненной работы.
test("SvgIcon не принимает цвет параметром", () => {
const component = readSource(
"apps/frontend/src/components/SvgIcon/index.vue"
);
const useTag = component.match(/<use\b[^>]*>/);
expect(useTag).not.toBeNull();
expect(useTag![0]).not.toMatch(/\bfill\s*=/);
const propsBlock = component.match(/defineProps\(\{[\s\S]*?\n\}\)/);
expect(propsBlock).not.toBeNull();
expect(propsBlock![0]).not.toMatch(/\bcolor\s*:/);
});
});