package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/concurrency" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/opsconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestOpsConfigHandlersPublishAndRollback(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service, err := opsconfig.NewService(pool, audit.NewRepository()) if err != nil { t.Fatal(err) } admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"} approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"} tenantID := "10000000-0000-0000-0000-000000000010" configKey := "http.integration.routing." + uuid.NewString() createKey, evaluationKey, rollbackKey := "http-ops-create-"+uuid.NewString(), "http-ops-eval-"+uuid.NewString(), "http-ops-rollback-"+uuid.NewString() defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'http-ops-%'`) _, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{createKey, evaluationKey, rollbackKey}) _, _ = pool.Exec(ctx, `DELETE FROM runtime_config_rollout WHERE config_key=$1`, configKey) _, _ = pool.Exec(ctx, `DELETE FROM ops_config_evaluation WHERE config_version_id IN (SELECT id FROM runtime_config_version WHERE config_key=$1)`, configKey) _, _ = pool.Exec(ctx, `DELETE FROM runtime_config_version WHERE config_key=$1`, configKey) }() withRequestID := func(request *http.Request, id string) *http.Request { return request.WithContext(context.WithValue(request.Context(), requestIDKey, id)) } setOpsHeaders := func(request *http.Request, key string) { request.Header.Set("X-Tenant-Id", tenantID) if key != "" { request.Header.Set("Idempotency-Key", key) } } configBody, _ := json.Marshal(map[string]any{"config_key": configKey, "display_name": "HTTP路由配置", "config_kind": "ROUTING", "payload": map[string]any{"default_engine_mode": "s2s_hybrid", "routes": []any{}}, "secret_refs": map[string]string{}, "change_summary": "HTTP配置状态机"}) request := withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions", string(configBody), admin), "http-ops-create") setOpsHeaders(request, createKey) recorder := httptest.NewRecorder() CreateOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("create config status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) } var configResponse struct { Data opsconfig.Version `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &configResponse); err != nil { t.Fatal(err) } version := configResponse.Data request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions", string(configBody), admin), "http-ops-create-replay") setOpsHeaders(request, createKey) recorder = httptest.NewRecorder() CreateOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" { t.Fatalf("replay config status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-versions?config_key="+configKey, "", approver) setOpsHeaders(request, "") recorder = httptest.NewRecorder() ListOpsConfigVersionsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), version.ID) { t.Fatalf("list config status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/submit", "", admin), "http-ops-submit") request.SetPathValue("id", version.ID) setOpsHeaders(request, "") recorder = httptest.NewRecorder() SubmitOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusPreconditionRequired { t.Fatalf("missing submit ETag status=%d body=%s", recorder.Code, recorder.Body.String()) } request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() SubmitOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) { t.Fatalf("submit config status=%d body=%s", recorder.Code, recorder.Body.String()) } publishBody := `{"reason":"HTTP评测通过发布","rollout":{"scope_type":"TENANT","rollout_percent":100,"max_concurrent_calls":10}}` request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/publish", publishBody, approver), "http-ops-publish-no-eval") request.SetPathValue("id", version.ID) setOpsHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(2)) recorder = httptest.NewRecorder() PublishOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "CONFIG_PUBLISH_GATE_FAILED") { t.Fatalf("publish gate status=%d body=%s", recorder.Code, recorder.Body.String()) } evaluationBody := `{"suite_version":"http-suite-v1","result":"PASSED","metrics":{"cases":8,"passed":8},"report_ref":"integration://http-ops/report"}` request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/evaluations", evaluationBody, admin), "http-ops-evaluation") request.SetPathValue("id", version.ID) setOpsHeaders(request, evaluationKey) recorder = httptest.NewRecorder() CreateOpsConfigEvaluationHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("create evaluation status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-versions/"+version.ID+"/evaluations", "", approver) request.SetPathValue("id", version.ID) setOpsHeaders(request, "") recorder = httptest.NewRecorder() ListOpsConfigEvaluationsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "http-suite-v1") { t.Fatalf("list evaluations status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/publish", publishBody, approver), "http-ops-publish") request.SetPathValue("id", version.ID) setOpsHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(2)) recorder = httptest.NewRecorder() PublishOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(3) { t.Fatalf("publish config status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-rollouts?config_key="+configKey, "", approver) setOpsHeaders(request, "") recorder = httptest.NewRecorder() ListOpsConfigRolloutsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), version.ID) { t.Fatalf("list rollouts status=%d body=%s", recorder.Code, recorder.Body.String()) } var rolloutResponse struct { Data struct { Items []opsconfig.Rollout `json:"items"` } `json:"data"` } if err = json.Unmarshal(recorder.Body.Bytes(), &rolloutResponse); err != nil || len(rolloutResponse.Data.Items) != 1 { t.Fatalf("decode rollouts=%+v err=%v", rolloutResponse, err) } activeRollout := rolloutResponse.Data.Items[0] pauseBody := `{"reason":"HTTP纵向冒烟暂停"}` request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/pause", pauseBody, approver), "http-ops-rollout-pause") request.SetPathValue("id", activeRollout.ID) setOpsHeaders(request, "") recorder = httptest.NewRecorder() PauseOpsConfigRolloutHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusPreconditionRequired { t.Fatalf("missing rollout ETag status=%d body=%s", recorder.Code, recorder.Body.String()) } request.Header.Set("If-Match", concurrency.FormatETag(activeRollout.RowVersion)) recorder = httptest.NewRecorder() PauseOpsConfigRolloutHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) { t.Fatalf("pause rollout status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) } resumeBody := `{"reason":"HTTP纵向冒烟恢复"}` request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/resume", resumeBody, approver), "http-ops-rollout-resume-stale") request.SetPathValue("id", activeRollout.ID) setOpsHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() ResumeOpsConfigRolloutHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "VERSION_CONFLICT") { t.Fatalf("stale rollout resume status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/resume", resumeBody, approver), "http-ops-rollout-resume") request.SetPathValue("id", activeRollout.ID) setOpsHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(2)) recorder = httptest.NewRecorder() ResumeOpsConfigRolloutHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(3) || !strings.Contains(recorder.Body.String(), `"status":"ACTIVE"`) { t.Fatalf("resume rollout status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) } rollbackBody := `{"reason":"HTTP回滚验证","rollout":{"scope_type":"TENANT","rollout_percent":100,"max_concurrent_calls":10}}` request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/rollback", rollbackBody, approver), "http-ops-rollback") request.SetPathValue("id", version.ID) setOpsHeaders(request, rollbackKey) recorder = httptest.NewRecorder() RollbackOpsConfigVersionHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || !strings.Contains(recorder.Body.String(), `"version_no":2`) { t.Fatalf("rollback status=%d body=%s", recorder.Code, recorder.Body.String()) } }