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,155 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAdministratorDeletionRemainsTenantScoped(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
kind string
|
||||
tenant uint64
|
||||
owner string
|
||||
role types.TenantRole
|
||||
status int
|
||||
}{
|
||||
{"admin canvas", "flows", 1, "old-owner", types.TenantRoleAdmin, http.StatusOK},
|
||||
{"own canvas", "flows", 1, "actor", types.TenantRoleContributor, http.StatusOK},
|
||||
{"contributor other canvas", "flows", 1, "other", types.TenantRoleContributor, http.StatusNotFound},
|
||||
{"admin other tenant", "flows", 2, "old-owner", types.TenantRoleAdmin, http.StatusNotFound},
|
||||
{"admin other map", "training-maps", 1, "other", types.TenantRoleAdmin, http.StatusOK},
|
||||
{"admin other task", "content-tasks", 1, "other", types.TenantRoleAdmin, http.StatusOK},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := sqliteWorkbench(t)
|
||||
r := seedResource(t, s, test.tenant, test.owner, test.kind)
|
||||
ctx := workbenchContext(1, "actor", test.role)
|
||||
if test.owner != "actor" && (test.tenant != 1 || test.role != types.TenantRoleAdmin) {
|
||||
if _, err := lockResource(ctx, s.db, r.Kind, r.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("deletion permission granted an edit lock: %v", err)
|
||||
}
|
||||
if err := persistResource(ctx, s.db, r, r.Version); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("deletion permission granted save access: %v", err)
|
||||
}
|
||||
}
|
||||
w := deleteWorkbenchResource(s, ctx, r, true)
|
||||
if w.Code != test.status {
|
||||
t.Fatalf("delete returned %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Model(&Resource{}).Where("id = ?", r.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (count == 0) != (test.status == http.StatusOK) {
|
||||
t.Fatalf("unexpected resource count: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorCanvasDeletionDetachesWithRealActorAndPreservesOutput(t *testing.T) {
|
||||
s := sqliteWorkbench(t)
|
||||
ctx := workbenchContext(1, "admin", types.TenantRoleAdmin)
|
||||
flow := seedResource(t, s, 1, "deleted-owner", "flows")
|
||||
task := seedResource(t, s, 1, "different-owner", "content-tasks")
|
||||
task.Data = asJSON(TaskData{FlowID: flow.ID, MapID: "existing-map", Segments: []Segment{{ID: "s1", Status: "accepted", Content: "accepted prose"}}, Review: "review", Composed: "final prose"})
|
||||
task.Data["legacy_history"] = JSON{"preserved": true}
|
||||
if err := s.db.Save(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := compact(task.Data)
|
||||
foreign := seedResource(t, s, 2, flow.OwnerID, "content-tasks")
|
||||
foreign.Data = asJSON(TaskData{FlowID: flow.ID, Composed: "foreign prose"})
|
||||
if err := s.db.Save(foreign).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := deleteWorkbenchResource(s, ctx, flow, false); w.Code != http.StatusConflict {
|
||||
t.Fatalf("detachment must be explicit: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := deleteWorkbenchResource(s, ctx, flow, true); w.Code != http.StatusOK {
|
||||
t.Fatalf("admin detach failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
stored, err := s.get(ctx, task.Kind, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task.Data["flow_id"] = ""
|
||||
if stored.OwnerID != task.OwnerID || stored.Version != 2 || stored.Status != "needs_configuration" || compact(stored.Data) != compact(task.Data) {
|
||||
t.Fatalf("detachment changed task ownership or historical content: %+v", stored)
|
||||
}
|
||||
var revisions []Revision
|
||||
if err := s.db.Where("resource_id = ?", task.ID).Order("version ASC").Find(&revisions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) != 2 || revisions[0].ActorID != "admin" || revisions[1].ActorID != "admin" || compact(revisions[0].Data) != before || compact(revisions[1].Data) != compact(stored.Data) {
|
||||
t.Fatalf("detachment lost before/after history or audited the wrong actor: %+v", revisions)
|
||||
}
|
||||
var event Event
|
||||
if err := s.db.Where("resource_id = ? AND action = ?", flow.ID, "delete").First(&event).Error; err != nil || event.OwnerID != "admin" || event.TenantID != 1 {
|
||||
t.Fatalf("deletion audit missing or wrong actor: %+v, %v", event, err)
|
||||
}
|
||||
var foreignStored Resource
|
||||
if err := s.db.First(&foreignStored, "id = ?", foreign.ID).Error; err != nil || foreignStored.Version != 1 || compact(foreignStored.Data) != compact(foreign.Data) {
|
||||
t.Fatalf("cross-tenant task changed: %+v, %v", foreignStored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorCanvasDeletionBlocksActiveTasks(t *testing.T) {
|
||||
for _, state := range []string{"queued", "running"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
s := sqliteWorkbench(t)
|
||||
flow := seedResource(t, s, 1, "old-owner", "flows")
|
||||
owner := "active-user"
|
||||
task := seedResource(t, s, 1, owner, "content-tasks")
|
||||
task.Data = asJSON(TaskData{FlowID: flow.ID, Composed: "preserved"})
|
||||
if err := s.db.Save(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.db.Create(&Job{ID: uuid.NewString(), TenantID: 1, OwnerID: owner, ResourceID: task.ID, Kind: task.Kind, Status: state}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := deleteWorkbenchResource(s, workbenchContext(1, "admin", types.TenantRoleAdmin), flow, true)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("unsafe delete was not blocked specifically: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var stored Resource
|
||||
if err := s.db.First(&stored, "id = ?", task.ID).Error; err != nil || stored.Version != 1 || compact(stored.Data) != compact(task.Data) {
|
||||
t.Fatalf("blocked deletion changed task: %+v, %v", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorCanvasDeletionRollsBackIfAuditFails(t *testing.T) {
|
||||
s := sqliteWorkbench(t)
|
||||
flow := seedResource(t, s, 1, "old-owner", "flows")
|
||||
task := seedResource(t, s, 1, flow.OwnerID, "content-tasks")
|
||||
task.Data = asJSON(TaskData{FlowID: flow.ID, Composed: "preserved"})
|
||||
if err := s.db.Save(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.db.Exec("CREATE TRIGGER reject_delete_audit BEFORE INSERT ON workbench_events BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END;").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := deleteWorkbenchResource(s, workbenchContext(1, "admin", types.TenantRoleAdmin), flow, true)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("audit failure did not abort deletion: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var stored Resource
|
||||
if err := s.db.First(&stored, "id = ?", task.ID).Error; err != nil || stored.Version != 1 || compact(stored.Data) != compact(task.Data) {
|
||||
t.Fatalf("audit failure left a detached task: %+v, %v", stored, err)
|
||||
}
|
||||
if err := s.db.First(&Resource{}, "id = ?", flow.ID).Error; err != nil {
|
||||
t.Fatalf("audit failure deleted the canvas: %v", err)
|
||||
}
|
||||
var revisions int64
|
||||
if err := s.db.Model(&Revision{}).Count(&revisions).Error; err != nil || revisions != 0 {
|
||||
t.Fatalf("audit failure left partial history: %d, %v", revisions, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user