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:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type apiResult struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Errors []vo.FieldError `json:"errors"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// failService переводит отказ сервисного слоя в ответ панели.
|
||||
//
|
||||
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
|
||||
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
|
||||
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
|
||||
// при этом не придётся ни в одном известном случае.
|
||||
func failService(err error, c *gin.Context) {
|
||||
var peerErr *service.PeerError
|
||||
if errors.As(err, &peerErr) {
|
||||
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
}
|
||||
+44
-14
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -18,18 +19,31 @@ import (
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
|
||||
//
|
||||
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
|
||||
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
|
||||
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
|
||||
// вызывала validateField, который читает тело запроса, а обработчик следом
|
||||
// читает то же тело второй раз — gin его не буферизует, и второй разбор
|
||||
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
|
||||
// когда понадобился бы.
|
||||
func resolveID(c *gin.Context) (int64, error) {
|
||||
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
return parsed, nil
|
||||
}
|
||||
raw := strings.TrimSpace(c.Param("id"))
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
vo.FailValidation(
|
||||
"идентификатор пира в адресе некорректен",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Field: "id",
|
||||
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return 0, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
idDto, err := validateField(c, dto.IdDto{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *idDto.Id, nil
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
@@ -39,6 +53,14 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
@@ -65,7 +87,7 @@ func SavePeer(c *gin.Context) {
|
||||
}
|
||||
peerVo, err := service.CreatePeer(peerSaveDto)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(peerVo, c)
|
||||
@@ -100,12 +122,12 @@ func UpdatePeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if taken {
|
||||
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
||||
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -158,7 +180,15 @@ func ImportPeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
vo.FailValidation(
|
||||
"импорт принимает только файлы .json",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeImportFileExtension,
|
||||
Field: "file",
|
||||
Message: "импорт принимает только файлы .json",
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Контракт формы пира: необязательный секрет и внятный отказ.
|
||||
//
|
||||
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
|
||||
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
|
||||
// слоёв и ни один из них по отдельности его не показывал: панель обещала
|
||||
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
|
||||
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
|
||||
|
||||
func newPeerControllerDB(t *testing.T) {
|
||||
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() })
|
||||
}
|
||||
|
||||
// peerPayload — тело создания пира со всеми обязательными полями.
|
||||
// Тесты меняют в нём ровно то, что проверяют.
|
||||
func peerPayload(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"quotaBytes": -1,
|
||||
"expiresAt": 0,
|
||||
"maxDevices": 3,
|
||||
"disabled": 0,
|
||||
"remark": "",
|
||||
}
|
||||
}
|
||||
|
||||
func createPeer(t *testing.T, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
return postJSON(t, SavePeer, "/peers", body)
|
||||
}
|
||||
|
||||
// errorFor возвращает причину отказа по имени поля.
|
||||
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, item := range result.Errors {
|
||||
if item.Field == field {
|
||||
return item.Code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func storedPeer(t *testing.T, name string) entity.Peer {
|
||||
t.Helper()
|
||||
peer, err := dao.GetPeer("name = ?", name)
|
||||
if err != nil {
|
||||
t.Fatalf("пир %q не найден в базе: %v", name, err)
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
|
||||
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
|
||||
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
|
||||
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
|
||||
// генерация в CreatePeer была недостижима.
|
||||
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
|
||||
cases := map[string]func(map[string]any){
|
||||
"поле отсутствует": func(body map[string]any) {},
|
||||
"пустая строка": func(body map[string]any) { body["secret"] = "" },
|
||||
"только пробелы": func(body map[string]any) { body["secret"] = " " },
|
||||
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
|
||||
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
|
||||
}
|
||||
|
||||
for label, mutate := range cases {
|
||||
t.Run(label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
mutate(body)
|
||||
|
||||
result := createPeer(t, body)
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
|
||||
peer := storedPeer(t, "client-01")
|
||||
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
|
||||
t.Fatal("секрет не сохранён")
|
||||
}
|
||||
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if len(secret) < 6 {
|
||||
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
|
||||
}
|
||||
|
||||
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
|
||||
// записан, ничего не значит, пока по нему не проходит проверка
|
||||
// доступа. Это же связывает digest и шифртекст между собой.
|
||||
id, authID, authErr := service.Hysteria2Auth(secret)
|
||||
if authErr != nil {
|
||||
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
|
||||
}
|
||||
if id != *peer.Id || authID != *peer.AuthId {
|
||||
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
|
||||
// обязан быть случайным, а не производной от имени.
|
||||
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
secrets := make(map[string]struct{}, 5)
|
||||
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
|
||||
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
|
||||
t.Fatalf("создание %q отклонено: %+v", name, result)
|
||||
}
|
||||
peer := storedPeer(t, name)
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
|
||||
}
|
||||
if _, seen := secrets[secret]; seen {
|
||||
t.Fatalf("сгенерированный секрет повторился: %q", secret)
|
||||
}
|
||||
secrets[secret] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
|
||||
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
secret string
|
||||
accepted bool
|
||||
expectCode string
|
||||
}{
|
||||
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
|
||||
{"6 символов", strings.Repeat("a", 6), true, ""},
|
||||
{"128 символов", strings.Repeat("a", 128), true, ""},
|
||||
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["secret"] = tc.secret
|
||||
result := createPeer(t, body)
|
||||
|
||||
if tc.accepted {
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if stored != tc.secret {
|
||||
t.Fatalf("сохранён не тот секрет, который передали")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("секрет длиной %d принят", len(tc.secret))
|
||||
}
|
||||
code, ok := errorFor(t, result, "secret")
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
|
||||
}
|
||||
if code != tc.expectCode {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
|
||||
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
|
||||
// вынуждена была бы разбирать текст, чтобы попытаться.
|
||||
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
body func() map[string]any
|
||||
field string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
label: "имя не передано",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
delete(body, "name")
|
||||
return body
|
||||
},
|
||||
field: "name",
|
||||
code: constant.ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
label: "имя короче допустимого",
|
||||
body: func() map[string]any { return peerPayload("pc1") },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "имя длиннее допустимого",
|
||||
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "лимит устройств меньше единицы",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["maxDevices"] = 0
|
||||
return body
|
||||
},
|
||||
field: "maxDevices",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "disabled вне множества значений",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["disabled"] = 7
|
||||
return body
|
||||
},
|
||||
field: "disabled",
|
||||
code: constant.ErrCodeOneOf,
|
||||
},
|
||||
{
|
||||
label: "квота меньше минимума",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["quotaBytes"] = -2
|
||||
return body
|
||||
},
|
||||
field: "quotaBytes",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "комментарий длиннее допустимого",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = strings.Repeat("я", 65)
|
||||
return body
|
||||
},
|
||||
field: "remark",
|
||||
code: constant.ErrCodeMaxLength,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, tc.body())
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("некорректный ввод принят: %+v", result)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
|
||||
}
|
||||
code, ok := errorFor(t, result, tc.field)
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
|
||||
}
|
||||
if code != tc.code {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
|
||||
}
|
||||
// Сообщение остаётся человекочитаемым для клиента без панели, но
|
||||
// панель им не пользуется: у неё есть код.
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatal("отказ без человекочитаемого сообщения")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
|
||||
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
|
||||
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
|
||||
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
|
||||
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer;name",
|
||||
"peer,name",
|
||||
"peer.name",
|
||||
"peer<name",
|
||||
"peer name",
|
||||
"пир-01",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(name))
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("имя %q принято", name)
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
|
||||
}
|
||||
// Обе двери в таблицу пиров обязаны требовать одного и того же.
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsTakenName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("первое создание отклонено: %+v", result)
|
||||
}
|
||||
|
||||
result := createPeer(t, peerPayload("client-01"))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("повторное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsReservedName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("зарезервированное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
|
||||
// Панели важно различать: в первом случае подсвечивать нечего.
|
||||
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/peers", SavePeer)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "no" {
|
||||
t.Fatal("неразбираемое тело принято")
|
||||
}
|
||||
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
|
||||
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
|
||||
}
|
||||
if result.Errors[0].Field != "" {
|
||||
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
|
||||
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.PATCH("/peers/:id", UpdatePeer)
|
||||
|
||||
target := "/peers/" + strconv.FormatInt(id, 10)
|
||||
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// При изменении пустой секрет означает «не менять», и это то же самое
|
||||
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
|
||||
// когда оператор открыл форму и не трогал поле секрета.
|
||||
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
before := storedPeer(t, "client-01")
|
||||
|
||||
for _, blank := range []string{"", " "} {
|
||||
result := patchPeer(t, *before.Id, map[string]any{
|
||||
"name": "client-01",
|
||||
"secret": blank,
|
||||
"remark": "рабочее устройство",
|
||||
})
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if *after.SecretDigest != *before.SecretDigest {
|
||||
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
|
||||
}
|
||||
if after.Remark == nil || *after.Remark != "рабочее устройство" {
|
||||
t.Fatal("остальные поля формы не применились")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
|
||||
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
|
||||
// которой нормализация проходит для каждого поля отдельно.
|
||||
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = "временная пометка"
|
||||
if result := createPeer(t, body); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
|
||||
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
|
||||
t.Fatalf("очистка комментария отклонена: %+v", result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if after.Remark != nil && *after.Remark != "" {
|
||||
t.Fatalf("комментарий не очищен: %q", *after.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
|
||||
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
|
||||
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
|
||||
// «invalid» — то есть список пиров ломался в один клик по крестику.
|
||||
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/peers", PagePeer)
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("pageNum", "1")
|
||||
query.Set("pageSize", "10")
|
||||
query.Set("name", "")
|
||||
query.Set("remark", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !credentialStrPattern.MatchString(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if credentialStrPattern.MatchString(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
accepted := []string{
|
||||
"client-01",
|
||||
"alpha1",
|
||||
"bootstrap-admin-peer",
|
||||
strings.Repeat("a", 6),
|
||||
strings.Repeat("a", 32),
|
||||
}
|
||||
for _, name := range accepted {
|
||||
if !service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q отклонено, ожидался приём", name)
|
||||
}
|
||||
}
|
||||
|
||||
rejected := []string{
|
||||
"",
|
||||
" ",
|
||||
"pc1",
|
||||
strings.Repeat("a", 33),
|
||||
"peer name",
|
||||
"peer\nname",
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer.name",
|
||||
"пир-01",
|
||||
}
|
||||
for _, name := range rejected {
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q принято, ожидался отказ", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user