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:
+20
-22
@@ -63,39 +63,36 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||||
// результат которых виден в списке пиров. Bootstrap-пир после установки
|
||||
// остаётся действующим доступом, и запрет его убрать означал бы вечный
|
||||
// неотзываемый доступ.
|
||||
var errBootstrapPeerIdentity = fmt.Errorf(
|
||||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||||
ReservedBootstrapPeerName,
|
||||
)
|
||||
var errBootstrapPeerIdentity = &PeerError{
|
||||
Code: constant.ErrCodePeerBootstrapLocked,
|
||||
Message: fmt.Sprintf(
|
||||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||||
ReservedBootstrapPeerName,
|
||||
),
|
||||
}
|
||||
|
||||
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||
if peerDto.Name == nil || *peerDto.Name == "" {
|
||||
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
||||
return vo.PeerVo{}, ErrPeerNameRequired
|
||||
}
|
||||
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
|
||||
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
|
||||
// защитой, предназначенной не ему.
|
||||
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
return vo.PeerVo{}, ErrPeerNameReserved
|
||||
}
|
||||
taken, err := ExistPeerName(*peerDto.Name, 0)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
if taken {
|
||||
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
|
||||
return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
|
||||
}
|
||||
secret := ""
|
||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||||
secret = *peerDto.Secret
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
|
||||
secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
authId, err := util.RandomString(18)
|
||||
if err != nil {
|
||||
@@ -178,7 +175,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
|
||||
return err
|
||||
}
|
||||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||||
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
return ErrPeerNameReserved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +215,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
|
||||
//
|
||||
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
|
||||
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
|
||||
// недействующее значение (см. docs/04-admin-panel.md).
|
||||
// недействующее значение (см. docs/admin/04-admin-panel.md).
|
||||
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
||||
|
||||
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
||||
@@ -428,11 +425,12 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
// Генерация одна на весь продукт: импорт без секрета обязан давать
|
||||
// пира, неотличимого от созданного через форму.
|
||||
createSecret, err := GeneratePeerSecret(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||||
digest, err := PeerSecretDigest(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user