Files
story/internal/httpapi/diagnostic_handlers_integration_test.go
T
2026-09-12 08:57:29 +08:00

168 lines
8.7 KiB
Go

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-000000000104"}
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())
}
}
}