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

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

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

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

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

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

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

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

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
@@ -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`.