a8407cf16b
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.
134 lines
6.8 KiB
Go
134 lines
6.8 KiB
Go
package controller
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"hy2xs-admin/service"
|
||
)
|
||
|
||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||
//
|
||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||
// оператора в панель.
|
||
//
|
||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||
//
|
||
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||
// не увидела бы расхождения между копией и правилом.
|
||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||
|
||
for _, symbol := range strings.Split(historical, "") {
|
||
candidate := "admin" + symbol
|
||
if !service.IsValidAdminUsername(candidate) {
|
||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||
}
|
||
}
|
||
|
||
for _, rejected := range []string{
|
||
"", // не задано
|
||
"admi", // короче шести символов
|
||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||
"admin пробел", // пробел
|
||
"админ1", // кириллица
|
||
"admin\n1", // перевод строки
|
||
"admin'1", // апостроф вне набора
|
||
} {
|
||
if service.IsValidAdminUsername(rejected) {
|
||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||
// обязан описывать тот же класс, что и регексп.
|
||
//
|
||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||
// оператор читает набор, которого правило не принимает.
|
||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) {
|
||
t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength)
|
||
}
|
||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) {
|
||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength)
|
||
}
|
||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) {
|
||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength)
|
||
}
|
||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) {
|
||
t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength)
|
||
}
|
||
|
||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||
punctuation := strings.Fields(service.AdminUsernameCharset)
|
||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||
if !service.IsValidAdminUsername("admin" + symbol) {
|
||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator
|
||
// в правилах `min`/`max` на строке.
|
||
//
|
||
// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной
|
||
// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА.
|
||
func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) {
|
||
if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) {
|
||
t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength)
|
||
}
|
||
if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) {
|
||
t.Error("пароль длиннее максимума принят")
|
||
}
|
||
if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) {
|
||
t.Error("пароль короче минимума принят")
|
||
}
|
||
if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) {
|
||
t.Error("пароль длиной ровно в минимум отклонён")
|
||
}
|
||
}
|
||
|
||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||
// одну таблицу и не имеют права требовать разного.
|
||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||
accepted := []string{
|
||
"client-01",
|
||
"alpha1",
|
||
"bootstrap-admin-peer",
|
||
strings.Repeat("a", 6),
|
||
strings.Repeat("a", 32),
|
||
}
|
||
for _, name := range accepted {
|
||
if !service.IsValidPeerName(name) {
|
||
t.Errorf("имя %q отклонено, ожидался приём", name)
|
||
}
|
||
}
|
||
|
||
rejected := []string{
|
||
"",
|
||
" ",
|
||
"pc1",
|
||
strings.Repeat("a", 33),
|
||
"peer name",
|
||
"peer\nname",
|
||
"peer/name",
|
||
"peer:name",
|
||
"peer.name",
|
||
"пир-01",
|
||
}
|
||
for _, name := range rejected {
|
||
if service.IsValidPeerName(name) {
|
||
t.Errorf("имя %q принято, ожидался отказ", name)
|
||
}
|
||
}
|
||
}
|