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) } }