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.
This commit is contained in:
2026-09-04 02:32:50 +05:00
parent 82e5ca40cc
commit a8407cf16b
29 changed files with 2409 additions and 77 deletions
+76
View File
@@ -96,6 +96,82 @@ Hysteria-интеграции с официальной документацие
никто не проверял. Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом
Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла
целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал,
`/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый
запрос, потому что валидатор паниковал на теге правила, пережившего
переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради
которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер,
проверяющий каждый тег валидации на существование, и smoke, выполняющий
настоящий вход. Прогон задокументирован в
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
### Исправлено — вход в панель
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
`LoginDto.Username` стоял тег `validateStr` — правило с таким именем не
регистрировалось: при переименовании в `credentialStr` правка не доехала до
одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее
имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе
структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой
установки была недоступна целиком.
- **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все
теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору
отдельно — закрыт класс, а не найденный экземпляр.
- **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот
вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают
истинны при полностью недоступной панели. Теперь smoke выполняет настоящий
`POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с
непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и
на отказ. Отрицательная проба выполняется в любом режиме операции.
- **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER`
по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и
установка с ним завершалась успешно, создавая учётную запись, под которой
невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба
значения теперь проверяются при разборе окружения, умолчание сведено к
`hy2xsadmin` во всех трёх местах.
- **Панель была строже сервера и запирала оператора после смены пароля.** Форма
входа ограничивала пароль 32 символами при серверном пределе в 64, а форма
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
объявлен один раз в `apps/service/admin_credentials.go`, а копии в панели и
оркестраторе сверяются с ним тестами, читающими Go-исходник.
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на
установленных серверах.
- **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus
рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов,
а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень
селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг
одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед
переключателем видимости. Индикация перенесена на элемент, который оператор и
видит полем.
- **Требование не называлось.** «Неверный формат логина» и «Некорректное
значение» не давали оператору ни одного способа узнать, что от него хотят:
набор символов логина приходит из `hy2xs.env` и в панели нигде не показан.
Фразы форм и серверная причина `credential_format` теперь перечисляют границы
и набор.
- **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в
entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал.
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа