diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f95c08..2b4aacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` сразу после успешной проверки пароля; отказ записи + вход не отменяет, но попадает в журнал. + ### Исправлено — правило доступа - **Исчерпанная квота не отключала пира никогда.** Правило доступа diff --git a/README.md b/README.md index 15734bb..026e133 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/apps/controller/auth.go b/apps/controller/auth.go new file mode 100644 index 0000000..3012967 --- /dev/null +++ b/apps/controller/auth.go @@ -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) +} diff --git a/apps/controller/auth_test.go b/apps/controller/auth_test.go new file mode 100644 index 0000000..ada6d13 --- /dev/null +++ b/apps/controller/auth_test.go @@ -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) + } +} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index 24068b6..a8d06fa 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -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 { diff --git a/apps/controller/validator.go b/apps/controller/validator.go index 412f667..5f56be3 100644 --- a/apps/controller/validator.go +++ b/apps/controller/validator.go @@ -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()} diff --git a/apps/controller/validator_test.go b/apps/controller/validator_test.go index 94abf19..3a1703c 100644 --- a/apps/controller/validator_test.go +++ b/apps/controller/validator_test.go @@ -7,41 +7,96 @@ 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 пробел", // пробел - "админ1", // кириллица - "admin\n1", // перевод строки - "admin'1", // апостроф вне набора + "", // не задано + "admi", // короче шести символов + strings.Repeat("a", 33), // длиннее тридцати двух + "admin пробел", // пробел + "админ1", // кириллица + "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) { diff --git a/apps/frontend/src/constants/credentials.ts b/apps/frontend/src/constants/credentials.ts new file mode 100644 index 0000000..335fb87 --- /dev/null +++ b/apps/frontend/src/constants/credentials.ts @@ -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; diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 1dafea7..75a2671 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -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: diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 79db353..b86691e 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -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: "Запрос не разобран: проверьте формат и типы полей", diff --git a/apps/frontend/src/views/admin/change-password/index.vue b/apps/frontend/src/views/admin/change-password/index.vue index 753f090..7cdfa9a 100644 --- a/apps/frontend/src/views/admin/change-password/index.vue +++ b/apps/frontend/src/views/admin/change-password/index.vue @@ -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 }, ], }; diff --git a/apps/frontend/src/views/login/index.vue b/apps/frontend/src/views/login/index.vue index 356e542..1d02f95 100644 --- a/apps/frontend/src/views/login/index.vue +++ b/apps/frontend/src/views/login/index.vue @@ -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({ 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 { diff --git a/apps/model/dto/admin_user.go b/apps/model/dto/admin_user.go index a5fc22f..5731568 100644 --- a/apps/model/dto/admin_user.go +++ b/apps/model/dto/admin_user.go @@ -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"` diff --git a/apps/model/dto/auth.go b/apps/model/dto/auth.go index a19643f..c9d87c8 100644 --- a/apps/model/dto/auth.go +++ b/apps/model/dto/auth.go @@ -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) +} diff --git a/apps/model/dto/normalize.go b/apps/model/dto/normalize.go index 25a31fe..d87d5ea 100644 --- a/apps/model/dto/normalize.go +++ b/apps/model/dto/normalize.go @@ -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: ноль у необязательного числового поля означает «не задано». // // Применяется ТОЛЬКО там, где ноль не является осмысленным значением: diff --git a/apps/service/admin_credentials.go b/apps/service/admin_credentials.go new file mode 100644 index 0000000..a8bc014 --- /dev/null +++ b/apps/service/admin_credentials.go @@ -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 +} diff --git a/apps/service/admin_user.go b/apps/service/admin_user.go index ca07988..421460a 100644 --- a/apps/service/admin_user.go +++ b/apps/service/admin_user.go @@ -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, который ставит установка. diff --git a/docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md b/docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md new file mode 100644 index 0000000..9b005f1 --- /dev/null +++ b/docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md @@ -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. diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 89543ee..070f0aa 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -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) — все закрыты | ## Разборы кода между прогонами diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index 357aeee..f81a835 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -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`), и фраза панели обязана их использовать: правило одно +и проверяет и длину, и набор, поэтому описывать его только через символы — +значит описывать отказ по длине неверно. diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index b7d3040..b7c5357 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -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 diff --git a/docs/runtime/09-post-install-env.md b/docs/runtime/09-post-install-env.md index ffadc3b..06bbef4 100644 --- a/docs/runtime/09-post-install-env.md +++ b/docs/runtime/09-post-install-env.md @@ -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` создаётся оркестратором только при первичной установке. diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index cf51e71..0a2e56b 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -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 конфига diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 8794798..89f9304 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -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), diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index cc1de71..2393d29 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -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; +} + /** * Где оркестратор живёт на установленном хосте. * diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index 5ba25f9..cfa90d6 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -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 { ); } + await assertAdminLoginWorks(context); + await retry( "trafficStats valid secret", 10, @@ -244,6 +246,132 @@ export async function smoke(context: RuntimeContext): Promise { 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 { + 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 может относиться к другому пакету, поэтому diff --git a/orchestrator/test/admin-credentials.test.ts b/orchestrator/test/admin-credentials.test.ts new file mode 100644 index 0000000..6fad386 --- /dev/null +++ b/orchestrator/test/admin-credentials.test.ts @@ -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(/(? 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); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 4a6d870..33a10bd 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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" diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 2c95bea..36a8e8c 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -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. Оператор не имел причин