package sharing import ( "context" "errors" "os" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestStorySharingReadEditVersionAndRevocationLifecycle(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 := NewService(pool, audit.NewRepository(), &sharingIntegrationStore{}, []byte(strings.Repeat("t", 32)), 5*time.Minute) if err != nil { t.Fatal(err) } owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} editor := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000102"} outsider := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000103"} contextID, contextPublicID := uuid.NewString(), uuid.NewString() conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() storyID, storyPublicID, revisionID := uuid.NewString(), uuid.NewString(), uuid.NewString() mediaID, mediaPublicID, consentID, consentPublicID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() editKey := "sharing-story-edit-" + uuid.NewString() viewKey := "sharing-story-view-" + uuid.NewString() recordingListenKey := "sharing-story-recording-listen-" + uuid.NewString() recordingDownloadKey := "sharing-story-recording-download-" + uuid.NewString() requestPrefix := "sharing-story-" defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%") _, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE scope='sharing-grant.create' AND idempotency_key=ANY($1::text[])`, []string{editKey, viewKey, recordingListenKey, recordingDownloadKey}) _, _ = pool.Exec(ctx, `DELETE FROM sharing_grant WHERE resource_type='STORY' AND resource_public_id=$1::uuid`, storyPublicID) _, _ = pool.Exec(ctx, `DELETE FROM sharing_grant WHERE resource_type='RECORDING' AND resource_public_id=$1::uuid`, mediaPublicID) _, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id=$1::uuid)`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_revision WHERE story_id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_draft WHERE id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM media_segment WHERE id=$1::uuid`, mediaID) _, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM consent_record WHERE id=$1::uuid`, consentID) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) }() if _, err := pool.Exec(ctx, ` INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',1, '10000000-0000-0000-0000-000000000701','[]','sharing-story-v1','s2s_hybrid',$3,'合成故事分享上下文')`, contextID, contextPublicID, "a"+strings.Repeat("1", 63)); 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,started_at,ended_at,stop_reason) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201', '10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501', '10000000-0000-0000-0000-000000000701',$3::uuid,$4,'s2s_hybrid','transcript_only','COMPLETED',now(),now()-interval '2 minutes',now(),'user_hangup')`, conversationID, conversationPublicID, contextID, "sharing-story-task-"+conversationID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, ` INSERT INTO story_draft(id,public_id,tenant_id,subject_id,conversation_id,title,status,confidence,sensitivity,generation_version) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$3::uuid,'合成教师故事','CANDIDATE',0.9,'normal',1)`, storyID, storyPublicID, conversationID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO story_revision(id,story_id,revision_no,content,author_type,model_ref,prompt_version) VALUES($1::uuid,$2::uuid,1,$3,'model','sharing-story-model','v1')`, revisionID, storyID, "初始合成故事内容。"); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=$1::uuid WHERE id=$2::uuid`, revisionID, storyID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, ` INSERT INTO consent_record(id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401', 'raw_audio_storage','{}'::jsonb,'sharing-story-v1',repeat('b',64),'10000000-0000-0000-0000-000000000101')`, consentID, consentPublicID); 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,uploaded_at,verified_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,'10000000-0000-0000-0000-000000000501', 'near',7,'wav_pcm_s16le','audio/wav',1000,32044,repeat('c',64),$4,'VERIFIED',now(),now())`, mediaID, mediaPublicID, conversationID, "sharing/story/recording/"+mediaID+".wav"); err != nil { t.Fatal(err) } expiresAt := time.Now().UTC().Add(time.Hour) editGrant, created, err := service.Create(ctx, owner, editKey, requestPrefix+"edit-create", CreateInput{ ResourceType: "story", ResourceID: storyPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: "edit", Purpose: "共同核对故事", ExpiresAt: expiresAt, LoginRequired: true, }) if err != nil || !created || editGrant.Permission != "EDIT" || !editGrant.LoginRequired { t.Fatalf("edit grant=%+v created=%t err=%v", editGrant, created, err) } shared, err := service.Access(ctx, editor, editGrant.ID, requestPrefix+"edit-access") if err != nil || shared.Title != "合成教师故事" || shared.Text != "初始合成故事内容。" || shared.Version != 1 || shared.RevisionNo != 1 { t.Fatalf("shared story=%+v err=%v", shared, err) } if _, err := service.Access(ctx, outsider, editGrant.ID, requestPrefix+"outsider-access"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider access error=%v", err) } if _, err := service.EditStory(ctx, editor, editGrant.ID, "", "缺少正文", requestPrefix+"invalid-edit", 1); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid edit error=%v", err) } if _, err := service.EditStory(ctx, outsider, editGrant.ID, "越权内容", "越权修改", requestPrefix+"outsider-edit", 1); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider edit error=%v", err) } if _, err := service.EditStory(ctx, editor, editGrant.ID, "错误版本内容", "版本冲突", requestPrefix+"stale-edit", 99); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale edit error=%v", err) } updatedText := "家庭成员核对后的合成故事内容。" updated, err := service.EditStory(ctx, editor, editGrant.ID, updatedText, "根据本人陈述修订", requestPrefix+"edit-success", 1) if err != nil || updated.Text != updatedText || updated.Version != 2 || updated.RevisionNo != 2 || updated.Permission != "EDIT" { t.Fatalf("updated story=%+v err=%v", updated, err) } shared, err = service.Access(ctx, editor, editGrant.ID, requestPrefix+"edit-access-updated") if err != nil || shared.Text != updatedText || shared.Version != 2 || shared.RevisionNo != 2 { t.Fatalf("updated shared story=%+v err=%v", shared, err) } viewGrant, created, err := service.Create(ctx, owner, viewKey, requestPrefix+"view-create", CreateInput{ ResourceType: "STORY", ResourceID: storyPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: "VIEW_TEXT", Purpose: "匿名查看已核对故事", ExpiresAt: expiresAt, LoginRequired: false, }) if err != nil || !created || len(viewGrant.AccessToken) < 32 { t.Fatalf("view grant=%+v created=%t err=%v", viewGrant, created, err) } shared, err = service.AccessByToken(ctx, viewGrant.ID, viewGrant.AccessToken, requestPrefix+"token-access") if err != nil || shared.Text != updatedText || shared.Permission != "VIEW_TEXT" { t.Fatalf("token story=%+v err=%v", shared, err) } if _, err := service.EditStory(ctx, editor, viewGrant.ID, "禁止修改", "只有查看权限", requestPrefix+"view-edit", 2); !errors.Is(err, ErrAccessDenied) { t.Fatalf("view grant edit error=%v", err) } if _, err := service.AccessByToken(ctx, uuid.NewString(), viewGrant.AccessToken, requestPrefix+"missing-token-grant"); !errors.Is(err, ErrNotFound) { t.Fatalf("missing token grant error=%v", err) } if _, err := service.Revoke(ctx, owner, editGrant.ID, requestPrefix+"edit-revoke"); err != nil { t.Fatal(err) } if _, err := service.EditStory(ctx, editor, editGrant.ID, "撤销后修改", "应被拒绝", requestPrefix+"revoked-edit", 2); !errors.Is(err, ErrExpired) { t.Fatalf("revoked edit error=%v", err) } if _, err := service.Access(ctx, editor, editGrant.ID, requestPrefix+"revoked-access"); !errors.Is(err, ErrExpired) { t.Fatalf("revoked access error=%v", err) } recordingListenGrant, created, err := service.Create(ctx, owner, recordingListenKey, requestPrefix+"recording-listen-create", CreateInput{ ResourceType: "RECORDING", ResourceID: mediaPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: "LISTEN", Purpose: "登录后试听录音", ExpiresAt: expiresAt, LoginRequired: true, }) if err != nil || !created { t.Fatalf("recording listen grant=%+v created=%t err=%v", recordingListenGrant, created, err) } recording, err := service.Access(ctx, editor, recordingListenGrant.ID, requestPrefix+"recording-listen-access") if err != nil || recording.AccessURL != "https://objects.invalid/shared-media" || recording.AccessUntil == nil || recording.Permission != "LISTEN" { t.Fatalf("recording listen=%+v err=%v", recording, err) } recordingDownloadGrant, created, err := service.Create(ctx, owner, recordingDownloadKey, requestPrefix+"recording-download-create", CreateInput{ ResourceType: "RECORDING", ResourceID: mediaPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: "DOWNLOAD", Purpose: "匿名下载录音", ExpiresAt: expiresAt, LoginRequired: false, }) if err != nil || !created || len(recordingDownloadGrant.AccessToken) < 32 { t.Fatalf("recording download grant=%+v created=%t err=%v", recordingDownloadGrant, created, err) } recording, err = service.AccessByToken(ctx, recordingDownloadGrant.ID, recordingDownloadGrant.AccessToken, requestPrefix+"recording-download-access") if err != nil || recording.AccessURL != "https://objects.invalid/shared-media" || recording.AccessUntil == nil || recording.Permission != "DOWNLOAD" { t.Fatalf("recording download=%+v err=%v", recording, err) } if _, err := pool.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now(),updated_at=now() WHERE id=$1::uuid`, consentID); err != nil { t.Fatal(err) } if _, err := service.Access(ctx, editor, recordingListenGrant.ID, requestPrefix+"recording-listen-withdrawn"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("withdrawn recording listen error=%v", err) } if _, err := service.AccessByToken(ctx, recordingDownloadGrant.ID, recordingDownloadGrant.AccessToken, requestPrefix+"recording-download-withdrawn"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("withdrawn recording download error=%v", err) } if _, err := service.loadResource(ctx, uuid.NewString(), "UNKNOWN", uuid.NewString(), "VIEW_TEXT"); !errors.Is(err, ErrInvalid) { t.Fatalf("unknown resource type error=%v", err) } }