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` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал.
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
+2 -2
View File
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа, набор символов не ограничен; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
//
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
// продукта обязана называться своим именем в стеке.
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
+615
View File
@@ -0,0 +1,615 @@
package controller
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
)
// Контракт входа в панель.
//
// Что здесь произошло и почему тестов было мало. В RC2 каждый
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
// `validateStr`, правило с таким именем не регистрировалось, и
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
// делал никто.
//
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
//
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
// правило (закрывает КЛАСС, а не найденный экземпляр);
// правила — LoginDto проходит через тот самый validate, что и в бою;
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
// --------------------------------------------------------------- структура ---
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
//
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
// первом несработавшем, поэтому на пустом Username проверка отказывает по
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
func TestEveryValidationTagIsRegistered(t *testing.T) {
root := filepath.Join("..", "model")
users := map[string]map[string]struct{}{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
return nil
}
source, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
for _, rule := range splitValidationRules(string(match[1])) {
if users[rule] == nil {
users[rule] = map[string]struct{}{}
}
users[rule][filepath.ToSlash(path)] = struct{}{}
}
}
return nil
})
if err != nil {
t.Fatalf("не удалось прочитать исходники модели: %v", err)
}
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
// «теги найдены», а не их количество.
if len(users) < 5 {
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
}
for rule, files := range users {
assertRuleIsRegistered(t, rule, files)
}
}
// splitValidationRules разбирает содержимое тега на отдельные правила.
//
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
// существовать ровно так же.
func splitValidationRules(tag string) []string {
var rules []string
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
rule = strings.TrimSpace(rule)
if rule == "" || rule == "-" {
continue
}
rules = append(rules, rule)
}
return rules
}
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
t.Helper()
where := make([]string, 0, len(files))
for file := range files {
where = append(where, file)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Errorf(
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
rule, strings.Join(where, ", "), recovered,
)
}
}()
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
// результат проверки здесь не важен, важно её отсутствие паники.
_ = validate.Var("hy2xsadmin", rule)
}
// ------------------------------------------------------------------ правила ---
func loginDto(username string, password string) dto.LoginDto {
return dto.LoginDto{Username: &username, Pass: &password}
}
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
// видят только приведённый вход.
func normalizedAndValidated(login dto.LoginDto) error {
login.Normalize()
return validate.Struct(&login)
}
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
func TestLoginDtoPassesProductionValidator(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка LoginDto паникует: %v", recovered)
}
}()
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
t.Fatalf("корректный LoginDto отклонён: %v", err)
}
}
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
cases := []struct {
name string
username string
password string
code string
field string
}{
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин длиннее максимума", strings.Repeat("a", service.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"},
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
}
}()
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
if err == nil {
t.Fatal("некорректный вход принят")
}
described := describeValidationErrors(err)
if !hasFieldReason(described, testCase.field, testCase.code) {
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
}
})
}
}
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
for _, reason := range described {
if reason.Field == field && reason.Code == code {
return true
}
}
return false
}
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
//
// Оператору негде посмотреть, какие символы допустимы в логине: значение
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
if err == nil {
t.Fatal("некорректный логин принят")
}
for _, reason := range describeValidationErrors(err) {
if reason.Code != constant.ErrCodeCredentialStr {
continue
}
if reason.Params["charset"] != service.AdminUsernameCharset {
t.Errorf("причина не называет набор символов: %+v", reason.Params)
}
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) {
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
}
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) {
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
}
return
}
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
}
// ---------------------------------------------------------------- контракты ---
// Границы пароля объявлены в трёх местах и обязаны совпадать.
//
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
// принимала 64 символа, форма входа — 32. Тег структуры не умеет ссылаться на
// константу, поэтому равенство удерживается здесь.
func TestPasswordBoundsAreOneContract(t *testing.T) {
for _, subject := range []struct {
name string
typ reflect.Type
field string
}{
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
} {
field, ok := subject.typ.FieldByName(subject.field)
if !ok {
t.Fatalf("%s: поле не найдено", subject.name)
}
bounds := map[string]int{}
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
name, param, found := strings.Cut(rule, "=")
if !found || (name != "min" && name != "max") {
continue
}
value, err := strconv.Atoi(param)
if err != nil {
t.Fatalf("%s: граница %q не число", subject.name, rule)
}
bounds[name] = value
}
if bounds["min"] != service.AdminPasswordMinLength {
t.Errorf("%s: min=%d, а контракт — %d", subject.name, bounds["min"], service.AdminPasswordMinLength)
}
if bounds["max"] != service.AdminPasswordMaxLength {
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
}
}
}
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
//
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
// который сервер принял бы. Панель обязана держаться этого же контракта, и
// проверка существует, чтобы правило не «навели» обратно.
func TestPasswordHasNoCharsetRule(t *testing.T) {
for _, subject := range []struct {
name string
typ reflect.Type
field string
}{
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
} {
field, _ := subject.typ.FieldByName(subject.field)
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
if rule == "credentialStr" || rule == "peerName" {
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
}
}
}
}
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
//
// Два правила длины на одном поле уже приводили к отказу, который невозможно
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
// отвергали одно и то же значение, а панель показывала «invalid».
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
if !ok {
t.Fatal("LoginDto.Username не найдено")
}
rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range rules {
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
}
}
var hasCredentialRule bool
for _, rule := range rules {
if rule == "credentialStr" {
hasCredentialRule = true
}
}
if !hasCredentialRule {
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
}
}
// --------------------------------------------------------------------- HTTP ---
func newAuthTestDB(t *testing.T, username string, password string) int64 {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
// Токен подписывается ключом из таблицы config. Без него успешный вход
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
// вход от неработающего.
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
t.Fatalf("не удалось записать ключ подписи: %v", err)
}
hash, err := util.HashPassword(password)
if err != nil {
t.Fatalf("не удалось захешировать пароль: %v", err)
}
name := username
status := int64(1)
tokenVersion := int64(1)
force := int64(0)
changedAt := time.Now().UnixMilli()
id, err := dao.SaveAdminUser(entity.AdminUser{
Username: &name,
PasswordHash: &hash,
Status: &status,
TokenVersion: &tokenVersion,
ForcePasswordChange: &force,
PasswordChangedAt: &changedAt,
})
if err != nil {
t.Fatalf("не удалось создать администратора: %v", err)
}
return id
}
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
//
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
if recovery {
engine.Use(gin.Recovery())
}
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
}
return recorder.Code, result
}
func postLogin(t *testing.T, body any) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
return postLoginRaw(t, payload, false)
}
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
})
if status != http.StatusOK {
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
}
if result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
var issued struct {
TokenType string `json:"tokenType"`
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(result.Data, &issued); err != nil {
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
}
if strings.TrimSpace(issued.AccessToken) == "" {
t.Fatal("выдан пустой токен")
}
if issued.TokenType != constant.TokenType {
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
}
}
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
//
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
bodies := [][]byte{
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
[]byte(`{"username":"","pass":""}`),
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
[]byte(`{"username":"hy2xsadmin"}`),
[]byte(`{}`),
[]byte(`{"username":123,"pass":false}`),
[]byte(`не json вовсе`),
[]byte(``),
}
for _, body := range bodies {
status, result := postLoginRaw(t, body, true)
if status != http.StatusOK {
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
continue
}
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
}
}
}
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
})
if result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
if _, found := errorFor(t, result, ""); !found {
t.Fatalf("отказ без причины: %+v", result)
}
var hasCode bool
for _, reason := range result.Errors {
if reason.Code == constant.ErrCodeInvalidCredentials {
hasCode = true
}
}
if !hasCode {
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
}
}
// Несуществующий администратор неотличим от неверного пароля.
//
// Иначе форма входа превращается в способ проверять существование имён.
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
if missing.Code != wrong.Code || missing.Message != wrong.Message {
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
}
}
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
// строки на конце. Раньше это давало «неверный логин или пароль».
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": " hy2xsadmin\n",
"pass": "bootstrap-password",
})
if result.Code != constant.CodeSuccess {
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
}
}
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
// часть значения, а не мусор ввода.
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
if exact.Code != constant.CodeSuccess {
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
}
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
if trimmed.Code == constant.CodeSuccess {
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
}
}
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
//
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
password := strings.Repeat("a", service.AdminPasswordMaxLength)
newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if result.Code != constant.CodeSuccess {
t.Fatalf("пароль предельной длины отклонён: %+v", result)
}
}
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
before, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
}
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}); result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось перечитать администратора: %v", err)
}
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
t.Fatal("время входа не записано")
}
}
// Неудачная попытка отметку о входе НЕ ставит.
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}); result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
}
}
-21
View File
@@ -46,27 +46,6 @@ func resolveID(c *gin.Context) (int64, error) {
return parsed, nil
}
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
func PagePeer(c *gin.Context) {
peerPageDto, err := validateField(c, dto.PeerPageDto{})
if err != nil {
+27 -15
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/gin-gonic/gin"
@@ -60,21 +59,22 @@ func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// credentialStrPattern — набор символов логина и пароля администратора.
// validateCredentialStr — единственное правило имени администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
// Набор символов и длина берутся из service по той же причине, что и у имени
// пира: собственная копия правила в слое контроллеров уже расходилась с
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
// третье правило.
//
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
// была ловушка: у поля-указателя `required` считает непустым сам факт
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
// у него на самом деле.
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String()
return field == "" || credentialStrPattern.MatchString(field)
return service.IsValidAdminUsername(f.Field().String())
}
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
@@ -202,8 +202,20 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
// Причина называет набор и границы ровно так же, как это делает
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
// вопрос оператора: набор символов логина ему негде посмотреть, а
// отказ по длине этой фразой описывался как отказ по символам.
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength),
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength),
"charset": service.AdminUsernameCharset,
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов из набора %s",
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset,
)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
+63 -8
View File
@@ -7,28 +7,34 @@ import (
"hy2xs-admin/service"
)
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
// набор символов не ограничивает, и сужение правила означало бы, что установка
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
// оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestCredentialCharsetIsUnchanged(t *testing.T) {
//
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
// не увидела бы расхождения между копией и правилом.
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !credentialStrPattern.MatchString(candidate) {
if !service.IsValidAdminUsername(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"", // не задано
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
@@ -36,12 +42,61 @@ func TestCredentialCharsetIsUnchanged(t *testing.T) {
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if credentialStrPattern.MatchString(rejected) {
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) {
@@ -0,0 +1,49 @@
/**
* Контракт учётных данных администратора на стороне панели.
*
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
* формах и разошлись и с сервером, и друг с другом:
*
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
* форма смены пароля пароль 6-64 + набор
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
*
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
* формой смены, мог оказаться длиннее 32 символов и форма входа отказывалась
* его отправлять: оператор терял доступ после операции, которую панель ему же и
* предложила. А набор символов на пароле отвергал значение, которое сервер
* принял бы, панель была строже сервера там, где она не имеет на это права.
*
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
* (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts.
*/
export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32;
/**
* Набор символов логина в записи регекспа.
*
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
* множеством сужать его нельзя, оно уже работает на установленных
* серверах, но записан явно: пока он выглядел опечаткой, любая попытка
* «навести порядок» развела бы панель и сервер обратно.
*/
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
export const ADMIN_USERNAME_PATTERN = new RegExp(
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
);
/** Тот же набор в том виде, в каком его показывают оператору. */
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/**
* Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни
* при установке, ни при смене, и панель не имеет права отвергать значение,
* которое сервер принял бы.
*/
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
+7 -3
View File
@@ -21,8 +21,12 @@ export default {
password: "Password",
login: "Login",
capsLockOn: "Caps lock is On",
usernameFormatIncorrect: "Username format is incorrect",
passwordFormatIncorrect: "Password format is incorrect",
},
// Требования к учётным данным администратора: общие для формы входа и формы
// смены пароля. См. комментарий в ru.ts.
credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordLength: "Password: {min} to {max} characters",
},
dashboard: {
stale: "Dashboard data is stale. Retrying automatically...",
@@ -161,7 +165,7 @@ export default {
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: contains characters that are not allowed",
credential_format: "“{field}”: {min} to {max} characters from {charset}",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid:
+18 -3
View File
@@ -19,8 +19,19 @@ export default {
password: "Пароль",
login: "Войти",
capsLockOn: "Caps Lock включён",
usernameFormatIncorrect: "Неверный формат логина",
passwordFormatIncorrect: "Неверный формат пароля",
},
// Требования к учётным данным администратора. Фразы общие для формы входа и
// формы смены пароля: требование одно, и второй его формулировки быть не
// должно — расхождение здесь означало бы, что оператору обещают разное про
// одно и то же поле.
//
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
// одного способа узнать, что именно от него хотят: набор символов логина
// приходит из hy2xs.env, и посмотреть его в панели негде.
credentials: {
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
passwordLength: "Пароль: от {min} до {max} символов",
},
dashboard: {
stale:
@@ -167,7 +178,11 @@ export default {
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
// Сервер присылает границы и набор в params — фраза называет требование
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
@@ -33,6 +33,10 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/admin";
import {
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
} from "@/constants/credentials";
const { t } = useI18n();
const route = useRoute();
@@ -46,7 +50,29 @@ const form = reactive({
newPassword: "",
});
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
//
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
// фразой «Некорректное значение», не называя ни одного требования.
//
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
// пароль, назначенный здесь, невозможно было ввести там.
//
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
const passwordRule = {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
message: t("credentials.passwordLength", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
}),
trigger: ["change", "blur"] as string[],
};
const rules: FormRules = {
oldPassword: [
{
@@ -54,11 +80,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
newPassword: [
{
@@ -66,11 +88,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
};
+79 -4
View File
@@ -83,6 +83,14 @@ import { useAdminStore } from "@/store/modules/admin";
// Зависимость API
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AdminLoginDto } from "@/api/admin/types";
import {
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN,
} from "@/constants/credentials";
const adminStore = useAdminStore();
const route = useRoute();
@@ -114,6 +122,14 @@ const loginForm = ref<AdminLoginDto>({
pass: "",
});
/**
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
*
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
* символов, из-за чего форма входа отказывалась отправлять пароль, который
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
* которая умеет только запереть оператора и ничего не защищает, не проверка.
*/
const loginRules = {
username: [
{
@@ -122,8 +138,12 @@ const loginRules = {
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: t("login.usernameFormatIncorrect"),
pattern: ADMIN_USERNAME_PATTERN,
message: t("credentials.usernameFormat", {
min: ADMIN_USERNAME_MIN_LENGTH,
max: ADMIN_USERNAME_MAX_LENGTH,
charset: ADMIN_USERNAME_CHARSET,
}),
trigger: ["change", "blur"],
},
],
@@ -134,8 +154,12 @@ const loginRules = {
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: t("login.passwordFormatIncorrect"),
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
message: t("credentials.passwordLength", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
}),
trigger: ["change", "blur"],
},
],
@@ -205,10 +229,61 @@ const handleLogin = () => {
}
}
// Видимое поле формы входа это `el-form-item`, а не `el-input`.
//
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
// среднюю из них.
//
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
// рисует состояние отказа на `el-input__wrapper` правилом
//
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
//
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
// селектором из трёх и проигрывало по специфичности. В результате красная
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
// не совпадала с видимой границей поля.
//
// Чинится это не увеличением специфичности ради победы, а переносом индикации
// на тот элемент, который оператор и видит полем.
.el-form-item {
background: var(--subMenuBg);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px;
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
// полю сверху.
margin-bottom: 26px;
&.is-error {
border-color: var(--el-color-danger);
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
// класса ниже и проигрывало из-за чего красный прямоугольник вокруг
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
// не сильнее чужого, а конкретнее.
:deep(.el-form-item__content .el-input__wrapper) {
&,
&:hover,
&:focus,
&.is-focus {
box-shadow: none;
}
}
}
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
// висит на сдвиг левее всего, что находится над ним.
:deep(.el-form-item__error) {
padding-top: 6px;
padding-left: 12px;
}
}
.el-input {
+11
View File
@@ -1,5 +1,16 @@
package dto
// Границы пароля здесь и в LoginDto обязаны совпадать.
//
// Пока они расходились, панель запирала оператора снаружи после операции,
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в
// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не
// подставить.
//
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
type AdminChangePasswordDto struct {
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
+47 -1
View File
@@ -1,6 +1,52 @@
package dto
// Контракт формы входа.
//
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
// тега.
//
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
// Два правила на одном поле уже приводили к отказу, который невозможно
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
// была бы той же ошибкой.
//
// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
// только запирать оператора с паролем, который сервер принял бы.
//
// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим
// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не
// подставить.
type LoginDto struct {
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
}
// Normalize: незаполненное поле обязано выглядеть незаполненным.
//
// У поля-указателя `required` считает значение заданным по ненулевому
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
// получало отказ по набору символов и по длине — то есть оператор, отправивший
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
//
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
// значение регулярно приезжает с пробелом или переводом строки на конце, и
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
// связать с причиной.
//
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
// законная часть значения, и молчаливое его удаление означало бы, что панель
// отправляет не то, что ввёл оператор.
func (d *LoginDto) Normalize() {
blankToNil(&d.Username)
emptyToNil(&d.Pass)
}
+18
View File
@@ -66,6 +66,24 @@ func trimValue(field *string) {
*field = strings.TrimSpace(*field)
}
// emptyToNil: пустая строка означает «не задано», и ничего больше.
//
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
// символов не ограничен, пробел по краям является частью значения, и
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
// по длине: у поля-указателя `required` считает значение заданным по самому
// факту ненулевого указателя.
func emptyToNil(field **string) {
if *field == nil {
return
}
if **field == "" {
*field = nil
}
}
// zeroToNil: ноль у необязательного числового поля означает «не задано».
//
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// Контракт учётных данных администратора — ОДИН на весь продукт.
//
// Зачем он вынесен сюда. Про логин и пароль администратора одновременно знали
// четыре места: регексп в слое контроллеров, границы длины в тегах LoginDto,
// правило формы входа в панели и правило формы смены пароля в ней же. Ни одно
// из них не ссылалось на остальные, и все четыре успели разойтись:
//
// - тег `validateStr` на Username пережил переименование правила и остался
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
// а gin.Recovery превращал панику в HTTP 500;
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
// форма входа отказывалась отправлять: панель запирала оператора снаружи
// после операции, которую сама же и предложила;
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
// завершалась успешно, а войти в панель было нельзя.
//
// Общее у всех трёх — контракт существовал только как совпадение литералов,
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
// IsValidAdminUsername, а расхождение с панелью и оркестратором ловится
// тестами, читающими эти же константы.
const (
AdminUsernameMinLength = 6
AdminUsernameMaxLength = 32
// Границы пароля. Верхняя выбрана не круглым числом: bcrypt читает первые
// 72 БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо
// ниже — иначе два разных пароля, совпадающие в начале, окажутся одним и
// тем же паролем.
AdminPasswordMinLength = 6
AdminPasswordMaxLength = 64
)
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
// собственную копию того же множества.
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
var adminUsernamePattern = regexp.MustCompile(
"^[" + adminUsernameCharacterClass + "]{" +
strconv.Itoa(AdminUsernameMinLength) + "," +
strconv.Itoa(AdminUsernameMaxLength) + "}$",
)
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
//
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
// значение приходит из файла окружения, а не из DTO.
func IsValidAdminUsername(username string) bool {
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
}
// IsValidAdminPassword проверяет ТОЛЬКО границы длины.
//
// Набор символов пароля не ограничен, и это осознанно. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы, то есть умеет только запереть оператора.
//
// Длина считается в РУНАХ: ровно так её считает go-playground/validator в
// правилах `min`/`max` на строке, и расхождение означало бы, что пароль с
// кириллицей проходит одну проверку и отказывается на другой.
func IsValidAdminPassword(password string) bool {
length := utf8.RuneCountInString(password)
return length >= AdminPasswordMinLength && length <= AdminPasswordMaxLength
}
+18
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil {
return "", false, tokenErr
}
// Отметка о входе ставится здесь, а не в слое контроллеров.
//
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
// проверка пароля — единственная дверь, и она здесь одна.
//
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
// отметка — расхождение между тем, что показывает панель, и тем, что
// произошло.
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
}
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
// с поддержкой самого формата: единственный источник этого требования —
// флаг force_password_change, который ставит установка.
@@ -0,0 +1,262 @@
# 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.
+2
View File
@@ -25,12 +25,14 @@
| Дата | Версия | Коммит источника | Вид | Вердикт |
| --- | --- | --- | --- | --- |
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
| 2026-09-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) |
## Открытые дефекты приёмки
| Прогон | Дефекты |
| --- | --- |
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
| 2026-09-04, `1.0.0-rc2` | [LOGIN-01…LOGIN-08](2026-09-04-v1.0.0-rc2-runtime-findings.md) — все закрыты |
## Разборы кода между прогонами
+87
View File
@@ -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`), и фраза панели обязана их использовать: правило одно
и проверяет и длину, и набор, поэтому описывать его только через символы —
значит описывать отказ по длине неверно.
+38
View File
@@ -542,6 +542,44 @@ production-профилем, а не ищет подстроки. Проверя
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл.
## Smoke проверяет, что панель ВПУСКАЕТ
Открытый порт — это не работающая панель.
До RC3 установка отвечала на вопрос «работает ли панель» тремя фактами: юнит
активен, `127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. RC2
доказал, что все три бывают истинными одновременно с полностью недоступной
панелью: на поле логина стоял тег незарегистрированного правила валидации,
`POST /api/auth/login` паниковал ещё до проверки учётных данных, `gin.Recovery`
превращал панику в HTTP 500 — и установка завершалась `INSTALL EXIT CODE: 0`.
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
| Проба | Когда | Что требуется |
| --- | --- | --- |
| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
Детали, которые здесь существенны:
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
успешный вход любой отказ, то есть не проверяла бы ничего;
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
панель, которая пускает и не выдаёт сессию;
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
объявила бы рабочую установку сломанной;
- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
журнал установки и в diagnostics-бандл;
- **положительная проба install-only.** На `reconfigure` пароль в
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
панели, и требовать по нему вход значило бы ронять законную операцию.
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
воспроизводит дефект RC2.
## Редактирование секретов
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
+28
View File
@@ -144,6 +144,34 @@ anycast. Отсутствие A-записи фатально при любом
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
### Учётные данные администратора проверяются при разборе окружения
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
потом принимает **форма входа в панель**. Оркестратор проверяет их против того
же контракта, что и админка (`apps/service/admin_credentials.go`):
| Переменная | Требование | Значение по умолчанию |
| --- | --- | --- |
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа, набор не ограничен | генерируется |
Значение вне контракта **роняет установку** с явным текстом, называющим границы
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
в `hy2xs.env`, а неработающий вход на готовом сервере — переустановкой.
Проверяется и сгенерированный пароль, а не только заданный оператором:
генератор — такой же источник значения.
Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя
учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» —
отказом, который невозможно связать с причиной.
Значение по умолчанию совпадает в трёх местах и обязано совпадать:
`package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное
значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять
символов при минимуме панели в шесть, — и установка завершалась
`INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти.
### Immutable-bootstrap контракт
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
+32
View File
@@ -52,6 +52,38 @@
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500
36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными
37. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
## C0. Панель обязана впускать, а не слушать порт
Проверки 1-4 отвечают на вопрос «поднялось ли», и ни одна из них не отвечает на
вопрос «работает ли». RC2 показал разницу: юнит активен, `127.0.0.1:8080` в
`LISTEN`, `/healthz` отвечает `ok: true` — и `POST /api/auth/login` отдаёт
HTTP 500 на каждый запрос, потому что валидатор паникует на теге
несуществующего правила. Установка при этом завершилась `INSTALL EXIT CODE: 0`.
Поэтому вход в панель проверяется **настоящим запросом**, а не косвенными
признаками, и эта проверка встроена в smoke оркестратора — то есть релиз с
недоступной панелью физически не может завершиться успешной установкой.
Ручной эквивалент:
```bash
# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод.
read -r -s BOOTSTRAP_PASS < <(sudo grep '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)
BOOTSTRAP_USER="$(sudo grep '^ADMIN_USER=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)"
curl -sS --max-time 5 -X POST \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)'
unset BOOTSTRAP_PASS
```
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
действующая сессия администратора.
## C1. Семантический smoke конфига
+19 -2
View File
@@ -4,6 +4,8 @@ import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
assertValidAdminPassword,
assertValidAdminUsername,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "./profile";
@@ -250,8 +252,23 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
uiBindHost,
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
// Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с
// запасным значением в apps/dao/sqlite.go. Раньше здесь стояло `admin`:
// пять символов при минимуме панели в шесть, и третье расходящееся
// умолчание про одно и то же имя. Установка при этом завершалась успешно, а
// войти было нельзя — отказ приходил не установщику, а оператору, и уже без
// объяснения.
adminUser: assertValidAdminUsername(
"HY2XS_ADMIN_USER",
requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "hy2xsadmin")
),
// Проверяется и сгенерированный пароль, а не только заданный оператором:
// генератор — такой же источник значения, и его расхождение с контрактом
// панели обязано ронять установку, а не всплывать на форме входа.
adminInitialPassword: assertValidAdminPassword(
"HY2XS_ADMIN_INITIAL_PASSWORD",
valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD)
),
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
+83
View File
@@ -41,6 +41,89 @@ export const HY2XS_TARGET_ARCH = "amd64";
export const ADMIN_API_BASE = "/api";
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
/**
* Путь формы входа в панель. Смысл тот же, что у HYSTERIA_MACHINE_AUTH_PATH:
* это runtime-контракт продукта, по которому smoke проверяет, что установка
* оставила после себя РАБОТАЮЩУЮ панель, а не просто открытый порт.
*/
export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
/**
* Контракт учётных данных администратора.
*
* Зачем он здесь. Оркестратор задаёт имя и первый пароль администратора, а
* принимает их панель по правилам, которых оркестратор не знал вовсе.
* Следствие было не теоретическим: значением по умолчанию здесь стояло
* `admin` пять символов при минимуме в шесть, и такая установка
* завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно
* войти. Проверять контракт обязана та сторона, которая значение ПОРОЖДАЕТ:
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
* готовом сервере переустановкой.
*
* Значения обязаны совпадать с apps/service/admin_credentials.go; сверка
* выполняется тестом admin-credentials.test.ts, который читает Go-исходник.
*/
export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32;
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
/**
* Набор символов логина в записи регекспа.
*
* Дефис ЭКРАНИРОВАН намеренно. В исходной записи `_+-=` он экранирован не был,
* из-за чего `+-=` образовывал диапазон и молча впускал `, - . / 0-9 : ; < =`.
* Здесь перечислено то же самое ФАКТИЧЕСКОЕ множество, но явно: сужать его в
* одиночку нельзя оно уже действует на установленных серверах.
*/
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
export const ADMIN_USERNAME_PATTERN = new RegExp(
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
);
/** Тот же набор в том виде, в каком его показывают оператору. */
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/**
* Проверка логина администратора против контракта панели.
*
* Возвращает значение, а не булево: вызывающий обязан использовать именно
* проверенное с обрезанными краями, иначе пробел из hy2xs.env уедет в базу
* и вход снова перестанет работать по причине, которую негде увидеть.
*/
export function assertValidAdminUsername(name: string, value: string): string {
const username = value.trim();
if (!ADMIN_USERNAME_PATTERN.test(username)) {
throw new Error(
`invalid ${name}: панель принимает от ${ADMIN_USERNAME_MIN_LENGTH} до ${ADMIN_USERNAME_MAX_LENGTH} ` +
`символов из набора ${ADMIN_USERNAME_CHARSET}. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
);
}
return username;
}
/**
* Проверка пароля администратора против контракта панели.
*
* Набор символов НЕ проверяется: сервер его не ограничивает ни при установке,
* ни при смене пароля. Проверяется только длина и в РУНАХ, ровно так её
* считает валидатор админки. `String.length` считает единицы UTF-16, и пароль
* из эмодзи прошёл бы здесь и отказался бы на форме входа.
*/
export function assertValidAdminPassword(name: string, value: string): string {
const length = [...value].length;
if (length < ADMIN_PASSWORD_MIN_LENGTH || length > ADMIN_PASSWORD_MAX_LENGTH) {
throw new Error(
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов, получено ${length}. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
);
}
return value;
}
/**
* Где оркестратор живёт на установленном хосте.
*
+129 -1
View File
@@ -2,7 +2,7 @@ import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log";
import { readText } from "../lib/fs";
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
import { assertEffectiveFirewallIsOurs } from "./firewall";
@@ -209,6 +209,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
);
}
await assertAdminLoginWorks(context);
await retry(
"trafficStats valid secret",
10,
@@ -244,6 +246,132 @@ export async function smoke(context: RuntimeContext): Promise<void> {
await assertEffectiveHysteriaVersion(context);
}
/**
* Панель обязана ВПУСКАТЬ, а не просто слушать порт.
*
* Почему эта проверка появилась. До неё установка отвечала на вопрос «работает
* ли панель» тремя фактами: юнит активен, `127.0.0.1:8080` в LISTEN, `/healthz`
* отвечает `ok:true`. RC2 доказал, что все три могут быть истинными
* одновременно с полностью недоступной панелью: на поле логина стоял тег
* несуществующего правила валидации, `POST /api/auth/login` паниковал ещё до
* проверки учётных данных, gin.Recovery превращал панику в HTTP 500 и
* установка завершалась `INSTALL EXIT CODE: 0`.
*
* Разница между «порт открыт» и «оператор может войти» это весь продукт,
* поэтому smoke выполняет НАСТОЯЩИЙ вход теми учётными данными, которые создал
* установщик.
*
* Что здесь важно по деталям:
*
* - тело собирается JSON.stringify, а не интерполяцией в строку. Пароль
* задаёт оператор, и кавычка или обратный слеш в нём иначе сломали бы не
* панель, а сам запрос и проверка объявила бы рабочую установку сломанной;
* - обе команды идут через runReadOnlySecret: этот раннер не кладёт команду в
* текст ошибки, а команда несёт пароль администратора. Ошибка проверки
* уезжает в журнал и в diagnostics-бандл;
* - положительная проба выполняется только на install. На reconfigure пароль в
* bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его
* в панели, и требовать по нему вход значило бы ронять законную операцию;
* - отрицательная проба выполняется ВСЕГДА и от актуальности пароля не
* зависит. Именно она воспроизводит дефект RC2: заведомо неверные учётные
* данные обязаны получить осмысленный отказ, а не 500.
*/
async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`;
const rejectedPayload = JSON.stringify({
username: "hy2xsadmin",
pass: "definitely-not-the-admin-password"
});
const rejectedStatus = await retry(
"admin login rejects wrong credentials",
10,
1000,
async () =>
runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`,
(code) => code.trim() === "200",
(code, error) =>
new Error(
`admin login answered HTTP ${code ?? String(error)} for invalid credentials: ` +
`панель обязана отвечать конвертом отказа, а HTTP 500 здесь означает, что запрос ` +
`не доживает до проверки учётных данных`
)
);
info(`admin login rejects wrong credentials with HTTP ${rejectedStatus.trim()}`);
if (context.mode !== "install") {
return;
}
const adminUser = (
await runReadOnlySecret`grep '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`
).trim();
const adminPassword = (
await runReadOnlySecret`grep '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`
).trim();
if (!adminUser) {
throw new Error("admin username is empty in bootstrap secret file");
}
if (!adminPassword) {
throw new Error("admin initial password is empty in bootstrap secret file");
}
const payload = JSON.stringify({ username: adminUser, pass: adminPassword });
const response = await retry(
"admin login with bootstrap credentials",
10,
1000,
async () =>
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${payload} ${loginUrl}`,
(body) => isSuccessfulLogin(body),
(body, error) =>
new Error(
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`
)
);
info(`admin login accepted: ${describeIssuedToken(response)}`);
}
/**
* Успех определяется по КОНВЕРТУ, а не по коду HTTP.
*
* Админка отвечает `200 OK` и на отказ тоже: причина живёт в поле `code`
* ответа. Проверка «HTTP 200» приняла бы за успешный вход любой отказ то есть
* ровно ничего бы не проверяла.
*
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
* панель, которая пускает и не выдаёт сессию.
*/
function isSuccessfulLogin(body: string): boolean {
return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body);
}
/**
* Причина отказа БЕЗ тела ответа.
*
* Тело сюда попасть не может: в ответе успешного входа лежит токен доступа, а
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
*/
function describeLoginFailure(body: string | undefined, error: unknown): string {
if (body === undefined) {
return `запрос не выполнен: ${String(error)}`;
}
const code = body.match(/"code"\s*:\s*(\d+)/);
if (code) {
return `ответ с code=${code[1]} и без токена доступа`;
}
return "ответ не является конвертом API админки";
}
/** Подтверждение выдачи токена без самого токена. */
function describeIssuedToken(body: string): string {
const tokenType = body.match(/"tokenType"\s*:\s*"([^"]*)"/);
return tokenType ? `выдан токен типа ${tokenType[1]}` : "выдан токен доступа";
}
/**
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
+280
View File
@@ -0,0 +1,280 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { parseRuntimeEnv } from "../src/config/env";
import {
ADMIN_LOGIN_PATH,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN
} from "../src/config/profile";
import { baselineConfig, envText } from "./fixtures";
/**
* Контракт учётных данных администратора на стороне оркестратора.
*
* Оркестратор ПОРОЖДАЕТ имя и первый пароль администратора, а принимает их
* панель по правилам, которых он раньше не знал вовсе. RC2 показал, чем это
* заканчивается: установка завершается `INSTALL EXIT CODE: 0`, а войти в панель
* нельзя. Отказ обязан приходить установщику, которому он чинится одной строкой
* в hy2xs.env, а не оператору на форме входа, где чинить его уже нечем.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
function goContract(): string {
return fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"),
"utf8"
);
}
function goConst(name: string): string {
const source = goContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
if (backticked) {
return backticked[1];
}
throw new Error(`константа ${name} не найдена в apps/service/admin_credentials.go`);
}
describe("контракт учётных данных совпадает с админкой", () => {
// Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
test("границы длины совпадают с service.Admin*Length", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
});
test("набор символов логина совпадает с классом из Go", () => {
// Класс сравнивается как ЗНАЧЕНИЕ строки, а не как запись: в Go он живёт в
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source.replace(
new RegExp(`^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`, "g"),
""
);
expect(mirrored).toBe(declared);
});
test("человекочитаемый набор совпадает с показываемым админкой", () => {
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
});
// Дефис в классе символов ЭКРАНИРОВАН. Без экранирования `+-=` образует
// диапазон и молча впускает `, - . / 0-9 : ; < =` — ровно тот дефект, из-за
// которого фактическое множество разошлось с задуманным на всех четырёх
// сторонах продукта.
test("дефис в классе экранирован, а не образует диапазон", () => {
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
expect(ADMIN_USERNAME_PATTERN.source).not.toContain("+-=");
});
test("путь формы входа собран из базового пространства имён API", () => {
expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login");
const router = fs.readFileSync(path.join(REPO_ROOT, "apps", "router", "auth.go"), "utf8");
expect(router).toContain('auth.POST("/login"');
expect(router).toContain('authApi.Group("/auth")');
});
});
describe("HY2XS_ADMIN_USER проверяется при разборе окружения", () => {
test("значение по умолчанию проходит контракт панели", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser).toBe("hy2xsadmin");
expect(ADMIN_USERNAME_PATTERN.test(config.adminUser)).toBe(true);
});
// Регрессия: умолчанием здесь стояло `admin` — пять символов при минимуме в
// шесть. Установка проходила целиком, а панель не впускала никого.
test("значение по умолчанию не короче минимума", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser.length).toBeGreaterThanOrEqual(ADMIN_USERNAME_MIN_LENGTH);
});
// Умолчание обязано совпадать с запасным значением админки: иначе установка
// без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при
// повреждённом окружении ищет другую.
test("умолчание совпадает с запасным значением админки и с packaged env", () => {
const dao = fs.readFileSync(path.join(REPO_ROOT, "apps", "dao", "sqlite.go"), "utf8");
expect(dao).toContain('adminUser = "hy2xsadmin"');
const packaged = fs.readFileSync(
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
"utf8"
);
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
});
test("слишком короткий логин роняет установку", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
/invalid HY2XS_ADMIN_USER/
);
});
test("слишком длинный логин роняет установку", () => {
expect(() =>
baselineConfig({ HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1) })
).toThrow(/invalid HY2XS_ADMIN_USER/);
});
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
/invalid HY2XS_ADMIN_USER/
);
}
});
test("отказ называет и границы, и набор символов", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`)
);
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(/a-z A-Z 0-9/);
});
// Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
// отказом, который невозможно связать с причиной.
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
expect(baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser).toBe("hy2xsadmin");
});
test("точка в логине принимается: набор не сужается задним числом", () => {
expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe("admin.ops");
});
});
describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => {
test("сгенерированный пароль проходит контракт панели", () => {
for (const raw of ["", "__GENERATE__"]) {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw }));
const length = [...config.adminInitialPassword].length;
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
}
});
test("слишком короткий пароль роняет установку", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" })).toThrow(
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
);
});
test("слишком длинный пароль роняет установку", () => {
expect(() =>
baselineConfig({
HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1)
})
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
});
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
// права быть строже: отвергнутый здесь пароль сервер принял бы.
test("пароль из произвольных символов принимается", () => {
for (const password of ["p@ssw0rd?!", "пароль-администратора", "a b c d e f"]) {
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }).adminInitialPassword).toBe(
password
);
}
});
// Длина считается в РУНАХ: 32 кириллические буквы — это 64 байта, и проверка
// по байтам отвергла бы пароль, который панель принимает.
test("длина считается в символах, а не в байтах", () => {
const cyrillic = "я".repeat(ADMIN_PASSWORD_MAX_LENGTH);
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: cyrillic }).adminInitialPassword).toBe(
cyrillic
);
});
});
describe("smoke выполняет настоящий вход в панель", () => {
const smoke = () =>
fs.readFileSync(path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"), "utf8");
// Главный барьер этого релиза. Пока установка отвечала на вопрос «работает ли
// панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной
// установку с полностью недоступной панелью.
test("smoke обращается к форме входа, а не только к healthz", () => {
const source = smoke();
expect(source).toContain("ADMIN_LOGIN_PATH");
expect(source).toContain("assertAdminLoginWorks");
expect(source).toContain("isSuccessfulLogin");
});
test("успех определяется конвертом ответа и выданным токеном", () => {
// Админка отвечает HTTP 200 и на отказ: проверка по коду HTTP приняла бы за
// успешный вход любой отказ.
const source = smoke();
expect(source).toMatch(/"code"\\s\*:\\s\*20000/);
expect(source).toMatch(/accessToken/);
});
test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
// пароль администратора.
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
expect(body).toMatch(/runReadOnlySecret`/);
// Подстановка в tagged template — это НЕ утечка: значения там проходят
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
// секрета в сообщение — в info() или в new Error(), — потому что оно уезжает
// в журнал установки и в diagnostics-бандл.
const messages = [
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
...body.matchAll(/new Error\(([\s\S]*?)\);/g)
].map((match) => match[1]);
expect(messages.length).toBeGreaterThan(0);
for (const message of messages) {
for (const secret of ["adminPassword", "payload", "response", "body}"]) {
expect(message).not.toContain(`\${${secret}`);
}
}
// Тело успешного ответа несёт токен доступа, поэтому наружу отдаётся только
// разбор ответа, а не он сам.
expect(body).toContain("describeLoginFailure(body, error)");
expect(body).toContain("describeIssuedToken(response)");
});
test("тело запроса собирается JSON.stringify, а не интерполяцией", () => {
// Пароль задаёт оператор: кавычка или обратный слеш в нём сломали бы сам
// запрос, и проверка объявила бы рабочую установку сломанной.
const body = smoke();
expect(body).toContain("JSON.stringify({ username: adminUser, pass: adminPassword })");
});
test("отрицательная проба не зависит от режима операции", () => {
// Пароль в bootstrap-admin.secret устаревает в момент, когда оператор сменил
// его в панели, поэтому положительная проба install-only. Отрицательная от
// пароля не зависит и обязана работать всегда: именно она воспроизводит
// дефект RC2.
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
const rejected = body.indexOf("admin login rejects wrong credentials");
const modeGate = body.indexOf('context.mode !== "install"');
expect(rejected).toBeGreaterThan(-1);
expect(modeGate).toBeGreaterThan(rejected);
});
});
+93
View File
@@ -768,9 +768,102 @@ run_clean_install_acceptance() {
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_admin_login_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Вход в панель: барьеры, без которых RC2 повторяется.
#
# Гейт существует по той же причине, что и гейт детектора гонок: проверки,
# закрывающие класс дефекта, не имеют права тихо исчезнуть. Удалённый тест
# оставляет сборку зелёной, а выпускаемый артефакт — неотличимым.
#
# Проверяется НАЛИЧИЕ барьеров, а не поведение: поведение доказывают сами
# тесты, и они обязательны (run_admin_tests, run_orchestrator_tests,
# run_frontend_tests). Здесь удерживается то, что их есть кому доказывать.
run_admin_login_acceptance() {
log_step "Acceptance: every validation tag is proven to exist before release"
# Дефект RC2 целиком: тег `validateStr` пережил переименование правила в
# `credentialStr`, валидатор паниковал на КАЖДОМ входе, gin.Recovery отдавал
# HTTP 500, установка при этом объявляла успех.
code_has apps/controller/auth_test.go -F -- 'func TestEveryValidationTagIsRegistered' \
|| fail "acceptance: барьер существования правил валидации удалён — stale-тег снова станет runtime-дефектом"
code_has apps/controller/auth_test.go -F -- 'validate.Var(' \
|| fail "acceptance: барьер обязан предъявлять тег валидатору, а не сравнивать строки"
# Наивная замена барьера не работает: цепочка правил поля обрывается на
# первом несработавшем, поэтому нулевое DTO отказывает по `required` и до
# испорченного тега не доходит. Барьер обязан ходить по ИСХОДНИКАМ.
code_has apps/controller/auth_test.go -F -- 'filepath.WalkDir' \
|| fail "acceptance: барьер перестал обходить исходники модели"
log_step "Acceptance: the admin credential contract has a single owner"
[ -f apps/service/admin_credentials.go ] \
|| fail "acceptance: контракт учётных данных администратора отсутствует"
code_has apps/controller/validator.go -F -- 'service.IsValidAdminUsername' \
|| fail "acceptance: правило логина снова несёт собственную копию набора символов"
# Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже
# приводили к отказу, который невозможно объяснить оператору.
! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \
|| fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом"
log_step "Acceptance: the panel is never stricter than the server"
[ -f apps/frontend/src/constants/credentials.ts ] \
|| fail "acceptance: контракт учётных данных панели отсутствует"
local form
for form in apps/frontend/src/views/login/index.vue \
apps/frontend/src/views/admin/change-password/index.vue; do
code_has "$form" -F -- '@/constants/credentials' \
|| fail "acceptance: $form снова заводит собственное правило учётных данных"
# Неэкранированный дефис превращает `+-=` в диапазон: набор совпадал с
# серверным только потому, что обе стороны несли одну опечатку.
! code_has "$form" -F -- '_+-=' \
|| fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке"
done
log_step "Acceptance: install proves that the panel lets an operator in"
# `LISTEN` и `/healthz` не отвечают на вопрос, ради которого проверяются:
# в RC2 оба были истинны при полностью недоступной панели.
code_has orchestrator/src/steps/smoke.ts -F -- 'assertAdminLoginWorks' \
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|| fail "acceptance: адрес формы входа перестал браться из профиля"
# Успех определяется КОНВЕРТОМ: админка отвечает 200 OK и на отказ тоже.
code_has orchestrator/src/steps/smoke.ts -F -- '"code"\s*:\s*20000' \
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
# Команда несёт пароль администратора и не имеет права попасть в текст ошибки:
# runReadOnly (без Secret) кладёт её туда, а текст уезжает в журнал установки
# и в diagnostics-бандл. Проверяется ТЕЛО функции, а не файл: остальной smoke
# законно пользуется обоими раннерами.
local login_body
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|| fail "acceptance: не удалось прочитать проверку входа в панель"
[ -n "$login_body" ] \
|| fail "acceptance: функция проверки входа в панель не найдена"
grep -q 'runReadOnlySecret`curl' <<<"$login_body" \
|| fail "acceptance: запрос входа не выполняется секретным раннером"
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
! grep -q 'runReadOnly`curl' <<<"$login_body" \
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
log_step "Acceptance: the orchestrator validates what it generates"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminPassword(' \
|| fail "acceptance: HY2XS_ADMIN_INITIAL_PASSWORD снова не проверяется против контракта панели"
# Умолчание про одно и то же имя обязано быть одним. Их было три, и одно из
# них — `admin` — короче минимума панели.
code_has orchestrator/src/config/env.ts -F -- '"hy2xsadmin"' \
|| fail "acceptance: умолчание логина администратора разошлось с админкой и packaged env"
code_has apps/dao/sqlite.go -F -- 'adminUser = "hy2xsadmin"' \
|| fail "acceptance: запасное значение логина в админке разошлось с оркестратором"
grep -q '^HY2XS_ADMIN_USER=hy2xsadmin$' package/config/hy2xs.env \
|| fail "acceptance: packaged env разошёлся с умолчанием оркестратора"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
+162
View File
@@ -7,6 +7,14 @@ import {
FLAMY_NAME,
FLAMY_URL,
} from "../../apps/frontend/src/constants/branding";
import {
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN,
} from "../../apps/frontend/src/constants/credentials";
import ru from "../../apps/frontend/src/lang/package/ru";
import en from "../../apps/frontend/src/lang/package/en";
@@ -678,6 +686,160 @@ describe("правдивая диагностика", () => {
});
});
describe("контракт учётных данных администратора", () => {
const goContract = () =>
fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"),
"utf8"
);
function goConst(name: string): string {
const source = goContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
if (backticked) {
return backticked[1];
}
throw new Error(`константа ${name} не найдена в admin_credentials.go`);
}
const loginForm = () => readSource("views", "login", "index.vue");
const changePasswordForm = () =>
readSource("views", "admin", "change-password", "index.vue");
// Панель не имеет права быть строже сервера: отвергнутое ею значение сервер
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
// подходит.
test("границы и набор символов совпадают с Go-контрактом", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source
.replace(/^\^\[/, "")
.replace(new RegExp(`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`), "");
expect(mirrored).toBe(declared);
});
// Регрессия RC2-UI-01. Дефис внутри класса символов не был экранирован, из-за
// чего `+-=` образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Набор
// совпадал с серверным только по совпадению — оба несли одну и ту же
// опечатку, — и любая попытка «навести порядок» развела бы их обратно.
test("класс символов записан явно, а не диапазоном по опечатке", () => {
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
// Считается КОД: комментарий, объясняющий, почему прежней записи здесь
// больше нет, обязан называть её по имени — иначе он бесполезен. Это тот же
// урок, что записан в vueCodeOf этажом выше, и он повторился прямо здесь.
for (const form of [loginForm(), changePasswordForm()]) {
expect(vueCodeOf(form)).not.toContain("_+-=");
}
});
// Правило объявлено ОДИН раз. Пока каждая форма несла своё, они разошлись и с
// сервером, и друг с другом.
test("обе формы берут правило из общего модуля, а не заводят своё", () => {
for (const form of [loginForm(), changePasswordForm()]) {
expect(form).toContain("@/constants/credentials");
// Собственных регекспов и собственных чисел в формах быть не должно.
expect(vueCodeOf(form)).not.toMatch(/pattern\s*[:=]\s*\//);
expect(vueCodeOf(form)).not.toMatch(/\{\s*6\s*,\s*\d+\s*\}/);
}
});
// Регрессия RC2-UI-02: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, а форма смены пароля назначала до 64. Пароль,
// назначенный штатной операцией, становился паролем, с которым невозможно
// войти.
test("границы пароля на обеих формах — одни и те же", () => {
for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form);
expect(code).toContain("min: ADMIN_PASSWORD_MIN_LENGTH");
expect(code).toContain("max: ADMIN_PASSWORD_MAX_LENGTH");
}
});
// Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в
// панели умеет только отвергнуть пароль, который сервер принял бы.
test("на пароль не навешен набор символов", () => {
for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form);
expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordLength\"");
expect(code).not.toMatch(/passwordPattern/);
}
// Правило логина — единственное место, где вообще упоминается набор.
expect(vueCodeOf(changePasswordForm())).not.toContain("ADMIN_USERNAME_PATTERN");
});
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
// логина приходит из hy2xs.env, и посмотреть его в панели больше негде.
test("фразы называют границы и набор, и есть в обоих словарях", () => {
for (const dictionary of [ru, en] as const) {
const credentials = (dictionary as any).credentials ?? {};
expect(typeof credentials.usernameFormat).toBe("string");
expect(typeof credentials.passwordLength).toBe("string");
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(credentials.usernameFormat).toContain(placeholder);
}
for (const placeholder of ["{min}", "{max}"]) {
expect(credentials.passwordLength).toContain(placeholder);
}
// Причина отказа от сервера несёт те же params и обязана их использовать.
const reason = (dictionary as any).error?.code?.credential_format ?? "";
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(reason).toContain(placeholder);
}
}
});
});
describe("индикация ошибки на форме входа", () => {
const loginForm = () => readSource("views", "login", "index.vue");
// Регрессия RC2-UI-03, видимая на скриншоте формы.
//
// Element Plus рисует состояние отказа на `el-input__wrapper` селектором
//
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
//
// то есть из ЧЕТЫРЁХ классов. Форма входа рисует видимую рамку поля на
// `el-form-item` — потому что в поле лежат ещё иконка и переключатель
// видимости пароля, — а тень враппера гасила селектором из трёх классов и
// проигрывала по специфичности. Красная рамка ложилась вокруг одного лишь
// поля ввода: у логина начиналась после иконки, у пароля обрывалась перед
// «глазом».
test("отказ подсвечивает поле целиком, а не один только ввод", () => {
const styles = loginForm();
// Индикация переехала на элемент, который оператор и видит полем.
expect(styles).toMatch(/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/);
// Штатная тень враппера гасится селектором, который повторяет чужой и
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
expect(styles).toContain(":deep(.el-form-item__content .el-input__wrapper)");
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
// состояния, о которых правило ничего не знает.
expect(styles).not.toContain("box-shadow: none !important");
});
// Сообщение об отказе позиционируется абсолютно от `top: 100%`, то есть
// висит ВНЕ рамки. При стандартных 18px оно прижималось к границе снизу и к
// следующему полю сверху и выглядело принадлежащим не тому полю.
test("сообщению об отказе оставлено место под полем", () => {
const styles = loginForm();
expect(styles).toContain(":deep(.el-form-item__error)");
expect(styles).toMatch(/margin-bottom:\s*(1[9]|[2-9]\d)px/);
});
});
describe("подсказки формы пира", () => {
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
// осталась от правила, действовавшего до EX-03. Оператор не имел причин