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:
@@ -259,3 +259,90 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `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`), и фраза панели обязана их использовать: правило одно
|
||||
и проверяет и длину, и набор, поэтому описывать его только через символы —
|
||||
значит описывать отказ по длине неверно.
|
||||
|
||||
Reference in New Issue
Block a user