package saas import ( "context" "errors" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/sharing" "github.com/google/uuid" ) func directStoryReviewFixture(t *testing.T) (homeFixture, string, string) { t.Helper() f := newHomeFixture(t, "Asia/Shanghai") f.service.audit = audit.NewRepository() session := f.direct(t, time.Now().UTC(), time.Minute, "ENDED") f.exec(t, `UPDATE call_session SET budget_snapshot=budget_snapshot || '{"story_extraction_allowed":true}'::jsonb WHERE id=$1::uuid`, session) f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'native-user-turn',1,'USER','小时候我和爷爷在河边种了一棵树,爷爷教我每天浇水。',false,now())`, session) story, public, revision := uuid.NewString(), uuid.NewString(), uuid.NewString() f.exec(t, `INSERT INTO story_draft(id,public_id,tenant_id,subject_id,call_session_id,call_privacy_epoch,title,generation_version) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$4::uuid,0,'和爷爷种树',1)`, story, public, f.subject, session) f.exec(t, `INSERT INTO story_revision(id,story_id,revision_no,content,author_type,model_ref,prompt_version) VALUES($1::uuid,$2::uuid,1,'小时候和爷爷在河边种树。','model','test','test')`, revision, story) f.exec(t, `INSERT INTO story_source(story_revision_id,call_session_id,call_utterance_id,call_source_revision,source_start_offset,source_end_offset,source_role) VALUES($1::uuid,$2::uuid,'native-user-turn',1,0,10,'subject')`, revision, session) f.exec(t, `UPDATE story_draft SET current_revision_id=$2::uuid WHERE id=$1::uuid`, story, revision) return f, public, session } func TestDirectStoryReviewRequiresSourceAccessAndCurrentEvidence(t *testing.T) { f, public, session := directStoryReviewFixture(t) ctx := context.Background() story, err := f.service.GetStory(ctx, f.owner, public) if err != nil || story.ConversationID != "" || story.CallSessionID != session || len(story.Sources) != 1 { t.Fatalf("native story=%+v err=%v", story, err) } source := story.Sources[0] if source.TurnID != "native-user-turn" || source.UtteranceID != source.TurnID || source.CallSessionID != session || source.SourceRevision == nil || *source.SourceRevision != 1 { t.Fatalf("native source=%+v", source) } editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} for _, principal := range []identity.Principal{editor, outsider} { if _, err := f.service.GetStory(ctx, principal, public); !errors.Is(err, ErrAccessDenied) { t.Fatalf("ungranted source read error=%v", err) } } f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject) items, err := f.service.ListSubjectStories(ctx, editor, f.public, "") if err != nil || len(items) != 0 { t.Fatalf("source access required for native list: items=%+v err=%v", items, err) } f.exec(t, `UPDATE memory_access_grant SET sensitive_read=true WHERE subject_id=$1::uuid`, f.subject) if _, err = f.service.GetStory(ctx, editor, public); err != nil { t.Fatalf("granted source read: %v", err) } if _, err = f.service.ReviseStory(ctx, editor, public, "修改", "核对", "native-story-denied", 1, ""); !errors.Is(err, ErrAccessDenied) { t.Fatalf("read-only grant allowed editing: %v", err) } f.exec(t, `UPDATE call_utterance SET source_revision=2,text_content='更正后的原话' WHERE session_id=$1::uuid`, session) if _, err = f.service.GetStory(ctx, f.owner, public); !errors.Is(err, ErrAccessDenied) { t.Fatalf("stale source read: %v", err) } if _, _, err = f.service.ConfirmStory(ctx, f.owner, public, "核对", "native-story-stale", 1); !errors.Is(err, ErrAccessDenied) { t.Fatalf("stale source confirmation: %v", err) } items, err = f.service.ListSubjectStories(ctx, f.owner, f.public, "") if err != nil || len(items) != 0 { t.Fatalf("stale source list: items=%+v err=%v", items, err) } } type directStorySharingStore struct{} func (directStorySharingStore) PresignMediaDownload(context.Context, string, time.Duration, string, string, bool) (string, error) { return "https://objects.invalid/unused", nil } func TestDirectStorySharedEditPreservesNativeSourcesAndWithdrawalHidesIt(t *testing.T) { f, public, session := directStoryReviewFixture(t) ctx := context.Background() service, err := sharing.NewService(f.pool, audit.NewRepository(), directStorySharingStore{}, []byte(strings.Repeat("s", 32)), time.Minute) if err != nil { t.Fatal(err) } var recipient string if err := f.pool.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id='10000000-0000-0000-0000-000000000102'`).Scan(&recipient); err != nil { t.Fatal(err) } grant, _, err := service.Create(ctx, f.owner, uuid.NewString(), "native-sharing-create", sharing.CreateInput{ResourceType: "STORY", ResourceID: public, GranteeUserID: recipient, Permission: "EDIT", Purpose: "核对回忆", ExpiresAt: time.Now().Add(time.Hour), LoginRequired: true}) if err != nil { t.Fatal(err) } editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} if _, err = service.EditStory(ctx, editor, grant.ID, "小时候我和爷爷一起在河边种树。", "核对原话", "native-sharing-edit", 1); err != nil { t.Fatal(err) } story, err := f.service.GetStory(ctx, f.owner, public) if err != nil || story.RevisionNo != 2 || len(story.Sources) != 1 || story.Sources[0].CallSessionID != session || story.Sources[0].UtteranceID != "native-user-turn" { t.Fatalf("shared revision lost evidence: story=%+v err=%v", story, err) } if _, err = service.Access(ctx, editor, grant.ID, "native-sharing-read"); err != nil { t.Fatal(err) } f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject) if _, err = service.Access(ctx, editor, grant.ID, "native-sharing-withdrawn-read"); err == nil { t.Fatal("shared story remained readable after withdrawal") } if _, err = service.EditStory(ctx, editor, grant.ID, "撤回后修改", "应拒绝", "native-sharing-withdrawn-edit", 2); err == nil { t.Fatal("shared story remained editable after withdrawal") } }