mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
* 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>
368 lines
13 KiB
Go
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)")
|
|
}
|
|
}
|