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