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>
This commit is contained in:
+137
-45
@@ -16,12 +16,12 @@ import (
|
||||
"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/service"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
@@ -174,11 +174,16 @@ func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
{"кириллица в логине", "админ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"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", credential.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"},
|
||||
{"пароль короче минимума", "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 {
|
||||
@@ -226,13 +231,13 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != service.AdminUsernameCharset {
|
||||
if reason.Params["charset"] != credential.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) {
|
||||
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) {
|
||||
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
@@ -242,44 +247,88 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// Границы пароля объявлены в трёх местах и обязаны совпадать.
|
||||
// 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"},
|
||||
}
|
||||
|
||||
// Правило пароля ОДНО, и границы живут внутри него.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
|
||||
// принимала 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"},
|
||||
} {
|
||||
// Здесь стояли `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)
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
}
|
||||
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)
|
||||
var hasPasswordRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "adminPassword" {
|
||||
hasPasswordRule = true
|
||||
}
|
||||
}
|
||||
if bounds["max"] != service.AdminPasswordMaxLength {
|
||||
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,15 +340,7 @@ func TestPasswordBoundsAreOneContract(t *testing.T) {
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
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"},
|
||||
} {
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
@@ -556,7 +597,7 @@ func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", service.AdminPasswordMaxLength)
|
||||
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
@@ -565,6 +606,57 @@ func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, упирающийся в предел 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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
@@ -33,6 +34,7 @@ func init() {
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
mustRegister("adminPassword", validateAdminPassword)
|
||||
}
|
||||
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
@@ -74,7 +76,23 @@ func validatePeerName(f validator.FieldLevel) bool {
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
return service.IsValidAdminUsername(f.Field().String())
|
||||
return credential.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||
// системная ошибка на штатной смене пароля.
|
||||
//
|
||||
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminPassword(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
@@ -208,13 +226,31 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength),
|
||||
"charset": service.AdminUsernameCharset,
|
||||
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
|
||||
"charset": credential.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset,
|
||||
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
|
||||
)
|
||||
case "adminPassword":
|
||||
// Причина называет ОБЕ границы, потому что их две и они в разных
|
||||
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
|
||||
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
|
||||
// такую причину, сокращал бы пароль, который отвергнут не за это.
|
||||
described.Code = constant.ErrCodeAdminPassword
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
|
||||
@@ -4,99 +4,67 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
//
|
||||
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||||
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||||
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||||
// не увидела бы расхождения между копией и правилом.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !service.IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||
// одинаково на одних и тех же значениях.
|
||||
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"hy2xsadmin",
|
||||
"admin.ops",
|
||||
"admin+1",
|
||||
"admi",
|
||||
"админ12",
|
||||
"admin пробел",
|
||||
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||
} {
|
||||
if service.IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
expected := credential.IsValidAdminUsername(candidate)
|
||||
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
|
||||
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
// Тот же вопрос про пароль, и он важнее.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength)
|
||||
}
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(service.AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !service.IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||
// пользуются bootstrap-установка и `reset-admin`.
|
||||
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"bootstrap-password",
|
||||
"abcde ",
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde\n",
|
||||
} {
|
||||
expected := credential.IsValidAdminPassword(candidate)
|
||||
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||
t.Errorf(
|
||||
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||
len([]rune(candidate)), len(candidate), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator
|
||||
// в правилах `min`/`max` на строке.
|
||||
//
|
||||
// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной
|
||||
// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА.
|
||||
func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) {
|
||||
if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) {
|
||||
t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength)
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) {
|
||||
t.Error("пароль длиннее максимума принят")
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) {
|
||||
t.Error("пароль короче минимума принят")
|
||||
}
|
||||
if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) {
|
||||
t.Error("пароль длиной ровно в минимум отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user