a8407cf16b
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
616 lines
26 KiB
Go
616 lines
26 KiB
Go
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)
|
||
}
|
||
}
|