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/credential" "hy2xs-admin/dao" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "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", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"}, {"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"}, {"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"}, {"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"}, // Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний // контракт принимал это значение, и отказ приходил из bcrypt уже после // всех проверок — системной ошибкой, а не причиной на поле формы. {"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"}, {"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "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"] != credential.AdminUsernameCharset { t.Errorf("причина не называет набор символов: %+v", reason.Params) } if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) { t.Errorf("причина не называет нижнюю границу: %+v", reason.Params) } if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) { t.Errorf("причина не называет верхнюю границу: %+v", reason.Params) } return } t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr) } // ---------------------------------------------------------------- контракты --- // passwordFields — все поля продукта, несущие пароль администратора. var passwordFields = []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"}, } // Правило пароля ОДНО, и границы живут внутри него. // // Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы // числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли // неполное правило: тег считает символы Unicode, а bcrypt считает байты, и // границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не // равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям // по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule). func TestPasswordLengthLivesInsideItsRule(t *testing.T) { for _, subject := range passwordFields { field, ok := subject.typ.FieldByName(subject.field) if !ok { t.Fatalf("%s: поле не найдено", subject.name) } rules := splitValidationRules(field.Tag.Get("validate")) for _, rule := range rules { if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") { t.Errorf( "%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+ "тег умеет считать только символы, а у пароля есть ещё граница в байтах", subject.name, rule, ) } } var hasPasswordRule bool for _, rule := range rules { if rule == "adminPassword" { hasPasswordRule = true } } if !hasPasswordRule { t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules) } } } // Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка. // // Проверка идёт через production-валидатор на граничных значениях: совпадения // имён тегов мало, потому что именно расхождение реализаций за одинаковыми // именами и было исходным дефектом. func TestEveryPasswordFieldFollowsTheContract(t *testing.T) { samples := []string{ strings.Repeat("a", credential.AdminPasswordMinLength), strings.Repeat("a", credential.AdminPasswordMinLength-1), strings.Repeat("a", credential.AdminPasswordMaxLength), strings.Repeat("я", 36), strings.Repeat("я", 37), strings.Repeat("😀", 18), strings.Repeat("😀", 19), strings.Repeat("я", credential.AdminPasswordMaxLength), "abcde ", "abcde\n", } for _, subject := range passwordFields { field, _ := subject.typ.FieldByName(subject.field) rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",") rules = strings.ReplaceAll(rules, "required,", "") for _, sample := range samples { expected := credential.IsValidAdminPassword(sample) if got := validate.Var(sample, rules) == nil; got != expected { t.Errorf( "%s: значение из %d символов / %d байт принято=%v, контракт=%v", subject.name, len([]rune(sample)), len(sample), got, expected, ) } } } } // Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене. // // Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому // ограничение набора не защищает ничего и умеет только отвергнуть пароль, // который сервер принял бы. Панель обязана держаться этого же контракта, и // проверка существует, чтобы правило не «навели» обратно. func TestPasswordHasNoCharsetRule(t *testing.T) { for _, subject := range passwordFields { 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", credential.AdminPasswordMaxLength) newAuthTestDB(t, "hy2xsadmin", password) _, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) if result.Code != constant.CodeSuccess { t.Fatalf("пароль предельной длины отклонён: %+v", result) } } // Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он // назначается формой смены пароля и вводится формой входа. // // 36 кириллических букв — это ровно 72 байта, то есть последнее значение, // которое GenerateFromPassword принимает. Проверяется весь путь: хеширование // при создании учётной записи и вход этим же значением. func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) { password := strings.Repeat("я", 36) if len(password) != credential.AdminPasswordMaxBytes { t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes) } newAuthTestDB(t, "hy2xsadmin", password) _, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) if result.Code != constant.CodeSuccess { t.Fatalf("пароль в 72 байта отклонён: %+v", result) } } // А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная // ошибка из bcrypt. // // Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64 // символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри // хеширования. На форме смены пароля оператор видел «system error». func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) { newAuthTestDB(t, "hy2xsadmin", "bootstrap-password") password := strings.Repeat("я", 37) if len(password) <= credential.AdminPasswordMaxBytes { t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes) } status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) if status != http.StatusOK { t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status) } if result.Code != constant.CodeInvalidError { t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result) } var hasReason bool for _, reason := range result.Errors { if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword { hasReason = true } } if !hasReason { t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, 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) } }