221 lines
14 KiB
Go
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-000000000104"}
|
|
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
|
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())
|
|
}
|
|
}
|
|
}
|