Files
HY2XS_flamy/apps/controller/auth_test.go
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

616 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}