package controller import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "hy2xs-admin/dao" "hy2xs-admin/model/constant" "hy2xs-admin/service" ) // Секреты, которые лежат в таблице `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.ResetTrafficCron, 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.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 TestUpdateConfigsRefusesRemovedKeys(t *testing.T) { for _, key := range []string{ "HYSTERIA2_ENABLE", "HYSTERIA2_CONFIG", "HYSTERIA2_TRAFFIC_TIME", "HYSTERIA2_CONFIG_REMARK", } { 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) } } } 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) } } // newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась // частично» без базы бессмысленна — именно состояние базы и есть предмет // утверждения. Схема поднимается отдельно от InitSql: bootstrap-администратор // и bootstrap-пир здесь не нужны. func newConfigTestDB(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() }) } func storedConfigValue(t *testing.T, key string) string { t.Helper() config, err := dao.GetConfig("key = ?", key) if err != nil { t.Fatalf("не удалось прочитать %s: %v", key, err) } if config.Value == nil { return "" } return *config.Value } // Отказ на любой записи партии обязан отменить партию ЦЕЛИКОМ. // // Разрешённый ключ стоит ПЕРВЫМ намеренно. Предыдущая версия этого теста // ставила запрещённый ключ первым, поэтому проходила и на реализации, которая // проверяла и записывала настройки в одном цикле: до второго элемента дело не // доходило. Поймать частичное применение такой порядок неспособен по // построению, и состояние базы тест тоже не смотрел. func TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden(t *testing.T) { newConfigTestDB(t) before := storedConfigValue(t, constant.ResetTrafficCron) result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ {"key": constant.ResetTrafficCron, "value": "@weekly"}, {"key": constant.JwtSecret, "value": "attacker-controlled"}, }, }) if result.Type != "no" { t.Fatalf("партия с секретом не отклонена: %+v", result) } if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { t.Fatalf("отклонённая партия изменила %s: было %q, стало %q", constant.ResetTrafficCron, before, after) } if secret := storedConfigValue(t, constant.JwtSecret); secret == "attacker-controlled" { t.Fatal("подписывающий ключ перезаписан отклонённой партией") } } // Успешная партия обязана и сохраниться, и доехать до рантайма — БЕЗ // перезапуска HTTP-сервера. // // Раньше новое расписание применялось единственным способом: обработчик // вызывал StopServer(), точка входа поднимала runServer заново, и новый // планировщик читал настройку из базы. Тест закрепляет обе половины замены: // значение в базе изменилось, джоба перепланирована, число записей // планировщика не выросло. func TestUpdateConfigsAppliesScheduleWithoutRestart(t *testing.T) { newConfigTestDB(t) if err := service.InitCron(); err != nil { t.Fatalf("InitCron: %v", err) } t.Cleanup(service.StopCron) before := service.CronEntryCount() result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ {"key": constant.ResetTrafficCron, "value": "@weekly"}, }, }) if result.Type != "ok" { t.Fatalf("корректная партия отклонена: %+v", result) } if stored := storedConfigValue(t, constant.ResetTrafficCron); stored != "@weekly" { t.Fatalf("значение не сохранено: %q", stored) } if !service.ResetTrafficScheduled() { t.Fatal("новое расписание не применилось к планировщику") } if after := service.CronEntryCount(); after != before { t.Fatalf("применение расписания добавило джобы: было %d, стало %d", before, after) } } // Невалидное значение в партии обязано отменить её так же, как запрещённый // ключ: разница между «нельзя писать сюда» и «нельзя писать это» касается // только текста ошибки, но не последствий для базы. func TestUpdateConfigsRefusesWholeBatchOnInvalidValue(t *testing.T) { newConfigTestDB(t) before := storedConfigValue(t, constant.ResetTrafficCron) result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ {"key": constant.ResetTrafficCron, "value": "какая-нибудь ерунда"}, }, }) if result.Type != "no" { t.Fatalf("невалидное cron-выражение принято: %+v", result) } if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { t.Fatalf("невалидное значение попало в базу: было %q, стало %q", before, after) } } // Один ключ дважды в партии — неоднозначный запрос: какое из двух значений // считать намерением оператора, определить нельзя. func TestUpdateConfigsRefusesDuplicateKeys(t *testing.T) { newConfigTestDB(t) before := storedConfigValue(t, constant.ResetTrafficCron) result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ {"key": constant.ResetTrafficCron, "value": "@weekly"}, {"key": constant.ResetTrafficCron, "value": "@monthly"}, }, }) if result.Type != "no" { t.Fatalf("партия с дубликатом ключа принята: %+v", result) } if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { t.Fatalf("партия с дубликатом изменила базу: было %q, стало %q", before, after) } }