package controller import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "hy2xs-admin/model/constant" ) // Секреты, которые лежат в таблице `config` рядом с пользовательскими // настройками. Каждый из них раньше можно было прочитать через // GET /api/config/getConfig?key=… и подменить через updateConfigs. var secretConfigKeys = []string{ constant.JwtSecret, constant.PeerSecretKey, constant.PeerSecretEncryptionKey, constant.Hysteria2TrafficStatsSecret, } type apiResult struct { Code int `json:"code"` Type string `json:"type"` Message string `json:"message"` Data json.RawMessage `json:"data"` } func postJSON(t *testing.T, handler gin.HandlerFunc, path string, body any) apiResult { t.Helper() gin.SetMode(gin.TestMode) payload, err := json.Marshal(body) if err != nil { t.Fatalf("не удалось собрать тело запроса: %v", err) } engine := gin.New() engine.POST(path, handler) request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(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 } // Отказ обязан наступать ДО обращения к базе: тест выполняется без // инициализированного SQLite, и любой поход в dao здесь уронил бы обработчик. // Это и есть доказательство того, что проверка стоит на входе. func TestListConfigRefusesInternalKeys(t *testing.T) { for _, key := range secretConfigKeys { result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{"keys": []string{key}}) if result.Type != "no" { t.Errorf("чтение %s не отклонено: %+v", key, result) } if !strings.Contains(result.Message, "not available for read") { t.Errorf("невнятный отказ для %s: %s", key, result.Message) } } } // Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос. func TestListConfigRefusesMixedBatch(t *testing.T) { result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{ "keys": []string{constant.Hysteria2TrafficTime, constant.PeerSecretEncryptionKey}, }) if result.Type != "no" { t.Fatalf("смешанная партия не отклонена: %+v", result) } if len(result.Data) != 0 && string(result.Data) != "null" { t.Fatalf("отказ вернул данные: %s", string(result.Data)) } } func TestListConfigRefusesUnknownKey(t *testing.T) { result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{ "keys": []string{"TOTALLY_UNKNOWN_KEY"}, }) if result.Type != "no" { t.Fatalf("неизвестный ключ не отклонён: %+v", result) } } func TestUpdateConfigsRefusesInternalKeys(t *testing.T) { for _, key := range secretConfigKeys { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{{"key": key, "value": "attacker-controlled"}}, }) if result.Type != "no" { t.Errorf("запись %s не отклонена: %+v", key, result) } } } // Ключи оркестратора отклоняются с указанием владельца, а не общим «нет // такого ключа»: оператор должен понять, что менять их надо через reconfigure. func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) { for _, key := range []string{ constant.Hysteria2Enable, constant.Hysteria2Config, constant.Hysteria2TrafficStatsSecret, } { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{{"key": key, "value": "x"}}, }) if result.Type != "no" { t.Errorf("запись %s не отклонена: %+v", key, result) } if !strings.Contains(result.Message, "hy2xs-orchestrator reconfigure") { t.Errorf("отказ по %s не называет владельца: %s", key, result.Message) } } } func TestUpdateConfigsRefusesUnknownKey(t *testing.T) { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}}, }) if result.Type != "no" { t.Fatalf("неизвестный ключ не отклонён: %+v", result) } if !strings.Contains(result.Message, "not available for write") { t.Fatalf("невнятный отказ: %s", result.Message) } } // Отказ на любой записи партии обязан отменить партию целиком: иначе первые // ключи применились бы, а оператор получил бы ошибку. func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ {"key": constant.JwtSecret, "value": "attacker-controlled"}, {"key": constant.Hysteria2TrafficTime, "value": "10"}, }, }) if result.Type != "no" { t.Fatalf("партия с секретом не отклонена: %+v", result) } }