mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +02:00
Squad create/manage was gated behind workspace owner/admin, inconsistent with agents and projects which any member can create. Move squads to a creator-scoped model: any member can create a squad and becomes its creator, and manages only the squads they created; owner/admin continue to manage every squad. Backend (server/internal/handler/squad.go): - Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad, AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load + squad load + per-squad check, replacing requireWorkspaceRole). - CreateSquad is now member-creatable. - Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger (canInvokeAgent as themselves) as squad leader (create/update) or worker (add member); admins may wire any workspace agent. Prevents a creator from smuggling an agent they cannot invoke into a squad. Frontend: - squad-detail-page: compute per-squad canManage (admin || creator) and render the inspector, members tab, instructions and archive read-only otherwise, mirroring the agent detail canEdit pattern. - squads-page: per-row actions and the actions column now key off per-squad canManage instead of workspace-admin. Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is out of scope for this iteration. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
223 lines
8.5 KiB
Go
223 lines
8.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// squadScopeReq builds a request as the given user (empty = workspace owner)
|
|
// with the chi URL params the squad handlers read (workspaceId + optional id).
|
|
// The squad handlers resolve the workspace from workspaceIDFromURL, which reads
|
|
// the chi route context, not the query string — so tests must inject the params
|
|
// here rather than on the path.
|
|
func squadScopeReq(userID, method, path string, body any, params map[string]string) *http.Request {
|
|
var req *http.Request
|
|
if userID == "" {
|
|
req = newRequest(method, path, body)
|
|
} else {
|
|
req = newRequestAs(userID, method, path, body)
|
|
}
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("workspaceId", testWorkspaceID)
|
|
for k, v := range params {
|
|
rctx.URLParams.Add(k, v)
|
|
}
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
// createSquadAs creates a squad through the handler as the given user and
|
|
// returns the decoded response. Registers cleanup for the squad + its members.
|
|
func createSquadAs(t *testing.T, userID, name, leaderID string) SquadResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r := squadScopeReq(userID, "POST", "/api/squads", map[string]any{
|
|
"name": name,
|
|
"leader_id": leaderID,
|
|
}, nil)
|
|
testHandler.CreateSquad(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateSquad(%s): expected 201, got %d: %s", name, w.Code, w.Body.String())
|
|
}
|
|
var resp SquadResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode squad: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
|
|
})
|
|
return resp
|
|
}
|
|
|
|
// TestCreateSquad_PlainMemberBecomesCreator verifies the gate change: a plain
|
|
// workspace member (not owner/admin) can create a squad and is recorded as its
|
|
// creator.
|
|
func TestCreateSquad_PlainMemberBecomesCreator(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
memberID := createPlainMember(t, "squad-creator@multica.test")
|
|
leaderID := createHandlerTestAgent(t, "squad-creator-leader", nil)
|
|
|
|
squad := createSquadAs(t, memberID, "Member Owned Squad", leaderID)
|
|
if squad.CreatorID != memberID {
|
|
t.Fatalf("expected creator_id=%s, got %s", memberID, squad.CreatorID)
|
|
}
|
|
}
|
|
|
|
// TestManageSquad_CreatorCanManageOwn verifies a creator can update, add a
|
|
// member to, and archive their own squad.
|
|
func TestManageSquad_CreatorCanManageOwn(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
memberID := createPlainMember(t, "squad-owner-manage@multica.test")
|
|
leaderID := createHandlerTestAgent(t, "squad-owner-manage-leader", nil)
|
|
worker := createHandlerTestAgent(t, "squad-owner-manage-worker", nil)
|
|
|
|
squad := createSquadAs(t, memberID, "Manage Own Squad", leaderID)
|
|
|
|
// Update name.
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateSquad(w, squadScopeReq(memberID, "PATCH", "/api/squads", map[string]any{
|
|
"name": "Renamed By Creator",
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateSquad as creator: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Add a public agent worker.
|
|
w = httptest.NewRecorder()
|
|
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
|
|
"member_type": "agent",
|
|
"member_id": worker,
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("AddSquadMember as creator: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Archive.
|
|
w = httptest.NewRecorder()
|
|
testHandler.DeleteSquad(w, squadScopeReq(memberID, "DELETE", "/api/squads", nil,
|
|
map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteSquad as creator: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestManageSquad_StrangerMemberForbidden verifies a plain member who did not
|
|
// create the squad cannot manage it, while a workspace admin/owner still can.
|
|
func TestManageSquad_StrangerMemberForbidden(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
creatorID := createPlainMember(t, "squad-stranger-creator@multica.test")
|
|
strangerID := createPlainMember(t, "squad-stranger-other@multica.test")
|
|
leaderID := createHandlerTestAgent(t, "squad-stranger-leader", nil)
|
|
|
|
squad := createSquadAs(t, creatorID, "Stranger Test Squad", leaderID)
|
|
|
|
// Stranger member: update denied.
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateSquad(w, squadScopeReq(strangerID, "PATCH", "/api/squads", map[string]any{
|
|
"name": "Hijacked",
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("UpdateSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Stranger member: archive denied.
|
|
w = httptest.NewRecorder()
|
|
testHandler.DeleteSquad(w, squadScopeReq(strangerID, "DELETE", "/api/squads", nil,
|
|
map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("DeleteSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Workspace owner (testUserID): update allowed — admin management unchanged.
|
|
w = httptest.NewRecorder()
|
|
testHandler.UpdateSquad(w, squadScopeReq("", "PATCH", "/api/squads", map[string]any{
|
|
"name": "Renamed By Admin",
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateSquad as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestAddSquadMember_CreatorAgentAccessGate verifies the comment-#2 rule: a
|
|
// non-admin creator may add a public agent (invocable) but not a private agent
|
|
// they cannot @-trigger. The workspace owner may add the same private agent —
|
|
// admin wiring is unrestricted.
|
|
func TestAddSquadMember_CreatorAgentAccessGate(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
privateAgentID, _, memberID := privateAgentTestFixture(t)
|
|
publicLeaderID := createHandlerTestAgent(t, "squad-gate-leader", nil)
|
|
publicWorkerID := createHandlerTestAgent(t, "squad-gate-worker", nil)
|
|
|
|
squad := createSquadAs(t, memberID, "Agent Gate Squad", publicLeaderID)
|
|
|
|
// Creator adds a public (invocable) worker — allowed.
|
|
w := httptest.NewRecorder()
|
|
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
|
|
"member_type": "agent",
|
|
"member_id": publicWorkerID,
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("AddSquadMember public agent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Creator adds a private agent they cannot invoke — denied.
|
|
w = httptest.NewRecorder()
|
|
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
|
|
"member_type": "agent",
|
|
"member_id": privateAgentID,
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("AddSquadMember private agent as creator: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Workspace owner adds the same private agent — allowed (admin unchanged).
|
|
w = httptest.NewRecorder()
|
|
testHandler.AddSquadMember(w, squadScopeReq("", "POST", "/api/squads/members", map[string]any{
|
|
"member_type": "agent",
|
|
"member_id": privateAgentID,
|
|
}, map[string]string{"id": squad.ID}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("AddSquadMember private agent as owner: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateSquad_CreatorPrivateLeaderForbidden verifies a non-admin cannot
|
|
// create a squad led by a private agent they cannot @-trigger.
|
|
func TestCreateSquad_CreatorPrivateLeaderForbidden(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
privateAgentID, _, memberID := privateAgentTestFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := squadScopeReq(memberID, "POST", "/api/squads", map[string]any{
|
|
"name": "Private Leader Squad",
|
|
"leader_id": privateAgentID,
|
|
}, nil)
|
|
testHandler.CreateSquad(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
// Nothing should have been created; if it slipped through, clean up.
|
|
if w.Code == http.StatusCreated {
|
|
var resp SquadResponse
|
|
if json.NewDecoder(w.Body).Decode(&resp) == nil {
|
|
testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
|
|
}
|
|
}
|
|
t.Fatalf("CreateSquad with private leader: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|