fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
+183
-17
@@ -1,47 +1,213 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
_ = validate.RegisterValidation("validateStr", validateStr)
|
||||
|
||||
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
|
||||
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
|
||||
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
|
||||
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "" || name == "-" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
}
|
||||
|
||||
func validateStr(f validator.FieldLevel) bool {
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
if err := validate.RegisterValidation(tag, fn); err != nil {
|
||||
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
|
||||
}
|
||||
}
|
||||
|
||||
// validatePeerName — единственное правило имени пира.
|
||||
//
|
||||
// Набор символов и длина берутся из service: имя пира проверяется на двух
|
||||
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
|
||||
// независимые копии правила уже расходились. Копия в слое контроллеров
|
||||
// выглядела так:
|
||||
//
|
||||
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
|
||||
//
|
||||
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
|
||||
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
|
||||
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
|
||||
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
|
||||
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
|
||||
func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
||||
// пира.
|
||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
||||
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
|
||||
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
|
||||
compile := regexp.MustCompile(reg)
|
||||
return field == "" || compile.MatchString(field)
|
||||
return field == "" || credentialStrPattern.MatchString(field)
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
// правила.
|
||||
//
|
||||
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
|
||||
// нарушение любого правила любого поля превращались в одну строку `invalid`:
|
||||
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
|
||||
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
|
||||
// же и предлагала сделать.
|
||||
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
||||
var bindErr error
|
||||
if c.Request.Method == http.MethodGet {
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
bindErr = c.ShouldBindQuery(&field)
|
||||
} else if c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodPatch ||
|
||||
c.Request.Method == http.MethodDelete {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
bindErr = c.ShouldBindJSON(&field)
|
||||
}
|
||||
if bindErr != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"запрос не разобран: проверьте формат и типы полей",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Message: bindErr.Error(),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
|
||||
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
|
||||
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
|
||||
// состояниями для валидатора и одинаковыми для человека.
|
||||
if normalizable, ok := any(&field).(dto.Normalizable); ok {
|
||||
normalizable.Normalize()
|
||||
}
|
||||
|
||||
if err := validate.Struct(&field); err != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"проверка данных не пройдена",
|
||||
describeValidationErrors(err),
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeValidationFailed)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// describeValidationErrors переводит отказ валидатора в список причин.
|
||||
func describeValidationErrors(err error) []vo.FieldError {
|
||||
var validationErrors validator.ValidationErrors
|
||||
if !errors.As(err, &validationErrors) {
|
||||
// InvalidValidationError означает ошибку программиста (в проверку
|
||||
// передали не структуру), а не плохой вход оператора. Скрывать её за
|
||||
// сообщением о поле нельзя: она никогда не чинится правкой формы.
|
||||
return []vo.FieldError{{
|
||||
Code: constant.ErrCodeValidationFailed,
|
||||
Message: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
out := make([]vo.FieldError, 0, len(validationErrors))
|
||||
for _, fieldErr := range validationErrors {
|
||||
out = append(out, describeFieldError(fieldErr))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
|
||||
// величину. Указатели валидатор к этому моменту уже разыменовал.
|
||||
func isTextField(fieldErr validator.FieldError) bool {
|
||||
return fieldErr.Kind() == reflect.String
|
||||
}
|
||||
|
||||
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
field := fieldErr.Field()
|
||||
param := fieldErr.Param()
|
||||
|
||||
described := vo.FieldError{Field: field}
|
||||
switch fieldErr.Tag() {
|
||||
case "required":
|
||||
described.Code = constant.ErrCodeRequired
|
||||
described.Message = fmt.Sprintf("поле %q обязательно", field)
|
||||
case "min":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMinLength
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMin
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
|
||||
case "max":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMaxLength
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMax
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
|
||||
case "len":
|
||||
described.Code = constant.ErrCodeLen
|
||||
described.Params = map[string]string{"len": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
|
||||
case "oneof":
|
||||
described.Code = constant.ErrCodeOneOf
|
||||
described.Params = map[string]string{"values": param}
|
||||
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
|
||||
case "gt":
|
||||
described.Code = constant.ErrCodeGreaterThan
|
||||
described.Params = map[string]string{"gt": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
|
||||
case "peerName":
|
||||
described.Code = constant.ErrCodePeerName
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
|
||||
"charset": service.PeerNameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"имя пира: от %d до %d символов из набора %s",
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
|
||||
}
|
||||
return described
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user