package httpapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/opsaccess" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type httpOpsAccessStore struct{} func (httpOpsAccessStore) PresignMediaDownload(_ context.Context, objectRef string, _ time.Duration, _ string, _ string, _ bool) (string, error) { return "https://objects.example.test/" + objectRef, nil } func TestOpsAccessHandlersCompleteTwoPersonAccessFlow(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 := opsaccess.NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository(), httpOpsAccessStore{}, 5*time.Minute) if err != nil { t.Fatal(err) } requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000102"} tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001" conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() consentSnapshotID, contextSnapshotID := uuid.NewString(), uuid.NewString() turnID, segmentID, segmentPublicID, consentID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() keyPrefix := "http-ops-access-" + uuid.NewString() createKey, reviewKey := keyPrefix+"-create", keyPrefix+"-review" requestPrefix := keyPrefix + "-request-" reason := "HTTP双人审批后临时排查转写和录音不一致-" + uuid.NewString() defer func() { _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%") _, _ = pool.Exec(context.Background(), `DELETE FROM ops_sensitive_access_log WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(context.Background(), `DELETE FROM ops_sensitive_access_request WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(context.Background(), `DELETE FROM media_segment WHERE id=$1::uuid`, segmentID) _, _ = pool.Exec(context.Background(), `DELETE FROM conversation_turn WHERE id=$1::uuid`, turnID) _, _ = pool.Exec(context.Background(), `DELETE FROM conversation WHERE id=$1::uuid`, conversationID) _, _ = pool.Exec(context.Background(), `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextSnapshotID) _, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID) _, _ = pool.Exec(context.Background(), `DELETE FROM consent_record WHERE id=$1::uuid`, consentID) _, _ = pool.Exec(context.Background(), `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{createKey, reviewKey}) }() if _, err = pool.Exec(ctx, `INSERT INTO consent_record(id,tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'raw_audio_storage',$3,repeat('a',64),'10000000-0000-0000-0000-000000000101'::uuid)`, consentID, tenantID, keyPrefix); err != nil { t.Fatal(err) } if _, err = pool.Exec(ctx, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,$3,'{"transcript_storage":true,"raw_audio_storage":true}'::jsonb)`, consentSnapshotID, tenantID, time.Now().UnixNano()%2_000_000_000); err != nil { t.Fatal(err) } if _, err = pool.Exec(ctx, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,$3::uuid,$4,'s2s_hybrid',repeat('b',64))`, contextSnapshotID, tenantID, consentSnapshotID, keyPrefix); err != nil { t.Fatal(err) } if _, err = pool.Exec(ctx, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,ended_at) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,$4::uuid,$5::uuid,$6,'s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '5 minutes',now())`, conversationID, conversationPublicID, tenantID, consentSnapshotID, contextSnapshotID, keyPrefix); err != nil { t.Fatal(err) } if _, err = pool.Exec(ctx, `INSERT INTO conversation_turn(id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,$3::uuid,$4,1,'subject',now()-interval '2 seconds',now()-interval '1 second','仅在批准后可见的HTTP测试字幕','provider')`, turnID, tenantID, conversationID, keyPrefix); err != nil { t.Fatal(err) } if _, err = pool.Exec(ctx, `INSERT INTO media_segment(id,public_id,tenant_id,conversation_id,device_id,track,sequence_no,codec,content_type,duration_ms,expected_size,expected_digest,object_ref,status,verified_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'near',0,'pcm16','audio/wav',1000,44,repeat('c',64),$5,'VERIFIED',now())`, segmentID, segmentPublicID, tenantID, conversationID, keyPrefix+"/audio.wav"); err != nil { t.Fatal(err) } requestWithHeaders := func(method, target, body string, principal identity.Principal, idempotencyKey, requestID string) *http.Request { request := authenticatedRequest(method, target, body, principal) request.Header.Set("X-Tenant-Id", tenantPublicID) if idempotencyKey != "" { request.Header.Set("Idempotency-Key", idempotencyKey) } return request.WithContext(context.WithValue(request.Context(), requestIDKey, requestPrefix+requestID)) } expiresAt := time.Now().UTC().Add(time.Hour) createBody, _ := json.Marshal(map[string]any{"conversation_id": conversationPublicID, "capabilities": []string{opsaccess.CapabilityTranscript, opsaccess.CapabilityRecording}, "reason": reason, "expires_at": expiresAt}) request := requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests", string(createBody), requester, createKey, "create") recorder := httptest.NewRecorder() CreateOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("create status=%d body=%s", recorder.Code, recorder.Body.String()) } var createdResponse struct { Data opsaccess.Request `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &createdResponse); err != nil { t.Fatal(err) } created := createdResponse.Data if created.ID == "" || created.Status != "PENDING" { t.Fatalf("unexpected created request: %+v", created) } request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests", string(createBody), requester, createKey, "create-replay") recorder = httptest.NewRecorder() CreateOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" { t.Fatalf("create replay status=%d body=%s", recorder.Code, recorder.Body.String()) } request = requestWithHeaders(http.MethodGet, "/api/v1/ops/sensitive-access-requests", "", approver, "", "list") recorder = httptest.NewRecorder() ListOpsSensitiveAccessRequestsHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), created.ID) || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("list status=%d body=%s", recorder.Code, recorder.Body.String()) } reviewBody := `{"decision":"APPROVE","reason":"另一名运营人员完成最小权限复核"}` request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests/"+created.ID+"/review", reviewBody, approver, reviewKey, "review") request.SetPathValue("id", created.ID) recorder = httptest.NewRecorder() ReviewOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"APPROVED"`) { t.Fatalf("review status=%d body=%s", recorder.Code, recorder.Body.String()) } request = requestWithHeaders(http.MethodGet, "/api/v1/ops/sensitive-conversations/"+conversationPublicID, "", requester, "", "conversation") request.SetPathValue("id", conversationPublicID) request.Header.Set("X-Ops-Access-Grant", created.ID) recorder = httptest.NewRecorder() GetOpsSensitiveConversationHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "仅在批准后可见的HTTP测试字幕") || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("conversation status=%d body=%s", recorder.Code, recorder.Body.String()) } request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+segmentPublicID+":access", `{"grant_id":"`+created.ID+`"}`, requester, "", "recording") request.SetPathValue("id", segmentPublicID) recorder = httptest.NewRecorder() CreateOpsSensitiveRecordingAccessHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || !strings.Contains(recorder.Body.String(), "https://objects.example.test/") || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("recording status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestOpsAccessHandlersRejectInvalidRequestsBeforeService(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 }{ {"create unauthenticated", CreateOpsSensitiveAccessRequestHandler(nil), httptest.NewRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", nil)}, {"create missing tenant", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{}`, principal)}, {"create missing idempotency", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{}`, principal)}, {"create malformed body", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{`, principal)}, {"list missing tenant", ListOpsSensitiveAccessRequestsHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/sensitive-access-requests", "", principal)}, {"review malformed body", ReviewOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests/"+resourceID+"/review", `{`, principal)}, {"conversation missing grant", GetOpsSensitiveConversationHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/sensitive-conversations/"+resourceID, "", principal)}, {"recording malformed body", CreateOpsSensitiveRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+resourceID+":access", `{`, principal)}, {"recording missing grant", CreateOpsSensitiveRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+resourceID+":access", `{}`, principal)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if !strings.Contains(test.name, "missing tenant") && !strings.Contains(test.name, "unauthenticated") { test.request.Header.Set("X-Tenant-Id", tenantID) } if strings.Contains(test.name, "malformed") { test.request.Header.Set("Idempotency-Key", "http-ops-access-invalid") } if strings.Contains(test.name, "review") || strings.Contains(test.name, "conversation") || strings.Contains(test.name, "recording") { test.request.SetPathValue("id", resourceID) } recorder := httptest.NewRecorder() test.handler.ServeHTTP(recorder, test.request) if recorder.Code != http.StatusBadRequest && recorder.Code != http.StatusUnauthorized { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } if !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") && !strings.Contains(recorder.Body.String(), "AUTHENTICATION_REQUIRED") { t.Fatalf("unexpected body=%s", recorder.Body.String()) } }) } } func TestWriteOpsAccessErrorStableMappings(t *testing.T) { tests := []struct { err error status int code string }{ {opsaccess.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"}, {opsaccess.ErrAccessDenied, http.StatusForbidden, "TEMPORARY_ACCESS_DENIED"}, {opsaccess.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"}, {opsaccess.ErrConflict, http.StatusConflict, "TEMPORARY_ACCESS_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 _, test := range tests { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ops/sensitive-access-requests", nil) writeOpsAccessError(recorder, request, test.err) if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) { t.Fatalf("error=%v status=%d body=%s", test.err, recorder.Code, recorder.Body.String()) } } }