package accesscontrol import ( "context" "errors" "fmt" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/knowledgeversion" "github.com/Tencent/WeKnora/internal/types" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" ) type versionCopyFixture struct { db *gorm.DB dir string fail bool afterDocument func(int) afterCopy func() } func (f *versionCopyFixture) CloneKnowledgeVersion(ctx context.Context, source, target string, progress func(int, int) error) error { if !knowledgeversion.IsWorker(ctx) { return fmt.Errorf("missing worker context") } var docs []types.Knowledge if err := f.db.Where("knowledge_base_id=?", source).Find(&docs).Error; err != nil { return err } if err := progress(0, len(docs)); err != nil { return err } for i, src := range docs { dst := src dst.ID = uuid.NewString() dst.KnowledgeBaseID = target if src.FilePath != "" { b, err := os.ReadFile(src.FilePath) if err != nil { return err } dst.FilePath = filepath.Join(f.dir, dst.ID) if err = os.WriteFile(dst.FilePath, b, 0600); err != nil { return err } } if err := f.db.Create(&dst).Error; err != nil { return err } if f.fail { return fmt.Errorf("injected storage outage") } var chunks []types.Chunk if err := f.db.Where("knowledge_id=?", src.ID).Find(&chunks).Error; err != nil { return err } for _, c := range chunks { c.ID = uuid.NewString() c.SeqID = 0 c.KnowledgeID = dst.ID c.KnowledgeBaseID = target if err := f.db.Create(&c).Error; err != nil { return err } } if f.afterDocument != nil { f.afterDocument(i + 1) } if err := progress(i+1, len(docs)); err != nil { return err } } if f.afterCopy != nil { f.afterCopy() } return nil } func knowledgeVersionFixture(t *testing.T) (*Service, *versionCopyFixture, string) { t.Helper() s := versionDB(t) if err := s.db.AutoMigrate(&types.TenantMember{}, &types.Chunk{}, &types.KnowledgeTag{}, &types.KnowledgeTagRelation{}, &KnowledgeVersionJob{}, &KnowledgeVersionAlias{}); err != nil { t.Fatal(err) } for _, statement := range []string{ "ALTER TABLE users ADD COLUMN tenant_id INTEGER DEFAULT 1", "ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE", "ALTER TABLE users ADD COLUMN is_system_admin BOOLEAN DEFAULT FALSE", "ALTER TABLE users ADD COLUMN can_access_all_tenants BOOLEAN DEFAULT FALSE", } { if err := s.db.Exec(statement).Error; err != nil { t.Fatal(err) } } for _, id := range []string{"owner", "alice", "bob"} { if err := s.db.Table("users").Create(map[string]any{"id": id, "username": id, "is_active": true}).Error; err != nil { t.Fatal(err) } if err := s.db.Create(&types.TenantMember{TenantID: 1, UserID: id, Role: types.TenantRoleContributor, Status: types.TenantMemberStatusActive}).Error; err != nil { t.Fatal(err) } } fake := &versionCopyFixture{db: s.db, dir: t.TempDir()} s.SetKnowledgeVersionAdapter(fake) kb := types.KnowledgeBase{ID: uuid.NewString(), TenantID: 1, CreatorID: "owner", Name: "知识原件", Type: "document"} if err := s.db.Create(&kb).Error; err != nil { t.Fatal(err) } if err := s.db.Create(&Publication{TenantID: 1, Kind: "knowledge", ResourceID: kb.ID, Global: true}).Error; err != nil { t.Fatal(err) } path := filepath.Join(fake.dir, "source.txt") if err := os.WriteFile(path, []byte("independent content"), 0600); err != nil { t.Fatal(err) } doc := types.Knowledge{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: kb.ID, Title: "原文", Type: "file", FilePath: path, ParseStatus: types.ParseStatusCompleted, CustomMetadata: types.JSON(`{}`)} if err := s.db.Create(&doc).Error; err != nil { t.Fatal(err) } if err := s.db.Create(&types.Chunk{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: kb.ID, KnowledgeID: doc.ID, Content: "原片段", SourceContent: "原片段", ChunkType: types.ChunkTypeText, IndexStatus: "ready"}).Error; err != nil { t.Fatal(err) } return s, fake, kb.ID } func refreshFork(t *testing.T, s *Service, f *ResourceFork) { t.Helper() if err := s.db.First(f, "id=?", f.ID).Error; err != nil { t.Fatal(err) } } func runVersionJob(t *testing.T, s *Service) { t.Helper() if err := s.RunKnowledgeVersionJob(context.Background()); err != nil { t.Fatal(err) } } func forkVersionJob(t *testing.T, s *Service, f *ResourceFork) KnowledgeVersionJob { t.Helper() var j KnowledgeVersionJob if err := s.db.Where("fork_id=? AND purpose='fork'", f.ID).First(&j).Error; err != nil { t.Fatal(err) } return j } func TestKnowledgeVersionImmutableApprovalAndAlias(t *testing.T) { s, _, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) admin := versionContext(1, "owner", true) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } if f.Status != "copying" { t.Fatal("copy was exposed synchronously") } if _, err = s.submitResourceVersion(ctx, f.ID, ""); !errors.Is(err, errConflict) { t.Fatal("unfinished fork submitted", err) } runVersionJob(t, s) refreshFork(t, s, f) if f.Status != "ready" { t.Fatal("fork not completed", forkVersionJob(t, s, f)) } s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "submitted edit") r, err := s.submitResourceVersion(ctx, f.ID, "review") if err != nil { t.Fatal(err) } if r.Status != "snapshotting" { t.Fatal("submission exposed before file copy") } if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) { t.Fatal("unfinished snapshot approved", err) } runVersionJob(t, s) s.db.First(r, "id=?", r.ID) if r.Status != "pending" { t.Fatal("snapshot not pending", r.Status) } if _, err := knowledgeVersionSnapshot(ctx, s.db, r.SnapshotID); !errors.Is(err, errConflict) { t.Fatal("snapshot leaked to normal context", err) } s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "later personal edit") 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) } s.db.First(r, "id=?", r.ID) if r.PublishedID == "" || r.PublishedID == id { t.Fatal("missing distinct published revision") } var original, published types.KnowledgeBase s.db.First(&original, "id=?", id) s.db.First(&published, "id=?", r.PublishedID) if !original.IsTemporary || published.IsTemporary || published.CreatorID != "owner" { t.Fatal("catalog promotion incorrect") } var chunk types.Chunk s.db.Where("knowledge_base_id=?", r.PublishedID).First(&chunk) if chunk.Content != "submitted edit" { t.Fatal("approval used mutable personal version", chunk.Content) } var oldDoc, newDoc types.Knowledge s.db.Where("knowledge_base_id=?", id).First(&oldDoc) s.db.Where("knowledge_base_id=?", r.PublishedID).First(&newDoc) if oldDoc.FilePath == newDoc.FilePath { t.Fatal("shared raw file") } if err = os.Remove(oldDoc.FilePath); err != nil { t.Fatal(err) } if b, err := os.ReadFile(newDoc.FilePath); err != nil || string(b) != "independent content" { t.Fatal("source deletion damaged published file", err) } latest, err := resolveKnowledgeVersion(s.db, 1, id) if err != nil || latest != r.PublishedID { t.Fatal("alias not advanced", latest, err) } again, err := s.forkResource(ctx, "knowledge", id) if err != nil || again.ID != f.ID { t.Fatal("old catalog link created duplicate fork", err) } if _, err = sourceView(s.db, 1, "knowledge", id); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("old revision remained in new selections", err) } } func TestKnowledgeVersionDurableRetryAndIsolation(t *testing.T) { s, fake, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } oldTarget := f.PersonalID fake.fail = true runVersionJob(t, s) refreshFork(t, s, f) j := forkVersionJob(t, s, f) if j.Status != "failed" || f.Status != "failed" { t.Fatal("failure was not persisted") } if err = s.retryKnowledgeVersionJob(versionContext(2, "alice", true), j.ID); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("cross tenant retry", err) } if err = s.retryKnowledgeVersionJob(versionContext(1, "bob", false), j.ID); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("cross user retry", err) } denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"knowledge.read": true}}) if err = s.retryKnowledgeVersionJob(denied, j.ID); !errors.Is(err, errVersionDenied) { t.Fatal("module denial bypass", err) } s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("content", "source changed before retry") if err = s.retryKnowledgeVersionJob(ctx, j.ID); err != nil { t.Fatal(err) } fake.fail = false // A fresh service instance simulates process restart; no in-memory queue is needed. restarted := NewService(s.db) restarted.SetKnowledgeVersionAdapter(fake) runVersionJob(t, restarted) refreshFork(t, s, f) j = forkVersionJob(t, s, f) if f.Status != "ready" || f.PersonalID == oldTarget || j.Attempts != 2 || len(j.AbandonedIDs) != 1 { t.Fatal("retry reused partial target", j) } var old types.KnowledgeBase s.db.First(&old, "id=?", oldTarget) if old.VersionState != "copying" || !old.IsTemporary { t.Fatal("partial version became visible") } } func TestKnowledgeVersionDetectsChangesDuringSnapshot(t *testing.T) { s, fake, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } runVersionJob(t, s) refreshFork(t, s, f) r, err := s.submitResourceVersion(ctx, f.ID, "") if err != nil { t.Fatal(err) } fake.afterCopy = func() { s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "edited during snapshot") } runVersionJob(t, s) s.db.First(r, "id=?", r.ID) if r.Status != "snapshot_failed" { t.Fatal("torn snapshot exposed", r.Status) } var j KnowledgeVersionJob s.db.Where("submission_id=?", r.ID).First(&j) if err = s.retryKnowledgeVersionJob(ctx, j.ID); !errors.Is(err, errConflict) { t.Fatal("changed immutable proposal retried", err) } if _, err = s.submitResourceVersion(ctx, f.ID, "new submission"); err != nil { t.Fatal("failed snapshot prevented new submission", err) } } func TestKnowledgeVersionStaleLeaseCannotExposeTarget(t *testing.T) { s, fake, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } fake.afterCopy = func() { s.db.Model(&KnowledgeVersionJob{}).Where("fork_id=?", f.ID).Updates(map[string]any{"lease_token": "replacement", "lease_until": time.Now().Add(-time.Minute)}) } if err = s.RunKnowledgeVersionJob(context.Background()); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("stale worker finalized", err) } refreshFork(t, s, f) if f.Status != "copying" { t.Fatal("stale copy exposed") } oldTarget := f.PersonalID fake.afterCopy = nil runVersionJob(t, s) refreshFork(t, s, f) if f.Status != "ready" || f.PersonalID == oldTarget { t.Fatal("expired lease did not allocate independent target") } } func TestKnowledgeVersionFingerprintCoversCategoriesAndSourceText(t *testing.T) { s, _, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) _, a, err := s.resourceSnapshot(s.db, ctx, "knowledge", id) if err != nil { t.Fatal(err) } s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("source_content", "changed raw parser output") _, b, _ := s.resourceSnapshot(s.db, ctx, "knowledge", id) if a == b { t.Fatal("raw source change ignored") } if err = s.db.Create(&types.KnowledgeTag{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: id, Name: "分类"}).Error; err != nil { t.Fatal(err) } _, c, _ := s.resourceSnapshot(s.db, ctx, "knowledge", id) if b == c { t.Fatal("category change ignored") } } func TestKnowledgeVersionMissingSourceDoesNotBlockQueue(t *testing.T) { s, fake, id := knowledgeVersionFixture(t) ctx := versionContext(1, "alice", false) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } fake.fail = true runVersionJob(t, s) j := forkVersionJob(t, s, f) if err = s.retryKnowledgeVersionJob(ctx, j.ID); err != nil { t.Fatal(err) } if err = s.db.Delete(&types.KnowledgeBase{}, "id=?", id).Error; err != nil { t.Fatal(err) } runVersionJob(t, s) j = forkVersionJob(t, s, f) if j.Status != "failed" || j.LeaseToken != "" { t.Fatal("missing source left a blocking queue entry", j) } if err = s.RunKnowledgeVersionJob(context.Background()); !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatal("failed source blocked queue", err) } var log types.AuditLog if err = s.db.Where("target_id=? AND outcome=?", j.ID, types.AuditOutcomeFailed).First(&log).Error; err != nil { t.Fatal("failure audit missing", err) } } func TestKnowledgeVersionRejectsUnsupportedBeforeReservation(t *testing.T) { s, _, id := knowledgeVersionFixture(t) if err := s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("chunk_type", types.ChunkTypeTableSummary).Error; err != nil { t.Fatal(err) } if _, err := s.forkResource(versionContext(1, "alice", false), "knowledge", id); !errors.Is(err, errInvalid) { t.Fatal("unsupported copy reserved", err) } var count int64 if err := s.db.Model(&KnowledgeVersionJob{}).Count(&count).Error; err != nil || count != 0 { t.Fatal("unsupported request created a job", count, err) } } func TestKnowledgeVersionApprovalConflictAndAliasChain(t *testing.T) { s, _, id := knowledgeVersionFixture(t) ctx, admin := versionContext(1, "alice", false), versionContext(1, "owner", true) f, err := s.forkResource(ctx, "knowledge", id) if err != nil { t.Fatal(err) } runVersionJob(t, s) refreshFork(t, s, f) r, err := s.submitResourceVersion(ctx, f.ID, "") if err != nil { t.Fatal(err) } runVersionJob(t, s) if err = s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("content", "global edit").Error; err != nil { t.Fatal(err) } if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) { t.Fatal("changed global source overwritten", err) } for _, action := range []struct { id string handler gin.HandlerFunc }{{r.ID, s.WithdrawResourceVersion}, {f.ID, s.RebaseResourceVersion}} { response := httptest.NewRecorder() request, _ := gin.CreateTestContext(response) request.Request = httptest.NewRequest("POST", "/", nil).WithContext(ctx) request.Params = gin.Params{{Key: "id", Value: action.id}} action.handler(request) if response.Code != 200 { t.Fatal("version action failed", response.Code, response.Body.String()) } } if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "bob", Kind: "knowledge", ResourceID: id}).Error; err != nil { t.Fatal(err) } previous := id for i := 0; i < 2; i++ { r, err = s.submitResourceVersion(ctx, f.ID, fmt.Sprintf("revision %d", i)) if err != nil { t.Fatal(err) } runVersionJob(t, s) if err = s.reviewResourceVersion(admin, r.ID, true, ""); err != nil { t.Fatal(err) } if err = s.db.First(r, "id=?", r.ID).Error; err != nil { t.Fatal(err) } for _, old := range []string{id, previous} { latest, err := resolveKnowledgeVersion(s.db, 1, old) if err != nil || latest != r.PublishedID { t.Fatal("alias chain not advanced", old, latest, err) } } var hidden ResourceHidden if err = s.db.Where("tenant_id=1 AND owner_id='bob' AND resource_id=?", r.PublishedID).First(&hidden).Error; err != nil { t.Fatal("personal hiding lost on approval", err) } previous = r.PublishedID } }