package accesscontrol import ( "context" "encoding/json" "errors" "path/filepath" "testing" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/workbench" "github.com/google/uuid" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) func versionContext(tenant uint64, user string, admin bool) context.Context { ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant) ctx = context.WithValue(ctx, types.UserIDContextKey, user) role := types.TenantRoleContributor if admin { role = types.TenantRoleAdmin } return context.WithValue(ctx, types.TenantRoleContextKey, role) } func versionDB(t *testing.T) *Service { t.Helper() db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "versions.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { t.Fatal(err) } if err = db.AutoMigrate(&Role{}, &Assignment{}, &Publication{}, &Grant{}, &ResourceFork{}, &ResourceSubmission{}, &ResourceHidden{}, &types.CustomAgent{}, &types.TenantSkillCatalogEntity{}, &types.DigitalHuman{}, &types.DigitalHumanVersion{}, &types.DigitalHumanBinding{}, &types.DigitalHumanJob{}, &workbench.Resource{}, &workbench.Revision{}, &workbench.Job{}, &types.AuditLog{}, &types.KnowledgeBase{}, &types.Knowledge{}); err != nil { t.Fatal(err) } sql, _ := db.DB() if err = db.Exec("CREATE UNIQUE INDEX uq_test_instruction_name ON tenant_skill_catalog(tenant_id,created_by,name,version_source_id) WHERE deleted_at IS NULL AND instruction_only = 1").Error; err != nil { t.Fatal(err) } if err = db.Exec("CREATE TABLE users(id TEXT PRIMARY KEY, username TEXT, deleted_at DATETIME)").Error; err != nil { t.Fatal(err) } t.Cleanup(func() { _ = sql.Close() }) return NewService(db) } func seedGlobal(t *testing.T, s *Service, kind string) string { t.Helper() id := uuid.NewString() var err error switch kind { case "agents": err = s.db.Create(&types.CustomAgent{ID: id, TenantID: 1, CreatedBy: "owner", Name: "原资源", Visibility: "global", Enabled: true, Config: types.CustomAgentConfig{SystemPrompt: "original"}}).Error case "skills": err = s.db.Create(&types.TenantSkillCatalogEntity{ID: id, TenantID: 1, CreatedBy: "owner", Name: "原资源", Visibility: "global", Enabled: true, InstructionOnly: true, Instructions: "original"}).Error case "flows": err = s.db.Create(&workbench.Resource{ID: id, TenantID: 1, OwnerID: "owner", Kind: "flows", Name: "原资源", Version: 1, Data: workbench.JSON{"nodes": []any{}, "edges": []any{}}}).Error case "digital-humans": err = repository.NewDigitalHumanRepository(s.db).Create(versionContext(1, "owner", true), &types.DigitalHuman{ID: id, TenantID: 1, OwnerID: "owner", Name: "原资源", Status: "draft", LatestVersion: 1, Revision: 1, PrimaryAgentID: uuid.NewString()}, map[string]string{"tone": "original"}) } if err != nil { t.Fatal(err) } if err = s.db.Create(&Publication{TenantID: 1, Kind: kind, ResourceID: id, Global: true}).Error; err != nil { t.Fatal(err) } return id } func TestPersonalVersionsPreserveOriginalAndApproveImmutableSubmission(t *testing.T) { for _, kind := range []string{"agents", "skills", "flows", "digital-humans"} { t.Run(kind, func(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, kind) ctx := versionContext(1, "alice", false) admin := versionContext(1, "owner", true) f, err := s.forkResource(ctx, kind, id) if err != nil { t.Fatal(err) } if !f.NoExport || f.PersonalID == id || f.OwnerID != "alice" { t.Fatal("fork provenance missing") } again, err := s.forkResource(ctx, kind, id) if err != nil || again.ID != f.ID { t.Fatal("fork must be idempotent", err) } bob, err := s.forkResource(versionContext(1, "bob", false), kind, id) if err != nil || bob.PersonalID == f.PersonalID { t.Fatal("personal copies shared", err) } body, _, err := s.resourceSnapshot(s.db, ctx, kind, f.PersonalID) if err != nil { t.Fatal(err) } var p map[string]any _ = json.Unmarshal(body, &p) p["name"] = "个人修改" body, _ = json.Marshal(p) if err = s.applyVersionResource(s.db, ctx, kind, f.PersonalID, body); err != nil { t.Fatal(err) } orig, _, _ := s.resourceSnapshot(s.db, ctx, kind, id) var original map[string]any _ = json.Unmarshal(orig, &original) if original["name"] != "原资源" { t.Fatal("fork changed global resource") } r, err := s.submitResourceVersion(ctx, f.ID, "修改说明") if err != nil { t.Fatal(err) } if _, err = s.submitResourceVersion(ctx, f.ID, "duplicate"); !errors.Is(err, errConflict) { t.Fatal("duplicate pending submission accepted", err) } p["name"] = "提交后的私有修改" body, _ = json.Marshal(p) if err = s.applyVersionResource(s.db, ctx, kind, f.PersonalID, body); err != nil { t.Fatal(err) } if err = s.reviewResourceVersion(ctx, r.ID, true, ""); !errors.Is(err, errVersionDenied) { t.Fatal("ordinary user approved", err) } if err = s.reviewResourceVersion(admin, r.ID, true, "通过"); err != nil { t.Fatal(err) } updated, _, _ := s.resourceSnapshot(s.db, ctx, kind, id) _ = json.Unmarshal(updated, &original) if original["name"] != "个人修改" { t.Fatal("approval did not use immutable snapshot", string(updated)) } if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) { t.Fatal("replayed approval accepted", err) } }) } } func TestSkillPersonalVersionPreservesNameForOwnerAndExistingImport(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, "skills") if err := s.db.Create(&types.TenantSkillCatalogEntity{ID: uuid.NewString(), TenantID: 1, CreatedBy: "alice", Name: "原资源", InstructionOnly: true, Instructions: "independent import"}).Error; err != nil { t.Fatal(err) } for _, user := range []string{"owner", "alice"} { ctx := versionContext(1, user, user == "owner") fork, err := s.forkResource(ctx, "skills", id) if err != nil { t.Fatal(err) } var personal types.TenantSkillCatalogEntity if err = s.db.First(&personal, "id=?", fork.PersonalID).Error; err != nil { t.Fatal(err) } if personal.Name != "原资源" || personal.VersionSourceID != id { t.Fatal("personal version lost original name or source") } } } func TestDeletedSkillPersonalVersionCanBeRecreatedWithoutLosingHistory(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, "skills") ctx := versionContext(1, "owner", true) fork, err := s.forkResource(ctx, "skills", id) if err != nil { t.Fatal(err) } submission, err := s.submitResourceVersion(ctx, fork.ID, "history") if err != nil { t.Fatal(err) } if err = s.db.Where("id=?", fork.PersonalID).Delete(&types.TenantSkillCatalogEntity{}).Error; err != nil { t.Fatal(err) } if _, err = s.forkResource(ctx, "skills", id); !errors.Is(err, errConflict) { t.Fatal("pending submission must block recreation", err) } if err = s.reviewResourceVersion(ctx, submission.ID, false, "withdraw old version"); err != nil { t.Fatal(err) } recreated, err := s.forkResource(ctx, "skills", id) if err != nil { t.Fatal(err) } if recreated.PersonalID == fork.PersonalID || recreated.ID != fork.ID { t.Fatal("recreation must keep provenance and create a fresh resource") } if recreated.CreatedAt.IsZero() || !recreated.CreatedAt.Equal(fork.CreatedAt) { t.Fatal("recreation must preserve the original provenance timestamp") } var saved ResourceSubmission if err = s.db.First(&saved, "id=?", submission.ID).Error; err != nil { t.Fatal(err) } if saved.PersonalID != fork.PersonalID || saved.Status != "rejected" { t.Fatal("recreation changed historical submission") } } func TestPersonalVersionsRejectConflictsAndForeignScope(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, "skills") ctx := versionContext(1, "alice", false) if _, err := s.forkResource(versionContext(2, "alice", false), "skills", id); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("cross tenant fork accepted", err) } denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"agents.read": true}}) if _, err := s.forkResource(denied, "skills", id); err == nil { t.Fatal("denied module fork accepted") } f, err := s.forkResource(ctx, "skills", id) if err != nil { t.Fatal(err) } if _, err = s.submitResourceVersion(versionContext(1, "bob", false), f.ID, ""); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("other user's fork submitted", err) } r, err := s.submitResourceVersion(ctx, f.ID, "") if err != nil { t.Fatal(err) } s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id=?", id).Update("instructions", "new global") if err = s.reviewResourceVersion(versionContext(1, "owner", true), r.ID, true, ""); !errors.Is(err, errConflict) { t.Fatal("stale global overwritten", err) } var stored types.TenantSkillCatalogEntity s.db.First(&stored, "id=?", id) if stored.Instructions != "new global" { t.Fatal("conflict changed source") } if err = s.reviewResourceVersion(versionContext(2, "admin", true), r.ID, false, ""); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("cross tenant review accepted", err) } if err = s.reviewResourceVersion(versionContext(1, "owner", true), r.ID, false, "已有新版本"); err != nil { t.Fatal(err) } } func TestPersonalVersionsRefusePrivateGlobalDependencies(t *testing.T) { s := versionDB(t) source := seedGlobal(t, s, "agents") ctx := versionContext(1, "alice", false) privateSkill := types.TenantSkillCatalogEntity{ID: uuid.NewString(), TenantID: 1, CreatedBy: "alice", Name: "private", Visibility: "private", InstructionOnly: true, Enabled: true, Instructions: "private"} if err := s.db.Create(&privateSkill).Error; err != nil { t.Fatal(err) } f, err := s.forkResource(ctx, "agents", source) if err != nil { t.Fatal(err) } var a types.CustomAgent s.db.First(&a, "id=?", f.PersonalID) a.Config.InstructionSkillIDs = []string{privateSkill.ID} s.db.Save(&a) if _, err = s.submitResourceVersion(ctx, f.ID, ""); !errors.Is(err, errInvalid) { t.Fatal("private dependency published", err) } } func TestPersonalAccessHidingAndExportProvenance(t *testing.T) { s := versionDB(t) source := seedGlobal(t, s, "skills") ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "skills", source) if err != nil { t.Fatal(err) } if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "alice", Kind: "skills", ResourceID: source}).Error; err != nil { t.Fatal(err) } for _, user := range []string{"alice", "bob"} { v := &accesspolicy.Snapshot{Publications: map[string]bool{"skills:" + source: true}} if err = s.populatePersonalAccess(versionContext(1, user, false), v); err != nil { t.Fatal(err) } if v.Hidden["skills:"+source] != (user == "alice") { t.Fatal("hide crossed users") } if !v.NoExport["skills:"+f.PersonalID] { t.Fatal("fork lost provenance") } if accesspolicy.Readable(accesspolicy.WithSnapshot(ctx, v), "skills", source, false, true) == (user == "alice") { t.Fatal("hidden shared resource readable") } } } func TestHiddenDigitalHumanAlsoHidesItsDefaultAgent(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, "digital-humans") ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "digital-humans", id) if err != nil { t.Fatal(err) } var human types.DigitalHuman if err = s.db.First(&human, "id=?", f.PersonalID).Error; err != nil { t.Fatal(err) } if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "alice", Kind: "digital-humans", ResourceID: f.PersonalID}).Error; err != nil { t.Fatal(err) } snapshot := &accesspolicy.Snapshot{} if err = s.populatePersonalAccess(ctx, snapshot); err != nil { t.Fatal(err) } ctx = accesspolicy.WithSnapshot(ctx, snapshot) if accesspolicy.Readable(ctx, "agents", human.PrimaryAgentID, true, false) { t.Fatal("hidden digital human default Agent remains visible") } _, err = repository.NewDigitalHumanRepository(s.db).Get(ctx, types.DigitalHumanScope{TenantID: 1, OwnerID: "alice"}, f.PersonalID) if !errors.Is(err, types.ErrDigitalHumanNotFound) { t.Fatal("hidden owned digital human remains readable", err) } if _, err = repository.NewDigitalHumanRepository(s.db).Get(versionContext(1, "owner", true), types.DigitalHumanScope{TenantID: 1, OwnerID: "owner", ReadAll: true}, f.PersonalID); err != nil { t.Fatal("personal hiding removed administrator access", err) } } func TestBoundAgentForkRequiresDigitalHumanVersion(t *testing.T) { s := versionDB(t) id := seedGlobal(t, s, "digital-humans") var human types.DigitalHuman if err := s.db.First(&human, "id=?", id).Error; err != nil { t.Fatal(err) } if err := s.db.Create(&Publication{TenantID: 1, Kind: "agents", ResourceID: human.PrimaryAgentID, Global: true}).Error; err != nil { t.Fatal(err) } if _, err := s.forkResource(versionContext(1, "alice", false), "agents", human.PrimaryAgentID); !errors.Is(err, errInvalid) { t.Fatal("bound Agent cloned without its digital human relationship", err) } }