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:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+505
View File
@@ -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)
}
}