Files
story/internal/httpapi/ops_access_handlers_integration_test.go
T

221 lines
14 KiB
Go

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