package workbench import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/types" "github.com/gin-gonic/gin" "github.com/google/uuid" ) func contentContext(permission string) context.Context { return accesspolicy.WithSnapshot(workbenchContext(1, "alice", types.TenantRoleContributor), &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"content.read": true, permission: true}}) } func TestContentSubmissionRequiresActionPermission(t *testing.T) { s := sqliteWorkbench(t) for _, action := range []string{"generate", "revise", "decompose", "import-image"} { if _, err := s.submit(contentContext("content.review"), "content-tasks", "missing", action, ActionRequest{}); !errors.Is(err, ErrForbidden) { t.Fatalf("reviewer submitted %s: %v", action, err) } } for _, action := range []string{"accept", "skip", "review", "review-settings", "compose"} { if _, err := s.submit(contentContext("content.write"), "content-tasks", "missing", action, ActionRequest{}); !errors.Is(err, ErrForbidden) { t.Fatalf("producer without review submitted %s: %v", action, err) } if _, err := s.submit(contentContext("content.review"), "content-tasks", "missing", action, ActionRequest{}); errors.Is(err, ErrForbidden) { t.Fatalf("reviewer was denied %s before resource lookup", action) } } } func TestReviewSubmissionDoesNotRequireGenerationDependencies(t *testing.T) { s := sqliteWorkbench(t) for _, action := range []string{"accept", "skip", "review"} { r := seedResource(t, s, 1, "alice", "content-tasks") if err := s.db.Model(r).Update("data", asJSON(TaskData{Segments: []Segment{{ID: "one", Status: "generated", Content: "Existing prose"}}})).Error; err != nil { t.Fatal(err) } if _, err := s.submit(contentContext("content.review"), r.Kind, r.ID, action, ActionRequest{Version: r.Version, SegmentID: "one"}); err != nil { t.Fatalf("reviewer cannot submit %s: %v", action, err) } } } func TestQualityReviewUsesRecordedModelWithoutPrivateAgentDependencies(t *testing.T) { s, ctx, job, model := generationHarness(t) ctx = accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"content.read": true, "content.review": true}}) s.agents = nil job.Action = "review" model.output = `{"passed":true,"summary":"逻辑清晰","issues":[]}` r := &Resource{Data: asJSON(TaskData{AgentID: "author", AgentSnapshots: map[string]AgentSnapshot{"author": {ModelID: "model", SystemPrompt: "PRIVATE_PERSONA", Instructions: "PRIVATE_SKILLS"}}, Segments: []Segment{{ID: "one", Status: "accepted", Content: "Approved prose"}}})} if err := s.executeTask(ctx, job, r, ActionRequest{}); err != nil { t.Fatal(err) } if len(model.messages) != 2 || strings.Contains(model.messages[0].Content, "PRIVATE_") || !strings.Contains(model.messages[1].Content, "Approved prose") { t.Fatal("review must receive only the built-in review rules and approved prose") } denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"content.read": true}}) if _, err := s.prepareQualityCompletion(denied, "author"); !errors.Is(err, ErrForbidden) { t.Fatal("review revocation was ignored") } } func TestContentReviewSaveDoesNotRequireGenerationReferences(t *testing.T) { for _, tc := range []struct { action string configuration bool allowed bool }{ {action: "review", allowed: true}, {action: "accept", allowed: true}, {action: "skip", allowed: true}, {action: "generate"}, {action: "revise"}, {action: "compose", allowed: true}, {action: "review-settings", allowed: true}, {action: "decompose"}, {action: "import-image"}, {action: "review", configuration: true}, } { name := tc.action if tc.configuration { name += "_configuration" } t.Run(name, func(t *testing.T) { s := sqliteWorkbench(t) r := seedResource(t, s, 1, "alice", "content-tasks") r.Data = asJSON(TaskData{FlowID: "missing-flow", MapID: "missing-map", Segments: []Segment{{ID: "one", Status: "accepted", Content: "Existing prose"}}}) if err := s.db.Save(r).Error; err != nil { t.Fatal(err) } until := time.Now().Add(time.Minute) job := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Action: tc.action, Status: "running", LeaseToken: "lease", LeaseUntil: &until} if err := s.db.Create(&job).Error; err != nil { t.Fatal(err) } ctx := context.WithValue(contentContext("content.review"), leaseContextKey{}, jobLease{job.ID, job.LeaseToken}) if tc.configuration { ctx = context.WithValue(ctx, configurationSaveKey{}, true) } r.Data["review"] = "Saved review" err := s.save(ctx, r, 1) if tc.allowed && err != nil { t.Fatalf("review result could not save without source references: %v", err) } if !tc.allowed && err == nil { t.Fatal("configuration or generation saved without source references") } var stored Resource if err := s.db.First(&stored, "id = ?", r.ID).Error; err != nil { t.Fatal(err) } if tc.allowed && (stored.Version != 2 || stored.Data["review"] != "Saved review") { t.Fatal("review result was not persisted") } if !tc.allowed && (stored.Version != 1 || stored.Data["review"] == "Saved review") { t.Fatal("failed reference validation changed persisted content") } }) } } func TestContentReviewSaveRequiresCurrentLeaseAndVersion(t *testing.T) { for _, state := range []string{"expired", "token", "status", "resource", "owner", "tenant", "version"} { t.Run(state, func(t *testing.T) { s := sqliteWorkbench(t) r := seedResource(t, s, 1, "alice", "content-tasks") until := time.Now().Add(time.Minute) job := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Action: "review", Status: "running", LeaseToken: "lease", LeaseUntil: &until} expected := 1 switch state { case "expired": until = time.Now().Add(-time.Second) case "token": job.LeaseToken = "other" case "status": job.Status = "cancelled" case "resource": job.ResourceID = uuid.NewString() case "owner": job.OwnerID = "bob" case "tenant": job.TenantID = 2 case "version": expected = 2 } if err := s.db.Create(&job).Error; err != nil { t.Fatal(err) } ctx := context.WithValue(contentContext("content.review"), leaseContextKey{}, jobLease{job.ID, "lease"}) r.Data["review"] = "Rejected review" if err := s.save(ctx, r, expected); !errors.Is(err, ErrConflict) { t.Fatalf("invalid review save did not preserve conflict guard: %v", err) } var stored Resource if err := s.db.First(&stored, "id = ?", r.ID).Error; err != nil { t.Fatal(err) } var revisions int64 if err := s.db.Model(&Revision{}).Count(&revisions).Error; err != nil { t.Fatal(err) } if stored.Version != 1 || stored.Data["review"] != nil || revisions != 0 { t.Fatal("rejected review changed persisted content or history") } }) } } func TestContentJobControlUsesStoredAction(t *testing.T) { s := sqliteWorkbench(t) for _, action := range []string{"accept", "generate"} { r := seedResource(t, s, 1, "alice", "content-tasks") j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Action: action, Status: "queued", Request: asJSON(ActionRequest{Version: r.Version})} if err := s.db.Create(&j).Error; err != nil { t.Fatal(err) } _, err := s.controlJob(contentContext("content.review"), j.ID, "pause") if action == "generate" && !errors.Is(err, ErrForbidden) { t.Fatalf("reviewer controlled generation: %v", err) } if action == "accept" { if err != nil { t.Fatal(err) } if _, err := s.controlJob(contentContext("content.write"), j.ID, "resume"); !errors.Is(err, ErrForbidden) { t.Fatalf("review revocation did not block resume: %v", err) } if _, err := s.controlJob(contentContext("content.review"), j.ID, "resume"); err != nil { t.Fatalf("reviewer could not resume review: %v", err) } } } } func TestContentExportPermissionAndOwnerScope(t *testing.T) { s := sqliteWorkbench(t) r := seedResource(t, s, 1, "alice", "content-tasks") if err := s.db.Model(r).Update("data", asJSON(TaskData{Composed: "final manuscript"})).Error; err != nil { t.Fatal(err) } for _, tc := range []struct { ctx context.Context status int }{ {contentContext("content.read"), http.StatusForbidden}, {contentContext("content.review"), http.StatusForbidden}, {contentContext("content.deliver"), http.StatusOK}, {accesspolicy.WithSnapshot(workbenchContext(1, "bob", types.TenantRoleContributor), &accesspolicy.Snapshot{}), http.StatusNotFound}, {accesspolicy.WithSnapshot(workbenchContext(2, "alice", types.TenantRoleContributor), &accesspolicy.Snapshot{}), http.StatusNotFound}, } { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "kind", Value: r.Kind}, {Key: "id", Value: r.ID}} c.Request = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(tc.ctx) NewHandler(s).Export(c) if w.Code != tc.status { t.Fatalf("export got %d want %d: %s", w.Code, tc.status, w.Body.String()) } } } func TestWorkerReloadsActionPermission(t *testing.T) { s := sqliteWorkbench(t) if err := s.db.AutoMigrate(&types.TenantMember{}, &types.Tenant{}); err != nil { t.Fatal(err) } if err := s.db.Create(&types.TenantMember{TenantID: 1, UserID: "alice", Role: types.TenantRoleContributor, Status: types.TenantMemberStatusActive}).Error; err != nil { t.Fatal(err) } if err := s.db.Create(&types.Tenant{ID: 1, Name: "test", Status: "active"}).Error; err != nil { t.Fatal(err) } s.SetAccessSnapshot(func(context.Context) (*accesspolicy.Snapshot, error) { return &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"content.read": true, "content.review": true}}, nil }) for _, action := range []string{"generate", "accept"} { until := time.Now().Add(time.Minute) j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: "missing", Kind: "content-tasks", Action: action, Status: "running", LeaseToken: "lease", LeaseUntil: &until, Request: asJSON(ActionRequest{Version: 1})} if err := s.db.Create(&j).Error; err != nil { t.Fatal(err) } s.run(context.Background(), &j) var stored Job if err := s.db.First(&stored, "id=?", j.ID).Error; err != nil { t.Fatal(err) } if stored.Status != "failed" || (stored.Error == ErrForbidden.Error()) != (action == "generate") { t.Fatalf("worker action %s authorization: %+v", action, stored) } } }