Files
HY2XS_flamy/apps/controller/auth_test.go
T
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

708 lines
31 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/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)
}
}