65042ee335
Верхняя граница пароля была объявлена в 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>
708 lines
31 KiB
Go
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)
|
|
}
|
|
}
|