package opsaccess import ( "context" "errors" "os" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "github.com/google/uuid" ) type fakeStore struct{} func (fakeStore) PresignMediaDownload(_ context.Context, objectRef string, _ time.Duration, _ string, _ string, _ bool) (string, error) { return "https://objects.example.test/" + objectRef, nil } func TestTemporarySensitiveAccessRequiresTwoPeopleAndAuditsEveryRead(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := database.Open(ctx, databaseURL, "ops-access-integration-test") if err != nil { t.Fatal(err) } defer pool.Close() service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository(), fakeStore{}, 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"} outsider := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000103"} 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() keys := []string{"ops-access-create", "ops-access-self-review", "ops-access-approve"} defer func() { _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id LIKE 'ops-access-test-%'`) _, _ = 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[])`, keys) }() 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','ops-access-test',repeat('a',64),'10000000-0000-0000-0000-000000000101'::uuid)`, consentID, tenantID); 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,9201,'{"transcript_storage":true,"raw_audio_storage":true}'::jsonb)`, consentSnapshotID, tenantID); 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,'ops-access-test','s2s_hybrid',repeat('b',64))`, contextSnapshotID, tenantID, consentSnapshotID); 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, "ops-access-task-"+conversationPublicID); 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,'ops-access-turn',1,'subject',now()-interval '2 seconds',now()-interval '1 second','仅在批准后可见的测试字幕','provider')`, turnID, tenantID, conversationID); 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, "ops-access/object-"+segmentPublicID); err != nil { t.Fatal(err) } expiresAt := time.Now().UTC().Add(time.Hour) created, fresh, err := service.CreateRequest(ctx, requester, tenantPublicID, conversationPublicID, []string{CapabilityTranscript, CapabilityRecording}, "排查一次供应商转写与录音不一致", expiresAt, keys[0], "ops-access-test-create") if err != nil || !fresh || created.Status != "PENDING" || len(created.Capabilities) != 2 { t.Fatalf("created=%+v fresh=%t err=%v", created, fresh, err) } requesterItems, err := service.ListRequests(ctx, requester, tenantPublicID) if err != nil || len(requesterItems) != 1 || requesterItems[0].ID != created.ID || requesterItems[0].Status != "PENDING" { t.Fatalf("requester items=%+v err=%v", requesterItems, err) } approverItems, err := service.ListRequests(ctx, approver, tenantPublicID) if err != nil { t.Fatal(err) } found := false for _, item := range approverItems { if item.ID == created.ID { found = true } } if !found { t.Fatalf("approver list missing request %s: %+v", created.ID, approverItems) } if _, err = service.ListRequests(ctx, outsider, tenantPublicID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("cross-tenant request list error=%v", err) } if _, _, err = service.ReviewRequest(ctx, requester, tenantPublicID, created.ID, "APPROVE", "不能自批", keys[1], "ops-access-test-self-review"); !errors.Is(err, ErrConflict) { t.Fatalf("self review error=%v", err) } if _, err = service.GetSensitiveConversation(ctx, approver, tenantPublicID, conversationPublicID, created.ID, "ops-access-test-wrong-user"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("wrong requester access error=%v", err) } approved, fresh, err := service.ReviewRequest(ctx, approver, tenantPublicID, created.ID, "APPROVE", "双人复核通过,仅限本次问题", keys[2], "ops-access-test-approve") if err != nil || !fresh || approved.Status != "APPROVED" || approved.ReviewedBy == "" { t.Fatalf("approved=%+v fresh=%t err=%v", approved, fresh, err) } conversation, err := service.GetSensitiveConversation(ctx, requester, tenantPublicID, conversationPublicID, created.ID, "ops-access-test-transcript") if err != nil || len(conversation.Turns) != 1 || conversation.Turns[0].FinalText != "仅在批准后可见的测试字幕" || len(conversation.Segments) != 1 { t.Fatalf("conversation=%+v err=%v", conversation, err) } recording, err := service.CreateRecordingAccess(ctx, requester, tenantPublicID, segmentPublicID, created.ID, "ops-access-test-recording") if err != nil || recording.AccessURL == "" || !recording.AccessUntil.After(time.Now().UTC()) { t.Fatalf("recording=%+v err=%v", recording, err) } service.now = func() time.Time { return expiresAt.Add(time.Second) } if _, err = service.GetSensitiveConversation(ctx, requester, tenantPublicID, conversationPublicID, created.ID, "ops-access-test-expired"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("expired access error=%v", err) } var status string var successes, denied int if err = pool.QueryRow(ctx, `SELECT status FROM ops_sensitive_access_request WHERE public_id=$1::uuid`, created.ID).Scan(&status); err != nil || status != "EXPIRED" { t.Fatalf("status=%s err=%v", status, err) } requesterItems, err = service.ListRequests(ctx, requester, tenantPublicID) if err != nil || len(requesterItems) != 1 || requesterItems[0].Status != "EXPIRED" || requesterItems[0].Version != 3 { t.Fatalf("expired requester items=%+v err=%v", requesterItems, err) } if err = pool.QueryRow(ctx, `SELECT count(*) FILTER(WHERE result='SUCCESS'),count(*) FILTER(WHERE result='DENIED') FROM ops_sensitive_access_log WHERE conversation_id=$1::uuid`, conversationID).Scan(&successes, &denied); err != nil || successes != 2 || denied != 2 { t.Fatalf("access logs success=%d denied=%d err=%v", successes, denied, err) } }