package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // TestMemberAllowedForPrivateAgent_Pure exercises the pure predicate that // drives the private-agent gate. The gate must allow: // - workspace owner / admin (regardless of agent ownership) // - the agent owner (regardless of role) // // And deny everyone else. This test runs without a database. func TestMemberAllowedForPrivateAgent_Pure(t *testing.T) { ownerUserID := "11111111-1111-1111-1111-111111111111" otherUserID := "22222222-2222-2222-2222-222222222222" agent := db.Agent{ OwnerID: util.MustParseUUID(ownerUserID), } cases := []struct { name string userID string role string want bool }{ {"workspace owner, not agent owner", otherUserID, "owner", true}, {"workspace admin, not agent owner", otherUserID, "admin", true}, {"agent owner with member role", ownerUserID, "member", true}, {"agent owner with admin role", ownerUserID, "admin", true}, {"plain member, not agent owner", otherUserID, "member", false}, {"plain member with no role string", otherUserID, "", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := memberAllowedForPrivateAgent(agent, tc.userID, tc.role) if got != tc.want { t.Fatalf("memberAllowedForPrivateAgent(userID=%s, role=%s) = %v; want %v", tc.userID, tc.role, got, tc.want) } }) } } // privateAgentTestFixture sets up a private agent owned by a freshly created // user, plus a second non-admin member in the workspace. Returns the agent // id, the owner's user id, and the unrelated member's user id. The caller's // own testUserID stays workspace owner so it can act as the privileged // admin path. func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) { t.Helper() ctx := context.Background() if err := testPool.QueryRow(ctx, ` INSERT INTO "user" (name, email) VALUES ('Private Agent Owner', 'private-agent-owner@multica.test') RETURNING id `).Scan(&ownerID); err != nil { t.Fatalf("create owner user: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE email = 'private-agent-owner@multica.test'`) }) if _, err := testPool.Exec(ctx, ` INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member') `, testWorkspaceID, ownerID); err != nil { t.Fatalf("add owner as member: %v", err) } if err := testPool.QueryRow(ctx, ` INSERT INTO "user" (name, email) VALUES ('Plain Member', 'plain-member@multica.test') RETURNING id `).Scan(&memberID); err != nil { t.Fatalf("create plain member user: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE email = 'plain-member@multica.test'`) }) if _, err := testPool.Exec(ctx, ` INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member') `, testWorkspaceID, memberID); err != nil { t.Fatalf("add plain member: %v", err) } if err := testPool.QueryRow(ctx, ` INSERT INTO agent ( workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args ) VALUES ($1, 'private-access-test-agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb) RETURNING id `, testWorkspaceID, handlerTestRuntimeID(t), ownerID).Scan(&agentID); err != nil { t.Fatalf("create private agent: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID) }) return agentID, ownerID, memberID } func newRequestAs(userID, method, path string, body any) *http.Request { req := newRequest(method, path, body) req.Header.Set("X-User-ID", userID) return req } // TestGetAgent_PrivateAgentForbidsPlainMember verifies the private-agent // visibility gate at the read-detail endpoint: a workspace member who is // neither the agent owner nor a workspace owner/admin gets 403, while the // agent owner and workspace owner both succeed. Mirrors the four-entry-point // gate (chat, history, edit, delete) on its read surface. func TestGetAgent_PrivateAgentForbidsPlainMember(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, ownerID, memberID := privateAgentTestFixture(t) // Workspace owner (testUserID): allowed via role. w := httptest.NewRecorder() testHandler.GetAgent(w, withURLParam(newRequest("GET", "/api/agents/"+agentID, nil), "id", agentID)) if w.Code != http.StatusOK { t.Fatalf("GetAgent as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String()) } // Agent owner (plain member who happens to own the agent): allowed. w = httptest.NewRecorder() testHandler.GetAgent(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID, nil), "id", agentID)) if w.Code != http.StatusOK { t.Fatalf("GetAgent as agent owner: expected 200, got %d: %s", w.Code, w.Body.String()) } // Plain member (not in allowed_principals): denied with 403. w = httptest.NewRecorder() testHandler.GetAgent(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil), "id", agentID)) if w.Code != http.StatusForbidden { t.Fatalf("GetAgent as plain member: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestListAgents_FiltersPrivateForPlainMember verifies that the workspace // agents listing hides private agents from members who lack access. This is // what makes the @-mention autocomplete picker (which feeds off this list) // drop unreachable private agents without any client-side logic. func TestListAgents_FiltersPrivateForPlainMember(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, _, memberID := privateAgentTestFixture(t) // Workspace owner sees the agent. w := httptest.NewRecorder() testHandler.ListAgents(w, newRequest("GET", "/api/agents", nil)) if w.Code != http.StatusOK { t.Fatalf("ListAgents as owner: expected 200, got %d: %s", w.Code, w.Body.String()) } if !listContainsAgent(t, w.Body.Bytes(), agentID) { t.Fatalf("ListAgents as owner did not include private agent %s", agentID) } // Plain member does NOT see the agent. w = httptest.NewRecorder() testHandler.ListAgents(w, newRequestAs(memberID, "GET", "/api/agents", nil)) if w.Code != http.StatusOK { t.Fatalf("ListAgents as plain member: expected 200, got %d: %s", w.Code, w.Body.String()) } if listContainsAgent(t, w.Body.Bytes(), agentID) { t.Fatalf("ListAgents as plain member leaked private agent %s", agentID) } } func listContainsAgent(t *testing.T, body []byte, agentID string) bool { t.Helper() var resp []AgentResponse if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode ListAgents response: %v", err) } for _, a := range resp { if a.ID == agentID { return true } } return false } // TestListAgentTasks_PrivateAgentForbidsPlainMember verifies that the agent // task history endpoint (the "查看历史会话" surface) is also gated. func TestListAgentTasks_PrivateAgentForbidsPlainMember(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, ownerID, memberID := privateAgentTestFixture(t) w := httptest.NewRecorder() testHandler.ListAgentTasks(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID)) if w.Code != http.StatusOK { t.Fatalf("ListAgentTasks as owner: expected 200, got %d: %s", w.Code, w.Body.String()) } w = httptest.NewRecorder() testHandler.ListAgentTasks(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID)) if w.Code != http.StatusForbidden { t.Fatalf("ListAgentTasks as plain member: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestCreateIssue_AssignToPrivateAgentForbidsPlainMember verifies that the // issue-assignment surface is gated by the same predicate. Without this gate // a plain workspace member could side-step chat/@-mention by assigning a // private agent to an issue and letting normal task dispatch run it. func TestCreateIssue_AssignToPrivateAgentForbidsPlainMember(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, ownerID, memberID := privateAgentTestFixture(t) body := func(actorID string) map[string]any { return map[string]any{ "title": "assign-to-private-agent test " + actorID, "status": "todo", "priority": "medium", "assignee_type": "agent", "assignee_id": agentID, } } // Workspace owner (testUserID): allowed. w := httptest.NewRecorder() testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body(testUserID))) if w.Code != http.StatusCreated { t.Fatalf("CreateIssue as workspace owner: expected 201, got %d: %s", w.Code, w.Body.String()) } // Agent owner (plain member who happens to own the agent): allowed. w = httptest.NewRecorder() testHandler.CreateIssue(w, newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(ownerID))) if w.Code != http.StatusCreated { t.Fatalf("CreateIssue as agent owner: expected 201, got %d: %s", w.Code, w.Body.String()) } // Plain member: denied with 403 — closes the back door where issue // assignment would otherwise hand the agent a task without going // through chat / @-mention. w = httptest.NewRecorder() testHandler.CreateIssue(w, newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(memberID))) if w.Code != http.StatusForbidden { t.Fatalf("CreateIssue as plain member: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestCreateChatSession_PrivateAgentForbidsPlainMember verifies that members // who can't access the private agent cannot start a chat session against it. // The chat handler reads workspace context from middleware, so we set it // explicitly via middleware.SetMemberContext before invoking the handler // (the test harness doesn't run the real middleware chain). func TestCreateChatSession_PrivateAgentForbidsPlainMember(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, _, memberID := privateAgentTestFixture(t) // Load the plain member's row so we can build a realistic context. memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{ UserID: util.MustParseUUID(memberID), WorkspaceID: util.MustParseUUID(testWorkspaceID), }) if err != nil { t.Fatalf("load plain member row: %v", err) } body := map[string]any{ "agent_id": agentID, "title": "should be denied", } w := httptest.NewRecorder() req := newRequestAs(memberID, "POST", "/api/chat/sessions", body) req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow)) testHandler.CreateChatSession(w, req) if w.Code != http.StatusForbidden { t.Fatalf("CreateChatSession as plain member: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestGetAgent_RejectsForgedAgentIDHeader is the regression test for the // #2359 review finding "X-Agent-ID can be forged by a plain member to bypass // the private gate". A workspace member sets X-Agent-ID to any visible // agent's UUID without supplying a valid X-Task-ID — resolveActor must now // fall back to the member identity, so the private-agent gate stays effective. func TestGetAgent_RejectsForgedAgentIDHeader(t *testing.T) { if testHandler == nil { t.Skip("database not available") } agentID, _, memberID := privateAgentTestFixture(t) w := httptest.NewRecorder() req := newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil) // Forge X-Agent-ID without X-Task-ID. Pre-fix this would have made // resolveActor return ("agent", agentID) and canAccessPrivateAgent // would have unconditionally allowed the read. req.Header.Set("X-Agent-ID", agentID) req = withURLParam(req, "id", agentID) testHandler.GetAgent(w, req) if w.Code != http.StatusForbidden { t.Fatalf("GetAgent with forged X-Agent-ID: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked is the regression // test for the #2359 review finding "chat history read path doesn't re-gate". // A member who created a chat session is later denied access to the agent // (here simulated by the member never being on the allowlist for a private // agent owned by someone else; the equivalent of an after-the-fact ownership // transfer). The session row still names them as creator, but the read // endpoints must refuse to surface the transcript. func TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked(t *testing.T) { if testHandler == nil { t.Skip("database not available") } ctx := context.Background() agentID, _, memberID := privateAgentTestFixture(t) // Insert a chat session row directly with the plain member as creator, // bypassing CreateChatSession's own gate. This represents a session // that existed before the member lost access (or before the gate // landed). var sessionID string if err := testPool.QueryRow(ctx, ` INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status) VALUES ($1, $2, $3, 'pre-revocation session', 'active') RETURNING id `, testWorkspaceID, agentID, memberID).Scan(&sessionID); err != nil { t.Fatalf("seed chat session: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID) }) memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{ UserID: util.MustParseUUID(memberID), WorkspaceID: util.MustParseUUID(testWorkspaceID), }) if err != nil { t.Fatalf("load plain member row: %v", err) } w := httptest.NewRecorder() req := newRequestAs(memberID, "GET", "/api/chat/sessions/"+sessionID+"/messages", nil) req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow)) req = withURLParam(req, "sessionId", sessionID) testHandler.ListChatMessages(w, req) if w.Code != http.StatusForbidden { t.Fatalf("ListChatMessages on stale session: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestMentionAgent_RejectsCrossWorkspaceAgentUUID is the regression test for // the #2359 review finding "@mention path doesn't constrain the mentioned // agent to the current workspace". A plain member in workspace A who happens // to be owner of workspace B should NOT be able to @mention a private agent // in workspace B from a comment on a workspace-A issue and have it pass the // gate (the gate was being applied against the wrong workspace's roles). func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) { if testHandler == nil { t.Skip("database not available") } ctx := context.Background() // Create a separate workspace + agent runtime + private agent. var foreignWorkspaceID, foreignUserID, foreignRuntimeID, foreignAgentID string if err := testPool.QueryRow(ctx, ` INSERT INTO "user" (name, email) VALUES ('Foreign Owner', 'cross-ws-foreign@multica.test') RETURNING id `).Scan(&foreignUserID); err != nil { t.Fatalf("create foreign user: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE email = 'cross-ws-foreign@multica.test'`) }) if err := testPool.QueryRow(ctx, ` INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ('Cross-WS Foreign', 'cross-ws-foreign', '', 'XWF') RETURNING id `).Scan(&foreignWorkspaceID); err != nil { t.Fatalf("create foreign workspace: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM workspace WHERE slug = 'cross-ws-foreign'`) }) if _, err := testPool.Exec(ctx, ` INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner') `, foreignWorkspaceID, foreignUserID); err != nil { t.Fatalf("add foreign member: %v", err) } if err := testPool.QueryRow(ctx, ` INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at) VALUES ($1, NULL, 'Foreign Runtime', 'cloud', 'foreign_test', 'online', 'Foreign', '{}'::jsonb, now()) RETURNING id `, foreignWorkspaceID).Scan(&foreignRuntimeID); err != nil { t.Fatalf("create foreign runtime: %v", err) } if err := testPool.QueryRow(ctx, ` INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args) VALUES ($1, 'foreign-private-agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb) RETURNING id `, foreignWorkspaceID, foreignRuntimeID, foreignUserID).Scan(&foreignAgentID); err != nil { t.Fatalf("create foreign agent: %v", err) } // Create an issue in OUR workspace and a comment that @mentions the // foreign agent's UUID. testUserID is owner of our workspace; pre-fix // the gate would have applied our-workspace-owner status to the foreign // agent and enqueued a task. var issueID, commentID string if err := testPool.QueryRow(ctx, ` INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number) VALUES ($1, 'cross-ws mention test', 'todo', 'medium', 'member', $2, COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) RETURNING id `, testWorkspaceID, testUserID).Scan(&issueID); err != nil { t.Fatalf("create test issue: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) }) // Multica's mention format is markdown-linked: [@Name](mention://agent/). mention := "[@Foreign](mention://agent/" + foreignAgentID + ")" if err := testPool.QueryRow(ctx, ` INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content) VALUES ($1, $2, 'member', $3, $4) RETURNING id `, testWorkspaceID, issueID, testUserID, mention).Scan(&commentID); err != nil { t.Fatalf("create test comment: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID) }) issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID)) if err != nil { t.Fatalf("load test issue: %v", err) } comment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(commentID)) if err != nil { t.Fatalf("load test comment: %v", err) } // Count tasks for the foreign agent before. Calling the dispatcher // directly bypasses HTTP-layer concerns and exercises only the // workspace-scoping check. var beforeCount int if err := testPool.QueryRow(ctx, `SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`, foreignAgentID, ).Scan(&beforeCount); err != nil { t.Fatalf("count tasks before: %v", err) } testHandler.enqueueMentionedAgentTasks(ctx, issue, comment, nil, "member", testUserID) var afterCount int if err := testPool.QueryRow(ctx, `SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`, foreignAgentID, ).Scan(&afterCount); err != nil { t.Fatalf("count tasks after: %v", err) } if afterCount != beforeCount { t.Fatalf("foreign agent task count changed: before=%d after=%d — cross-workspace mention was not rejected", beforeCount, afterCount) } } // TestShouldEnqueueOnComment_PrivateAgentGate is the regression test for // GH #3300: after an owner/admin assigns a private agent to an issue, the // agent's UUID is "welded" onto that issue and any member with comment // access could previously dispatch a new task to the private agent simply by // posting a plain (non-@mention) comment, bypassing the visibility gate that // #2359 added to chat / @mention / assignment. // // The gate must: // - reject plain workspace members (not owner, not admin, not agent owner) // - allow the agent owner // - allow workspace owners/admins // - allow agent-to-agent traffic regardless of agent visibility func TestShouldEnqueueOnComment_PrivateAgentGate(t *testing.T) { if testHandler == nil || testPool == nil { t.Skip("database not available") } ctx := context.Background() agentID, ownerID, memberID := privateAgentTestFixture(t) // Assign the private agent to a fresh issue. Owner/admin would normally // be the one performing this step; we insert directly so the test // focuses on the on_comment trigger path. var issueID string if err := testPool.QueryRow(ctx, ` INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id, number) VALUES ($1, 'on_comment private-agent gate test', 'todo', 'medium', 'member', $2, 'agent', $3, COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) RETURNING id `, testWorkspaceID, testUserID, agentID).Scan(&issueID); err != nil { t.Fatalf("create issue assigned to private agent: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) }) issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID)) if err != nil { t.Fatalf("load issue: %v", err) } cases := []struct { name string actorType string actorID string want bool reason string }{ { name: "plain member — denied", actorType: "member", actorID: memberID, want: false, reason: "GH #3300: plain members must not be able to dispatch a task to a private agent via on_comment", }, { name: "agent owner — allowed", actorType: "member", actorID: ownerID, want: true, reason: "agent owner is always in the allowed_principals set", }, { name: "workspace owner — allowed", actorType: "member", actorID: testUserID, want: true, reason: "workspace owners/admins are in the allowed_principals set", }, { name: "agent-to-agent — allowed", actorType: "agent", actorID: agentID, want: true, reason: "A2A traffic bypasses the visibility gate by design", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := testHandler.shouldEnqueueOnComment(ctx, issue, tc.actorType, tc.actorID) if got != tc.want { t.Fatalf("%s\n actor=%s/%s got=%v want=%v", tc.reason, tc.actorType, tc.actorID, got, tc.want) } }) } }