Files
zeekay 157b7127ab security: fix critical and high findings from red team review
- C1: add orgId field to _tasks/_workflows, filter all queries by org
- C2: validate spaceID/orgID with regex before Temporal visibility queries
- H1: add sync.RWMutex to orgClients map (prevent concurrent map panic)
- H2: atomic FailTask with CASE expression (eliminate TOCTOU race)
- H4: extractCreator uses authenticated user ID, not raw headers
- Add orgId to collection schemas, store methods, and API filters
2026-03-02 01:02:45 -07:00

493 lines
12 KiB
Go

package tasks
import (
"os"
"testing"
"github.com/hanzoai/base/core"
_ "github.com/hanzoai/base/migrations"
)
// newTestApp creates a minimal base app for testing (no import of tests package to avoid cycles).
func newTestApp(t *testing.T) core.App {
t.Helper()
dir, err := os.MkdirTemp("", "tasks_test_*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: dir,
EncryptionEnv: "test_enc_key",
})
if err := Register(app, Config{}); err != nil {
t.Fatal(err)
}
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
return app
}
func TestCreateAndGetTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
if s == nil {
t.Fatal("store not registered")
}
task := &Task{
SpaceID: "space-1",
Title: "Build the thing",
}
if err := s.CreateTask(task); err != nil {
t.Fatal(err)
}
if task.ID == "" {
t.Fatal("expected auto-generated ID")
}
if task.State != TaskPending {
t.Fatalf("expected pending, got %s", task.State)
}
got, err := s.GetTask(task.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "Build the thing" {
t.Fatalf("expected 'Build the thing', got %q", got.Title)
}
if got.SpaceID != "space-1" {
t.Fatalf("expected space-1, got %q", got.SpaceID)
}
}
func TestClaimTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Claimable"}
if err := s.CreateTask(task); err != nil {
t.Fatal(err)
}
if err := s.ClaimTask(task.ID, "agent-1"); err != nil {
t.Fatal(err)
}
// Second claim should fail.
if err := s.ClaimTask(task.ID, "agent-2"); err != ErrAlreadyClaimed {
t.Fatalf("expected ErrAlreadyClaimed, got %v", err)
}
got, _ := s.GetTask(task.ID)
if got.State != TaskClaimed {
t.Fatalf("expected claimed, got %s", got.State)
}
if got.AssignedTo != "agent-1" {
t.Fatalf("expected agent-1, got %s", got.AssignedTo)
}
}
func TestCompleteTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Completable"}
_ = s.CreateTask(task)
_ = s.ClaimTask(task.ID, "agent-1")
_ = s.StartTask(task.ID)
output := map[string]any{"result": "success"}
if err := s.CompleteTask(task.ID, output); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID)
if got.State != TaskCompleted {
t.Fatalf("expected completed, got %s", got.State)
}
if got.Progress != 100 {
t.Fatalf("expected progress 100, got %d", got.Progress)
}
if got.CompletedAt == nil {
t.Fatal("expected completedAt to be set")
}
}
func TestFailTaskWithRetry(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Retriable", MaxRetries: 2}
_ = s.CreateTask(task)
_ = s.ClaimTask(task.ID, "agent-1")
_ = s.StartTask(task.ID)
// First failure should re-queue.
if err := s.FailTask(task.ID, "oops"); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID)
if got.State != TaskPending {
t.Fatalf("expected re-queued to pending, got %s", got.State)
}
if got.RetryCount != 1 {
t.Fatalf("expected retryCount 1, got %d", got.RetryCount)
}
// Second cycle.
_ = s.ClaimTask(task.ID, "agent-2")
_ = s.StartTask(task.ID)
_ = s.FailTask(task.ID, "oops again")
got, _ = s.GetTask(task.ID)
if got.State != TaskPending {
t.Fatalf("expected re-queued, got %s", got.State)
}
if got.RetryCount != 2 {
t.Fatalf("expected retryCount 2, got %d", got.RetryCount)
}
// Third failure should be terminal.
_ = s.ClaimTask(task.ID, "agent-3")
_ = s.StartTask(task.ID)
_ = s.FailTask(task.ID, "final fail")
got, _ = s.GetTask(task.ID)
if got.State != TaskFailed {
t.Fatalf("expected terminal failed, got %s", got.State)
}
}
func TestCancelTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
task := &Task{SpaceID: "s1", Title: "Cancellable"}
_ = s.CreateTask(task)
if err := s.CancelTask(task.ID); err != nil {
t.Fatal(err)
}
got, _ := s.GetTask(task.ID)
if got.State != TaskCancelled {
t.Fatalf("expected cancelled, got %s", got.State)
}
// Can't cancel again.
if err := s.CancelTask(task.ID); err != ErrInvalidTransition {
t.Fatalf("expected ErrInvalidTransition, got %v", err)
}
}
func TestGetNextPendingTask(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
low := &Task{SpaceID: "s1", Title: "Low", Priority: PriorityLow}
high := &Task{SpaceID: "s1", Title: "High", Priority: PriorityHigh}
_ = s.CreateTask(low)
_ = s.CreateTask(high)
// Should get high-priority first.
next, err := s.GetNextPendingTask("s1", "agent-1")
if err != nil {
t.Fatal(err)
}
if next == nil {
t.Fatal("expected a task")
}
if next.Title != "High" {
t.Fatalf("expected High, got %q", next.Title)
}
if next.State != TaskClaimed {
t.Fatalf("expected claimed, got %s", next.State)
}
// Next should get low.
next, _ = s.GetNextPendingTask("s1", "agent-2")
if next == nil || next.Title != "Low" {
t.Fatalf("expected Low, got %v", next)
}
// No more.
next, _ = s.GetNextPendingTask("s1", "agent-3")
if next != nil {
t.Fatalf("expected nil, got %+v", next)
}
}
func TestDependencyBlocking(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
a := &Task{SpaceID: "s1", Title: "Task A"}
_ = s.CreateTask(a)
b := &Task{SpaceID: "s1", Title: "Task B", DependsOn: []string{a.ID}}
_ = s.CreateTask(b)
// Next should return A (B blocked).
next, _ := s.GetNextPendingTask("s1", "agent-1")
if next == nil || next.Title != "Task A" {
t.Fatal("expected Task A")
}
// B still blocked (A claimed, not completed).
next, _ = s.GetNextPendingTask("s1", "agent-2")
if next != nil {
t.Fatalf("expected nil (B blocked), got %q", next.Title)
}
// Complete A.
_ = s.StartTask(a.ID)
_ = s.CompleteTask(a.ID, nil)
// Now B available.
next, _ = s.GetNextPendingTask("s1", "agent-2")
if next == nil || next.Title != "Task B" {
t.Fatal("expected Task B after A completed")
}
}
func TestListTasksFiltered(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T1", Priority: PriorityLow})
_ = s.CreateTask(&Task{SpaceID: "s1", Title: "T2", Priority: PriorityHigh})
_ = s.CreateTask(&Task{SpaceID: "s2", Title: "T3", Priority: PriorityNormal})
items, _ := s.ListTasks(TaskFilters{SpaceID: "s1"})
if len(items) != 2 {
t.Fatalf("expected 2, got %d", len(items))
}
if items[0].Title != "T2" {
t.Fatalf("expected T2 first (higher priority), got %q", items[0].Title)
}
pending := TaskPending
items, _ = s.ListTasks(TaskFilters{SpaceID: "s2", State: &pending})
if len(items) != 1 {
t.Fatalf("expected 1 pending in s2, got %d", len(items))
}
}
func TestWorkflowLifecycle(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
wf := &Workflow{SpaceID: "s1", Name: "Pipeline"}
if err := s.CreateWorkflow(wf); err != nil {
t.Fatal(err)
}
if wf.ID == "" {
t.Fatal("expected auto-generated ID")
}
got, err := s.GetWorkflow(wf.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "Pipeline" {
t.Fatalf("expected Pipeline, got %q", got.Name)
}
if got.State != TaskPending {
t.Fatalf("expected pending, got %s", got.State)
}
items, _ := s.ListWorkflows("s1")
if len(items) != 1 {
t.Fatalf("expected 1, got %d", len(items))
}
}
func TestWorkflowAdvancement(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
// Create workflow with 2 tasks.
t1 := &Task{SpaceID: "s1", Title: "Step 1"}
_ = s.CreateTask(t1)
t2 := &Task{SpaceID: "s1", Title: "Step 2", DependsOn: []string{t1.ID}}
_ = s.CreateTask(t2)
wf := &Workflow{SpaceID: "s1", Name: "Pipeline", Tasks: []string{t1.ID, t2.ID}}
_ = s.CreateWorkflow(wf)
_ = s.UpdateWorkflowTasks(wf)
// Advance: should go from pending → running.
_ = s.AdvanceWorkflows()
got, _ := s.GetWorkflow(wf.ID)
if got.State != TaskRunning {
t.Fatalf("expected running, got %s", got.State)
}
// Complete both tasks.
_ = s.ClaimTask(t1.ID, "a")
_ = s.StartTask(t1.ID)
_ = s.CompleteTask(t1.ID, nil)
_ = s.ClaimTask(t2.ID, "a")
_ = s.StartTask(t2.ID)
_ = s.CompleteTask(t2.ID, nil)
// Advance: should complete workflow.
_ = s.AdvanceWorkflows()
got, _ = s.GetWorkflow(wf.ID)
if got.State != TaskCompleted {
t.Fatalf("expected completed, got %s", got.State)
}
if got.CompletedAt == nil {
t.Fatal("expected completedAt set")
}
}
func TestOrgIsolation(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
// Create tasks in two different orgs.
taskA := &Task{OrgID: "org-alpha", SpaceID: "s1", Title: "Alpha task"}
taskB := &Task{OrgID: "org-beta", SpaceID: "s1", Title: "Beta task"}
if err := s.CreateTask(taskA); err != nil {
t.Fatal(err)
}
if err := s.CreateTask(taskB); err != nil {
t.Fatal(err)
}
// ListTasks scoped to org-alpha should only return alpha task.
items, err := s.ListTasks(TaskFilters{OrgID: "org-alpha"})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Alpha task" {
t.Fatalf("expected 1 alpha task, got %d", len(items))
}
// GetTask with wrong org should fail.
_, err = s.GetTask(taskA.ID, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org GetTask, got %v", err)
}
// GetTask with correct org should succeed.
got, err := s.GetTask(taskA.ID, "org-alpha")
if err != nil {
t.Fatal(err)
}
if got.OrgID != "org-alpha" {
t.Fatalf("expected org-alpha, got %q", got.OrgID)
}
// ClaimTask with wrong org should fail silently (0 rows affected).
err = s.ClaimTask(taskA.ID, "agent-1", "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org ClaimTask, got %v", err)
}
// ClaimTask with correct org should succeed.
if err := s.ClaimTask(taskA.ID, "agent-1", "org-alpha"); err != nil {
t.Fatalf("expected claim to succeed for correct org, got %v", err)
}
// StartTask with wrong org should fail.
err = s.StartTask(taskA.ID, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org StartTask, got %v", err)
}
// StartTask with correct org should succeed.
if err := s.StartTask(taskA.ID, "org-alpha"); err != nil {
t.Fatal(err)
}
// CompleteTask with wrong org should fail.
err = s.CompleteTask(taskA.ID, nil, "org-beta")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org CompleteTask, got %v", err)
}
// CompleteTask with correct org should succeed.
if err := s.CompleteTask(taskA.ID, nil, "org-alpha"); err != nil {
t.Fatal(err)
}
// CancelTask with wrong org should fail.
err = s.CancelTask(taskB.ID, "org-alpha")
if err != ErrTaskNotFound {
t.Fatalf("expected ErrTaskNotFound for cross-org CancelTask, got %v", err)
}
// CancelTask with correct org should succeed.
if err := s.CancelTask(taskB.ID, "org-beta"); err != nil {
t.Fatal(err)
}
// GetNextPendingTask scoped to org returns only that org's tasks.
taskC := &Task{OrgID: "org-gamma", SpaceID: "s1", Title: "Gamma task"}
taskD := &Task{OrgID: "org-delta", SpaceID: "s1", Title: "Delta task"}
_ = s.CreateTask(taskC)
_ = s.CreateTask(taskD)
next, err := s.GetNextPendingTask("s1", "agent-2", "org-gamma")
if err != nil {
t.Fatal(err)
}
if next == nil || next.OrgID != "org-gamma" {
t.Fatalf("expected org-gamma task, got %v", next)
}
}
func TestWorkflowOrgIsolation(t *testing.T) {
app := newTestApp(t)
s := GetStore(app)
wfA := &Workflow{OrgID: "org-alpha", SpaceID: "s1", Name: "Alpha WF"}
wfB := &Workflow{OrgID: "org-beta", SpaceID: "s1", Name: "Beta WF"}
if err := s.CreateWorkflow(wfA); err != nil {
t.Fatal(err)
}
if err := s.CreateWorkflow(wfB); err != nil {
t.Fatal(err)
}
// GetWorkflow with wrong org should fail.
_, err := s.GetWorkflow(wfA.ID, "org-beta")
if err != ErrWorkflowNotFound {
t.Fatalf("expected ErrWorkflowNotFound for cross-org GetWorkflow, got %v", err)
}
// GetWorkflow with correct org should succeed.
got, err := s.GetWorkflow(wfA.ID, "org-alpha")
if err != nil {
t.Fatal(err)
}
if got.OrgID != "org-alpha" {
t.Fatalf("expected org-alpha, got %q", got.OrgID)
}
// ListWorkflows scoped to org-alpha should only return alpha workflow.
items, err := s.ListWorkflows("s1", "org-alpha")
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Name != "Alpha WF" {
t.Fatalf("expected 1 alpha workflow, got %d", len(items))
}
// ListWorkflows without org should return both.
all, err := s.ListWorkflows("s1")
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("expected 2 workflows, got %d", len(all))
}
}