Files
HY2XS_flamy/docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md
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

263 lines
17 KiB
Markdown
Raw Permalink 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.
# Runtime-прогон `1.0.0-rc2` на чистом Debian 13
```text
Findings base: 82e5ca40 — дерево, на котором собран проверявшийся RC2
Fixes verified in: рабочее дерево этого прохода
Хост: чистый Debian 13, установка с нуля из release-архива
```
Провенанс у этого файла другой, чем у соседних preflight-разборов: дефект
наблюдался **на хосте**, а не найден чтением дерева. Установка прошла целиком и
объявила успех, после чего панель оказалась недоступна.
## Статус прогона
```text
RC2 BUILD ACCEPTANCE: PASS
RC2 CLEAN INSTALL: PASS
RC2 SERVER RUNTIME: PASS
RC2 ADMIN LOGIN: FAIL
RC2 OVERALL RUNTIME: REJECTED
```
Hysteria работает, сервер не повреждён, паника восстановима. Приёмка RC2
останавливается здесь: основная admin-панель после чистой установки недоступна
целиком, и это P0 для release candidate.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| LOGIN-01 | `POST /api/auth/login` паниковал на теге незарегистрированного правила | P0 | закрыт |
| LOGIN-02 | Ни один тест не прогонял DTO через production-валидатор | P0 | закрыт |
| LOGIN-03 | Установка объявляла успех, не проверив, что в панель можно войти | P0 | закрыт |
| LOGIN-04 | Оркестратор не знал контракта учётных данных и по умолчанию писал невалидный логин | P0 | закрыт |
| LOGIN-05 | Форма входа была строже сервера и запирала оператора после смены пароля | P1 | закрыт |
| LOGIN-06 | Индикация ошибки на форме входа рисовалась вокруг не того элемента | P2 | закрыт |
| LOGIN-07 | Класс символов записан диапазоном по опечатке в двух формах панели | P2 | закрыт |
| LOGIN-08 | `last_login_at` объявлен в схеме, но не записывался никогда | P3 | закрыт |
---
## LOGIN-01 — вход паниковал до проверки учётных данных
**Наблюдалось на хосте.** Каждый `POST /api/auth/login` отдавал HTTP 500. В
journal на каждый запрос:
```text
panic recovered:
Undefined validation function 'validateStr' on field 'Username'
controller/validator.go:115
controller/peer.go:50
```
В `apps/model/dto/auth.go` на поле стоял тег `validateStr`:
```go
Username *string `json:"username" ... validate:"required,min=6,max=32,validateStr"`
```
Правило с таким именем не регистрируется: при переименовании в `credentialStr`
правка не доехала до одного файла. `go-playground/validator` на неизвестный тег
ПАНИКУЕТ при разборе структуры — то есть до всякой проверки логина и пароля, —
а `gin.Recovery` превращал панику в HTTP 500.
Побочно это подтверждается тем, что зарегистрированное правило `credentialStr`
не использовалось **нигде**: переименование оставило после себя мёртвую
регистрацию и живую ссылку на несуществующее имя.
**Закрыто.** Контракт учётных данных вынесен в
`apps/service/admin_credentials.go` — по образцу уже существующего
`IsValidPeerName`. Правило `credentialStr` зовёт его, длина живёт ВНУТРИ
правила (два правила длины на одном поле уже приводили к необъяснимому отказу
на имени пира), а обработчик входа переехал в `apps/controller/auth.go`: пока
он лежал в `peer.go`, стек указывал на управление пирами — подсистему, не
имеющую к отказу отношения.
---
## LOGIN-02 — 311 Go-тестов не видели дефекта
**Наблюдалось по дереву.** `validator_test.go` проверял регексп `credentialStr`
НАПРЯМУЮ, в обход валидатора, а обработчика входа не касался ни один тест.
Важно, почему очевидная проверка не помогла бы. Прогон нулевого `LoginDto`
через `validate.Struct` дефекта НЕ ловит: цепочка правил поля обрывается на
первом несработавшем, поэтому на пустом `Username` проверка отказывает по
`required` и до испорченного тега не доходит.
**Закрыто** барьером, закрывающим КЛАСС, а не найденный экземпляр:
`TestEveryValidationTagIsRegistered` извлекает все теги `validate:"…"` из
`apps/model/**` и предъявляет каждый валидатору отдельно. Незарегистрированное
правило паникует так же, как паниковало в бою, — но на сборке. Барьер проверен
возвратом исходного тега: тест падает с именем правила и файлом.
Сверх него добавлены прогон `LoginDto` через production-валидатор, таблица
негативных случаев с ожидаемыми кодами причин и HTTP-регрессия обработчика — в
том числе за `gin.Recovery`, то есть ровно в той конфигурации, в которой дефект
наблюдался.
---
## LOGIN-03 — установка не проверяла, что в панель можно войти
**Наблюдалось на хосте.** Установка завершилась `INSTALL EXIT CODE: 0` при
полностью недоступной панели.
Smoke отвечал на вопрос «работает ли панель» тремя фактами: юнит активен,
`127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. Все три были
истинны. Факт `LISTEN` не означает, что панель функциональна, — RC2 это
буквально доказал.
**Закрыто.** `orchestrator/src/steps/smoke.ts` выполняет настоящий
`POST /api/auth/login` bootstrap-учётными данными и требует `code: 20000` с
непустым `accessToken`; успех определяется КОНВЕРТОМ, а не кодом HTTP — админка
отвечает `200 OK` и на отказ тоже. Отдельная отрицательная проба выполняется в
любом режиме операции и не зависит от актуальности пароля: заведомо неверные
учётные данные обязаны получить конверт отказа, а не 500. Учётные данные не
попадают ни в текст ошибки, ни в журнал.
---
## LOGIN-04 — оркестратор не знал контракта, который сам порождает
**Найдено при разборе смежного кода.** `orchestrator/src/config/env.ts` брал
логин как
```ts
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin")
```
`admin` — пять символов при минимуме панели в шесть. При пустом
`HY2XS_ADMIN_USER` установка проходила целиком и создавала учётную запись, под
которой невозможно войти. Про одно и то же имя существовало три расходящихся
умолчания: `admin` здесь, `hy2xsadmin` в `apps/dao/sqlite.go` и `hy2xsadmin` в
`package/config/hy2xs.env`.
Ни логин, ни операторский `HY2XS_ADMIN_INITIAL_PASSWORD` не проверялись против
контракта панели вовсе.
**Закрыто.** Оба значения проверяются при разборе окружения — той стороной,
которая их ПОРОЖДАЕТ: отказ, пришедший установщику, чинится одной строкой в
`hy2xs.env`, а неработающий вход на готовом сервере — переустановкой. Умолчание
сведено к `hy2xsadmin` во всех трёх местах. Проверяется и сгенерированный
пароль: генератор — такой же источник значения.
---
## LOGIN-05 — панель была строже сервера и запирала после смены пароля
**Найдено при разборе панели.** Границы пароля различались на трёх сторонах:
| Где | Логин | Пароль |
| --- | --- | --- |
| форма входа | 6-32 + набор | 6-**32** + набор |
| форма смены пароля | — | 6-**64** + набор |
| сервер | 6-32 + набор | 6-**64**, набора нет |
Следствий два, и оба закрывают панель. Пароль длиннее 32 символов назначался
штатной формой смены и после этого не вводился на форме входа: оператор терял
доступ после операции, которую панель ему же и предложила. А набор символов на
пароле отвергал значение, которое сервер принял бы, — в том числе
`HY2XS_ADMIN_INITIAL_PASSWORD`, заданный оператором со знаком вне набора.
**Закрыто.** Правило объявлено один раз в
`apps/frontend/src/constants/credentials.ts` и используется обеими формами;
набор символов с пароля снят — сервер его не предъявляет нигде, а проверка,
умеющая только запереть оператора, не защищает ничего. Совпадение с Go-контрактом
удерживается тестом, читающим Go-исходник.
---
## LOGIN-06 — красная рамка обводила не то, что видит оператор
**Наблюдалось на экране.** При отказе проверки красная рамка ложилась вокруг
одного лишь поля ввода: у логина начиналась после иконки пользователя, у пароля
обрывалась перед переключателем видимости, и ни одна её сторона не совпадала с
видимой границей поля.
Причина — специфичность, а не опечатка. Element Plus 2.14.5
(`theme-chalk/src/form-item.scss`) рисует состояние отказа на
`el-input__wrapper` селектором из ЧЕТЫРЁХ классов:
```text
.el-form-item.is-error .el-form-item__content .el-input__wrapper
```
Форма входа рисует видимую рамку поля на `el-form-item` — внутрь одного поля
кладутся иконка, ввод и переключатель видимости, — а штатную тень враппера
гасила селектором из трёх классов и проигрывала.
**Закрыто.** Индикация перенесена на элемент, который оператор и видит полем
(`.el-form-item.is-error`), а тень враппера гасится селектором, повторяющим
чужой и добавляющим атрибут scoped-стиля, — то есть выигрывает конкретностью, а
не `!important`. Сообщению об отказе оставлено место под полем:
`el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт вне
рамки.
Проверены остальные формы панели: собственная рамка на `el-form-item` вместе с
переопределением `el-input__wrapper` встречается только на форме входа. Смена
пароля, диалог пира и тулбары используют штатную рамку, где `is-error` попадает
точно.
**Что машина не докажет.** Совпадение рамки с границей поля на экране остаётся
ручной проверкой; тест закрепляет только наличие правил, которые её
обеспечивают.
---
## LOGIN-07 — класс символов записан диапазоном по опечатке
**Найдено при разборе панели.** В формах входа и смены пароля стояло
```text
/^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/
```
Дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
несли одну и ту же опечатку. В форме пира тот же класс уже был записан явно
(`_+\-=`) — договорённость в проекте существовала и до входа не доехала.
**Закрыто.** Класс записан явно и НЕ сужен: фактическое множество уже действует
на установленных серверах. Экранирование дефиса закреплено тестом — пока набор
выглядел опечаткой, любая попытка «навести порядок» развела бы панель и сервер
обратно.
---
## LOGIN-08 — время последнего входа не записывалось никогда
**Найдено при разборе.** Колонка `last_login_at` объявлена в схеме и в entity,
`service.UpdateAdminLastLoginAt` существовал — и не вызывался ниоткуда.
**Закрыто.** Отметка ставится в `service.Login`, сразу после успешной проверки
пароля: это единственная дверь, и записать её оттуда невозможно забыть. Отказ
записи вход НЕ отменяет — учётные данные уже подтверждены, — но пишется в
журнал уровнем error: неписаная отметка есть расхождение между тем, что
показывает панель, и тем, что произошло.
---
## Отдельно: диагностика на хосте
`systemctl cat` открывает `less`, из-за чего вставленный следом блок
перемешивается с pager. Для воспроизводимых прогонов используется
```bash
SYSTEMD_PAGER=cat systemctl cat hy2xs-admin.service
```
или `systemctl --no-pager cat hy2xs-admin.service`. Юнит `hy2xs-admin.service`
проверен и к дефекту отношения не имеет.
## Что делать с установленным RC2
На хосте ничего чинить вручную не нужно и не следует: hotpatch бинарника на
проде и перезалив содержимого уже опубликованного `v1.0.0-rc2` противоречат
воспроизводимости и immutable provenance, вокруг которых построен продукт.
Правильный путь — исправленный source и новая сборка, а хост переустанавливается
с нуля, чтобы проверка шла по тому же clean-host сценарию, а не поверх
установленного RC2.