package httpapi import ( "context" "errors" "net/http" "net/http/httptest" "os" "strings" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/diagnostics" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestDiagnosticHandlersSearchRefreshJobsAndBundle(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 := diagnostics.NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository()) if err != nil { t.Fatal(err) } admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} tenantID := "10000000-0000-0000-0000-000000000010" deviceID := "10000000-0000-0000-0000-000000000511" refreshKey := "http-diagnostic-refresh-" + uuid.NewString() bundleKey := "http-diagnostic-bundle-" + uuid.NewString() requestPrefix := "http-diagnostic-" defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%") _, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE idempotency_key=$1`, "ops-refresh:"+refreshKey) _, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{refreshKey, bundleKey}) }() setHeaders := func(request *http.Request, key string) { request.Header.Set("X-Tenant-Id", tenantID) if key != "" { request.Header.Set("Idempotency-Key", key) } } withRequestID := func(request *http.Request, id string) *http.Request { return request.WithContext(context.WithValue(request.Context(), requestIDKey, id)) } request := authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/search?field=device_id&value="+deviceID, "", admin) setHeaders(request, "") recorder := httptest.NewRecorder() SearchOpsDiagnosticsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), deviceID) { t.Fatalf("search status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+deviceID+"/refresh-config", `{"reason":"HTTP诊断刷新配置"}`, admin), requestPrefix+"refresh") request.SetPathValue("id", deviceID) setHeaders(request, refreshKey) recorder = httptest.NewRecorder() RefreshOpsDiagnosticDeviceConfigHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), `"queued":true`) { t.Fatalf("refresh status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+deviceID+"/refresh-config", `{"reason":"HTTP诊断刷新配置"}`, admin), requestPrefix+"refresh-replay") request.SetPathValue("id", deviceID) setHeaders(request, refreshKey) recorder = httptest.NewRecorder() RefreshOpsDiagnosticDeviceConfigHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" { t.Fatalf("refresh replay status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/jobs", "", admin) setHeaders(request, "") recorder = httptest.NewRecorder() ListOpsDiagnosticJobsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("list jobs status=%d body=%s", recorder.Code, recorder.Body.String()) } bundleBody := `{"field":"device_id","value":"` + deviceID + `","reason":"导出HTTP脱敏诊断证据"}` request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", bundleBody, admin), requestPrefix+"bundle") setHeaders(request, bundleKey) recorder = httptest.NewRecorder() ExportOpsDiagnosticBundleHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), "ops-diagnostic-minimal-v1") || !strings.Contains(recorder.Body.String(), deviceID) { t.Fatalf("bundle status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", bundleBody, admin), requestPrefix+"bundle-replay") setHeaders(request, bundleKey) recorder = httptest.NewRecorder() ExportOpsDiagnosticBundleHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" { t.Fatalf("bundle replay status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestDiagnosticHandlersRejectInvalidRequestsBeforeService(t *testing.T) { principal := identity.Principal{Issuer: "issuer", Subject: "subject"} tenantID, resourceID := uuid.NewString(), uuid.NewString() tests := []struct { name string handler http.Handler request *http.Request status int code string }{ {"search unauthenticated", SearchOpsDiagnosticsHandler(nil), httptest.NewRequest(http.MethodGet, "/api/v1/ops/diagnostics/search", nil), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"}, {"search missing tenant", SearchOpsDiagnosticsHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/search", "", principal), http.StatusBadRequest, "VALIDATION_FAILED"}, {"stop missing idempotency", StopOpsDiagnosticConversationHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/conversations/"+resourceID+"/stop", `{}`, principal), http.StatusBadRequest, "VALIDATION_FAILED"}, {"reconcile malformed body", ReconcileOpsDiagnosticConversationHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/conversations/"+resourceID+"/reconcile", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"}, {"retry malformed body", RetryOpsDiagnosticJobHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/jobs/"+resourceID+"/retry", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"}, {"revoke malformed body", RevokeOpsDiagnosticDeviceHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+resourceID+"/revoke", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"}, {"bundle malformed body", ExportOpsDiagnosticBundleHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"}, } for _, item := range tests { t.Run(item.name, func(t *testing.T) { if !strings.Contains(item.name, "unauthenticated") && !strings.Contains(item.name, "missing tenant") { item.request.Header.Set("X-Tenant-Id", tenantID) } if strings.Contains(item.name, "malformed") { item.request.Header.Set("Idempotency-Key", "http-diagnostic-invalid-key") } if strings.Contains(item.name, "stop") || strings.Contains(item.name, "reconcile") || strings.Contains(item.name, "retry") || strings.Contains(item.name, "revoke") { item.request.SetPathValue("id", resourceID) } recorder := httptest.NewRecorder() item.handler.ServeHTTP(recorder, item.request) if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } }) } } func TestWriteDiagnosticErrorStableMappings(t *testing.T) { tests := []struct { err error status int code string }{ {diagnostics.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"}, {diagnostics.ErrAccessDenied, http.StatusForbidden, "OPS_ACCESS_DENIED"}, {diagnostics.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"}, {diagnostics.ErrStateConflict, http.StatusConflict, "DIAGNOSTIC_STATE_CONFLICT"}, {idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"}, {idempotency.ErrInProgress, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS"}, {errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"}, } for _, item := range tests { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ops/diagnostics", nil) writeDiagnosticError(recorder, request, item.err) if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) { t.Fatalf("error=%v status=%d body=%s", item.err, recorder.Code, recorder.Body.String()) } } }