Files
story/internal/sharing/story_integration_test.go
T

199 lines
12 KiB
Go

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