mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +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>
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestCreateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
|
// plain member cannot create an autopilot assigned to a squad whose leader
|
|
// is a private agent.
|
|
func TestCreateAutopilot_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, 'AP Private Leader Create', '', $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/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "should be blocked",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
"execution_mode": "create_issue",
|
|
})
|
|
testHandler.CreateAutopilot(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
|
// plain member cannot update an autopilot to point at a private-leader squad.
|
|
func TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, _, memberID := privateAgentTestFixture(t)
|
|
|
|
// Create a non-private agent for the initial autopilot.
|
|
publicAgentID := createHandlerTestAgent(t, "ap-private-leader-public", nil)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'AP Private Leader Update', '', $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 autopilot as workspace owner assigned to the public agent.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "update target ap",
|
|
"assignee_id": publicAgentID,
|
|
"execution_mode": "create_issue",
|
|
})
|
|
testHandler.CreateAutopilot(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var ap AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
|
})
|
|
|
|
// Plain member tries to update to the private-leader squad.
|
|
squadType := "squad"
|
|
w = httptest.NewRecorder()
|
|
r = newRequestAs(memberID, "PATCH", "/api/autopilots/"+ap.ID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"assignee_type": squadType,
|
|
"assignee_id": squadID,
|
|
})
|
|
r = withURLParam(r, "id", ap.ID)
|
|
testHandler.UpdateAutopilot(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateAutopilot_SquadPrivateLeader_OwnerAllowed verifies that a
|
|
// workspace owner CAN create an autopilot assigned to a private-leader squad.
|
|
func TestCreateAutopilot_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, 'AP Private Leader Owner', '', $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/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "owner creates private-leader squad ap",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
"execution_mode": "create_issue",
|
|
})
|
|
testHandler.CreateAutopilot(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var ap AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
|
})
|
|
}
|
|
|
|
// TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch verifies that a
|
|
// squad autopilot with private leader configured by an owner triggers
|
|
// correctly at dispatch time.
|
|
func TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch(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, 'AP Private Leader Dispatch', '', $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 autopilot as owner.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "dispatch test private leader squad",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
"execution_mode": "create_issue",
|
|
})
|
|
testHandler.CreateAutopilot(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var ap AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, ap.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id IN (SELECT id FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%')`, testWorkspaceID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%'`, testWorkspaceID)
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
|
})
|
|
|
|
// Trigger — should succeed since owner created it.
|
|
w = httptest.NewRecorder()
|
|
r = newRequest("POST", "/api/autopilots/"+ap.ID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
|
r = withURLParam(r, "id", ap.ID)
|
|
testHandler.TriggerAutopilot(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var run AutopilotRunResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
|
t.Fatalf("decode run: %v", err)
|
|
}
|
|
if run.Status != "issue_created" {
|
|
t.Fatalf("run status = %q, want issue_created", run.Status)
|
|
}
|
|
}
|
|
|
|
// TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked verifies
|
|
// that if an autopilot pointing to a private-leader squad was somehow saved
|
|
// by a plain member (legacy data), dispatch is blocked at runtime.
|
|
func TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked(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, 'AP Private Leader Blocked Dispatch', '', $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)
|
|
})
|
|
|
|
// Directly insert an autopilot with the plain member as creator
|
|
// (simulating legacy data before the save-time gate).
|
|
var apID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (workspace_id, title, assignee_type, assignee_id,
|
|
execution_mode, created_by_type, created_by_id, status)
|
|
VALUES ($1, 'legacy illegal ap', 'squad', $2, 'create_issue', 'member', $3, 'active')
|
|
RETURNING id
|
|
`, testWorkspaceID, squadID, memberID).Scan(&apID); err != nil {
|
|
t.Fatalf("create autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, apID)
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID)
|
|
})
|
|
|
|
// Trigger as workspace owner — the dispatch should fail because the
|
|
// autopilot's creator (plain member) cannot access the private leader.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/autopilots/"+apID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
|
r = withURLParam(r, "id", apID)
|
|
testHandler.TriggerAutopilot(w, r)
|
|
// Dispatch returns 200 with status=skipped (or failed) — the run is created
|
|
// but the dispatch is blocked by the private-leader gate.
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var run AutopilotRunResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
|
t.Fatalf("decode run: %v", err)
|
|
}
|
|
// The dispatch-time gate should cause a skipped or failed run.
|
|
if run.Status == "issue_created" || run.Status == "running" {
|
|
t.Fatalf("run status = %q; want skipped/failed since creator is plain member", run.Status)
|
|
}
|
|
}
|
|
|
|
// TestTriggerAutopilot_RunOnly_SquadPrivateLeader_PlainMemberCreator_Blocked
|
|
// mirrors the create_issue dispatch test above but exercises the run_only
|
|
// dispatch path (dispatchRunOnly), ensuring both dispatch branches gate
|
|
// private-leader access.
|
|
func TestTriggerAutopilot_RunOnly_SquadPrivateLeader_PlainMemberCreator_Blocked(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, 'AP RunOnly Private Leader Blocked', '', $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)
|
|
})
|
|
|
|
// Legacy autopilot: run_only mode, plain member creator, private-leader squad.
|
|
var apID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (workspace_id, title, assignee_type, assignee_id,
|
|
execution_mode, created_by_type, created_by_id, status)
|
|
VALUES ($1, 'legacy run_only illegal ap', 'squad', $2, 'run_only', 'member', $3, 'active')
|
|
RETURNING id
|
|
`, testWorkspaceID, squadID, memberID).Scan(&apID); err != nil {
|
|
t.Fatalf("create autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, apID)
|
|
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/autopilots/"+apID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
|
r = withURLParam(r, "id", apID)
|
|
testHandler.TriggerAutopilot(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var run AutopilotRunResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
|
t.Fatalf("decode run: %v", err)
|
|
}
|
|
if run.Status == "running" {
|
|
t.Fatalf("run status = %q; want skipped/failed since creator is plain member and leader is private", run.Status)
|
|
}
|
|
}
|