WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user