chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user