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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
@@ -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. Заведите пиров заново и раздайте новые клиентские ссылки.
|
||||||
|
|
||||||
|
|||||||
@@ -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 наружу.
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
<?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 @@
|
|||||||
<?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 @@
|
|||||||
<?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 @@
|
|||||||
<?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 @@
|
|||||||
<?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 @@
|
|||||||
<?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 |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
|
|||||||
|
|
||||||
$sideBarWidth: 210px;
|
$sideBarWidth: 210px;
|
||||||
$sideBarCollapsedWidth: 54px;
|
$sideBarCollapsedWidth: 54px;
|
||||||
|
|
||||||
|
// Высота подписи разработчика внизу бокового меню.
|
||||||
|
//
|
||||||
|
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
|
||||||
|
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
|
||||||
|
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
|
||||||
|
$sidebarFooterHeight: 34px;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"` // Первичный ключ
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("истёкший токен не должен выглядеть как недействительный")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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).
|
||||||
|
|
||||||
### Раннеры подпроцессов: два набора, а не один
|
### Раннеры подпроцессов: два набора, а не один
|
||||||
|
|
||||||
@@ -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).
|
||||||
@@ -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` на таблице маршрутов собранного роутера —
|
||||||
|
и существование этого теста само проверяется контрактом.
|
||||||
|
|
||||||
@@ -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-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||||
|
граница определяется значением, а не именем ключа.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
Разделение намеренное: документ проверок переживает релизы, а отчёт о прогоне
|
||||||
|
относится к одному артефакту и одному хосту и после релиза не редактируется.
|
||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function renderGenerationFailure(
|
|||||||
"Расхождения:",
|
"Расхождения:",
|
||||||
detail,
|
detail,
|
||||||
"",
|
"",
|
||||||
"Требуется чистая переустановка: см. docs/14-legacy-cleanup.md"
|
"Требуется чистая переустановка: см. docs/operations/14-legacy-cleanup.md"
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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`. Установщик её никогда не запускает сам.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: обход документации нашёл подозрительно мало файлов"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 и то, что уезжает в пакет.
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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*:/);
|
||||||
|
});
|
||||||
|
});
|
||||||