114 lines
6.3 KiB
Go
114 lines
6.3 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|