Files
story/internal/opsaccess/service_integration_test.go
T

138 lines
9.2 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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)
}
2026-08-29 21:56:51 +08:00
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"}
2026-08-26 06:41:07 +08:00
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)
}
}