Files
xinmi/internal/workbench/content_permissions_test.go
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

261 lines
10 KiB
Go

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)
}
}
}