c0a43ae915
Девятый проход, по итогам приёмки 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
88 lines
5.0 KiB
Go
88 lines
5.0 KiB
Go
package dto
|
||
|
||
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
|
||
// длину.
|
||
//
|
||
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
|
||
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
|
||
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
|
||
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
|
||
// внутри одного правила, чтобы такого расхождения больше не было.
|
||
|
||
type PeerPageDto struct {
|
||
BaseDto
|
||
Name *string `json:"name" form:"name" validate:"omitempty,max=32"`
|
||
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
|
||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||
}
|
||
|
||
// Normalize: очищенный фильтр — это отсутствие фильтра.
|
||
//
|
||
// Регрессия, которую это закрывает: `el-input` с крестиком очистки ставит
|
||
// пустую строку, axios сериализует её как `?name=`, и поиск пиров отказывал с
|
||
// «invalid» после нажатия на крестик.
|
||
func (d *PeerPageDto) Normalize() {
|
||
d.BaseDto.Normalize()
|
||
blankToNil(&d.Name)
|
||
blankToNil(&d.Remark)
|
||
}
|
||
|
||
type PeerSaveDto struct {
|
||
Name *string `json:"name" form:"name" validate:"required,peerName"`
|
||
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
|
||
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
|
||
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
|
||
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
|
||
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"`
|
||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||
}
|
||
|
||
// Normalize: пустой секрет означает «сгенерируй сам».
|
||
//
|
||
// Именно это обещает подпись под полем, и именно это умеет CreatePeer. Пустая
|
||
// пометка при этом остаётся пустой пометкой — «нет комментария» и «не менять
|
||
// комментарий» не одно и то же.
|
||
func (d *PeerSaveDto) Normalize() {
|
||
trimValue(d.Name)
|
||
blankToNil(&d.Secret)
|
||
trimValue(d.Remark)
|
||
}
|
||
|
||
type PeerUpdateDto struct {
|
||
// Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
|
||
// НЕОБЯЗАТЕЛЕН.
|
||
//
|
||
// Раньше сюда встраивался IdDto с правилом `required,gt=0`, и тело запроса
|
||
// обязано было повторять идентификатор, уже указанный в адресе. Панель его
|
||
// повторяла, поэтому расхождение не проявлялось; любой другой клиент,
|
||
// сделавший PATCH /peers/7 без `"id": 7` в теле, получал отказ «поле id
|
||
// обязательно» — при том, что значение из тела всё равно затирается
|
||
// значением из пути.
|
||
Id *int64 `json:"id" form:"id" validate:"omitempty,gt=0"`
|
||
Name *string `json:"name" form:"name" validate:"omitempty,peerName"`
|
||
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
|
||
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
|
||
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
|
||
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
|
||
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
|
||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||
}
|
||
|
||
// Normalize: при изменении пустое имя и пустой секрет означают «не менять».
|
||
//
|
||
// Ровно так их и читает service.UpdatePeer (`!= nil && != ""`), поэтому
|
||
// приведение здесь не добавляет поведения, а убирает расхождение: без него
|
||
// правила отказывали на входе, который сервис считает законным.
|
||
//
|
||
// `remark` и `disabled` намеренно не трогаются: пустая пометка и ноль — это
|
||
// значения, которые оператор устанавливает осознанно.
|
||
func (d *PeerUpdateDto) Normalize() {
|
||
blankToNil(&d.Name)
|
||
blankToNil(&d.Secret)
|
||
trimValue(d.Remark)
|
||
}
|
||
|
||
type PeerKickDto struct {
|
||
BannedUntil *int64 `json:"bannedUntil" form:"bannedUntil" validate:"required,min=0"`
|
||
}
|