Files
multica/server/internal/handler/squad_private_leader_test.go
LinYushen 2348301d2b fix: gate private squad leader bypass (MUL-2860) (#3648)
* fix: gate private squad leader from being triggered by unauthorized members

Add canEnqueueSquadLeader helper that checks canAccessPrivateAgent before
allowing a squad leader to be enqueued. Gate all EnqueueTaskForSquadLeader
call sites:

1. enqueueSquadLeaderTask (comment trigger, assign trigger, backlog→todo)
2. triggerChildDoneSquad (child-done → parent squad leader)
3. autopilot.go (defensive comment; actor is always agent → always passes)

Also fix validateAssigneePair's squad branch to run canAccessPrivateAgent
on the squad leader, returning 403 'cannot assign to squad with private
leader' when the actor lacks access.

Thread actorType/actorID through notifyParentOfChildDone →
dispatchParentAssigneeTrigger → triggerChildDoneSquad so the child-done
path can enforce the private-leader gate.

Regression tests:
- Plain member blocked from create-issue to private-leader squad (403)
- Plain member blocked from update-issue to private-leader squad (403)
- Owner allowed to assign private-leader squad
- Plain member comment on squad-assigned issue doesn't trigger private leader
- Child-done by plain member doesn't trigger parent's private leader
- Agent actor can still trigger private leader via comment

Closes MUL-2860

Co-authored-by: multica-agent <github@multica.ai>

* fix: add private-leader gate to autopilot save + dispatch paths

- validateAutopilotAssignee squad branch: call canAccessPrivateAgent on
  the leader, returning 403 for unauthorized members at save time.
- service/autopilot.go: add canCreatorAccessPrivateLeader helper that
  mirrors the handler-level canAccessPrivateAgent logic (agent creators
  pass; member creators must be owner/admin or agent owner).
- Gate both dispatch paths (dispatchCreateIssue and dispatchRunOnly)
  with fail-closed check: if leader is private and creator lacks access,
  the run is skipped instead of triggering the private leader.

Regression tests:
- Plain member create autopilot to private-leader squad → 403
- Plain member update autopilot to private-leader squad → 403
- Owner create autopilot to private-leader squad → 201
- Owner-created autopilot dispatch → issue_created (positive)
- Legacy plain-member-created autopilot dispatch → skipped (fail-closed)

Co-authored-by: multica-agent <github@multica.ai>

* test: add run_only legacy private-leader squad dispatch regression test

Covers the dispatchRunOnly path explicitly, complementing the existing
create_issue dispatch test. Both dispatch branches now have direct test
coverage for the private-leader fail-closed gate.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 15:47:57 +08:00

368 lines
13 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
// plain member cannot create an issue assigned to a squad whose leader is
// a private agent.
func TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader Create Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
w := httptest.NewRecorder()
r := newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Should be blocked",
"assignee_type": "squad",
"assignee_id": squadID,
})
testHandler.CreateIssue(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
// plain member cannot update an issue's assignee to a private-leader squad.
func TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader Update Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create an unassigned issue as workspace owner.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
VALUES ($1, 'member', $2, 'update target')
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
w := httptest.NewRecorder()
r := newRequestAs(memberID, "PATCH", "/api/issues/"+issueID, map[string]any{
"assignee_type": "squad",
"assignee_id": squadID,
})
r = withURLParam(r, "id", issueID)
testHandler.UpdateIssue(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateIssue_SquadPrivateLeader_OwnerAllowed verifies that a workspace
// owner CAN assign an issue to a squad with a private leader.
func TestCreateIssue_SquadPrivateLeader_OwnerAllowed(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, _ := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader Owner Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// testUserID is workspace owner — should succeed.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Owner assigns private-leader squad",
"assignee_type": "squad",
"assignee_id": squadID,
})
testHandler.CreateIssue(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, created.ID)
})
}
// TestComment_SquadPrivateLeader_PlainMemberNoEnqueue verifies that a plain
// member posting a comment on an issue assigned to a private-leader squad
// does NOT trigger the leader.
func TestComment_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader Comment Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create issue assigned to the squad as workspace owner.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
VALUES ($1, 'member', $2, 'private leader comment test', 'squad', $3)
RETURNING id
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// Plain member posts a plain comment (not a @mention).
w := httptest.NewRecorder()
r := newRequestAs(memberID, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "any update on this?",
})
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
}
// The private leader must NOT have a queued task.
var count int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&count); err != nil {
t.Fatalf("count tasks: %v", err)
}
if count != 0 {
t.Fatalf("private leader got %d queued tasks from plain member comment; want 0", count)
}
}
// TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue verifies that when
// a plain member completes a child issue whose parent is assigned to a
// private-leader squad, the leader is NOT enqueued.
func TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader ChildDone Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create parent issue assigned to the squad (as workspace owner).
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "parent with private-leader squad",
"assignee_type": "squad",
"assignee_id": squadID,
})
testHandler.CreateIssue(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parent IssueResponse
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
t.Fatalf("decode parent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, parent.ID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE parent_issue_id = $1`, parent.ID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parent.ID)
})
// Clear any tasks enqueued by the create.
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
// Create a child issue via API (as workspace owner, with member assignee).
w = httptest.NewRecorder()
r = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "child task",
"parent_issue_id": parent.ID,
"assignee_type": "member",
"assignee_id": memberID,
"status": "in_progress",
})
testHandler.CreateIssue(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
}
var child IssueResponse
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
t.Fatalf("decode child: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, child.ID)
})
// Plain member moves child to done.
w = httptest.NewRecorder()
r = newRequestAs(memberID, "PATCH", "/api/issues/"+child.ID, map[string]any{
"status": "done",
})
r = withURLParam(r, "id", child.ID)
testHandler.UpdateIssue(w, r)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
}
// The private leader must NOT have a queued task on the parent.
var count int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
parent.ID, agentID,
).Scan(&count); err != nil {
t.Fatalf("count tasks: %v", err)
}
if count != 0 {
t.Fatalf("private leader got %d queued tasks from plain member child-done; want 0", count)
}
}
// TestComment_SquadPrivateLeader_AgentActorAllowed verifies that an agent
// actor CAN trigger the private leader via comment on a squad-assigned issue.
func TestComment_SquadPrivateLeader_AgentActorAllowed(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, _ := privateAgentTestFixture(t)
otherAgentID := createHandlerTestAgent(t, "squad-private-leader-agent-actor", nil)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'Private Leader Agent Actor Test', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create issue assigned to the squad.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
VALUES ($1, 'member', $2, 'private leader agent actor test', 'squad', $3)
RETURNING id
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create a task for the other agent so X-Agent-ID / X-Task-ID are valid.
var taskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2)
RETURNING id
`, otherAgentID, issueID).Scan(&taskID); err != nil {
t.Fatalf("create agent task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
// Agent posts a comment.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "agent reporting in",
})
r.Header.Set("X-Agent-ID", otherAgentID)
r.Header.Set("X-Task-ID", taskID)
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
}
// The private leader SHOULD have a queued task — agents bypass private gate.
var count int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&count); err != nil {
t.Fatalf("count tasks: %v", err)
}
if count == 0 {
t.Fatalf("private leader got 0 queued tasks from agent actor comment; want ≥1 (agents bypass private gate)")
}
}