Files
HY2XS_flamy/docs/admin/15-ui-contracts.md
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

349 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракты панели
Свойства 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. Таблицы журнала
**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`,
содержательная — через `min-width`.
Без этого Element Plus делит доступную ширину между колонками практически
поровну. У журнала колонок три, поэтому уровень и время получали по трети
строки, а сообщение — единственное содержимое журнала — тоже треть.
**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает
диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один
вопрос, ради которого страницу открыли.
**Обе страницы журнала построены на одном компоненте**
(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три
дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа.
Собственная `el-table-column` на странице журнала запрещена гейтом приёмки.
---
## 5. Выгрузка файлов
**Правило.** Сборка ссылки на скачивание существует в панели в единственном
экземпляре — `utils/download.ts`. Единственность проверяется контрактным
тестом по вхождению `createObjectURL`.
Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос
ПЕРЕД `try`:
```ts
const response = await exportApi(...); // отказ сюда не попадает
try { ... } catch (e) { /* empty */ }
```
то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча:
оператор не получал ни файла, ни причины. Третья падала на `split(...)` при
отсутствующем `Content-Disposition` — и это исключение тоже глушилось.
**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через
общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому
перехватчик по нему фразы не даст.
---
## 6. Меню действий над строкой
**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на
`el-dropdown`.
`@click` на каждом пункте не запрещён самим Element Plus, но `command` является
штатным контрактом именно для меню действий, и при нём невозможно добавить
пункт, забыв его подключить. Обе половины проверяются контрактным тестом:
наличие `@command` и отсутствие `@click` на пунктах.
**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к
VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и
первая может примениться без второй. Панель обязана распознать
`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и
ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки
подтолкнул бы оператора к выводу, прямо противоположному истине.
---
## 7. Подтверждения
**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка.
`ElMessageBox` отклоняет промис при нажатии «Отмена». `await
ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное
отклонение промиса на каждую отмену. Единственный прямой вызов на странице
пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это
закреплено тестом.
---
## 8. Панель не выдумывает состояние
**Правило.** Отсутствие данных показывается как отсутствие данных.
Нарушений было три, и все три давали оператору ответ, противоположный истине.
**Страница конфигурации** строила форму merge'ем ответа сервера поверх полного
объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
показывалась как `listen: :9999`, а явное `speedTest: false` считалось
ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики
дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (`null`) от
значения, а секции вне production-профиля перечисляются отдельным списком
расхождений.
**Список пиров** получал `onlineUsers, _ := Hysteria2Online()` и при любом сбое
control plane показывал всех пиров офлайн. Теперь страница несёт
`onlineState: ok | unavailable` — один признак на ответ, а не флаг в каждой
строке, — и при `unavailable` показывает «онлайн неизвестен», а число устройств
как `?`.
**Дашборд** выводил доступность Traffic Stats API из ответа `systemctl` и умел
утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два
независимых факта, а у состояния службы три значения: `active`, `inactive`,
`unknown`.
**Запрещено:**
* подставлять значение по умолчанию вместо отсутствующего в ответе;
* показывать `0`, `false` или «офлайн» там, где данные не получены;
* выводить один факт из другого, если их можно спросить по отдельности.
**Секреты на читающем экране.** Read-only страница не имеет права быть щедрее
санитизированной выгрузки того же документа. Вместо значения показывается
диагностический факт: «задан» / «не задан» для паролей и секретов, имена
параметров без значений для `acme.dns.config`, адрес с вырезанным
`access_token` для auth-URL.
**Редакторы без сохранения запрещены.** Конфигом владеет оркестратор, маршрутов
записи в API нет, поэтому поля ввода на странице конфигурации обещают действие,
которого не существует. Проверяется контрактным тестом: в шаблоне нет ни
`el-input`, ни `el-switch`, ни `el-select`, ни `v-model`.
---
## 9. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.
---
## 10. Форма входа
**Правило.** Панель не имеет права быть строже сервера. Значение, которое
сервер принял бы, форма обязана отправить.
### Где живёт контракт
Требования к логину и паролю администратора объявлены **один раз**, в
`apps/service/admin_credentials.go`:
| Что | Значение | Владелец |
| --- | --- | --- |
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Набор символов пароля | не ограничен | — |
Остальные три стороны продукта только повторяют этот контракт, и каждая копия
сверяется с оригиналом тестом, читающим Go-исходник:
* панель — `apps/frontend/src/constants/credentials.ts`
(`tools/test/frontend-contract.test.ts`);
* оркестратор — `orchestrator/src/config/profile.ts`
(`orchestrator/test/admin-credentials.test.ts`);
* правило валидатора — `credentialStr` в `apps/controller/validator.go`, длина
живёт ВНУТРИ него.
### Почему у пароля нет набора символов
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы.
Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72
БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже.
Длина считается в **символах**, а не в байтах: `go-playground/validator` считает
`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32
кириллических букв, который сервер принимает.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Индикация ошибки принадлежит видимому полю
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
```text
.el-form-item.is-error .el-form-item__content .el-input__wrapper
```
то есть селектором из четырёх классов. На форме входа видимое поле — это
`el-form-item`: внутрь одного поля кладутся иконка, ввод и переключатель
видимости пароля, а `el-input` занимает лишь среднюю часть. Поэтому штатная
индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с
границей поля.
**Правило.** Там, где рамка поля нарисована на `el-form-item`, состояние отказа
рисуется на нём же, а штатная тень враппера гасится селектором, который
повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает
специфичностью, а не `!important`. Сообщению об отказе оставляется место под
полем: `el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт
вне рамки.
**Что машина не докажет.** Совпадение рамки с границей поля на экране. Проверка
остаётся ручной и фиксируется в отчёте приёмки; тест закрепляет только наличие
правил, которые её обеспечивают.
### Требование называется, а не нарушается
Фразы `credentials.usernameFormat` и `credentials.passwordLength` перечисляют
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
давало оператору ни одного способа узнать, что от него хотят.
Серверная причина `credential_format` несёт те же значения в `params`
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
и проверяет и длину, и набор, поэтому описывать его только через символы —
значит описывать отказ по длине неверно.