162759c599
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.
Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.
Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.
Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.
Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.
Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
549 lines
20 KiB
Go
549 lines
20 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"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() })
|
|
}
|
|
|
|
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
|
|
//
|
|
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
|
|
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
|
|
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
|
|
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
|
|
// делает вопрос «сколько устройств онлайн» отвечаемым.
|
|
func answerTrafficStats(t *testing.T) {
|
|
t.Helper()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/online" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte("{}"))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
|
|
if err != nil {
|
|
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
|
}
|
|
|
|
previous := constant.Hysteria2ConfigPath
|
|
constant.Hysteria2ConfigPath = path
|
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
|
|
|
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
|
|
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
answerTrafficStats(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)
|
|
}
|
|
}
|