mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
This reverts commit b13657be71.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
277 lines
11 KiB
Go
277 lines
11 KiB
Go
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"
|
|
)
|
|
|
|
// chatPendingCtxAs injects the workspace + member context that the chi
|
|
// workspace middleware would normally set, resolved for an arbitrary user.
|
|
// The pending-tasks handlers read the workspace id from ctxWorkspaceID and the
|
|
// caller's role from the member context, so a direct handler call needs both.
|
|
func chatPendingCtxAs(t *testing.T, req *http.Request, userID string) *http.Request {
|
|
t.Helper()
|
|
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: util.MustParseUUID(userID),
|
|
WorkspaceID: util.MustParseUUID(testWorkspaceID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load member row for %s: %v", userID, err)
|
|
}
|
|
return req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
|
|
}
|
|
|
|
// insertChatSessionAs inserts a chat_session owned by an explicit creator +
|
|
// agent (createHandlerTestChatSession hardcodes testUserID as creator, which
|
|
// these permission tests need to vary).
|
|
func insertChatSessionAs(t *testing.T, agentID, creatorID string) string {
|
|
t.Helper()
|
|
var sessionID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
|
VALUES ($1, $2, $3, 'pending-tasks-test', 'active')
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, creatorID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("insert chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID)
|
|
})
|
|
return sessionID
|
|
}
|
|
|
|
// insertPendingChatTask seeds an in-flight agent_task_queue row bound to a chat
|
|
// session and returns its id.
|
|
func insertPendingChatTask(t *testing.T, agentID, sessionID, status string) string {
|
|
t.Helper()
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, chat_session_id)
|
|
VALUES ($1, $2, $3, 0, $4)
|
|
RETURNING id
|
|
`, agentID, handlerTestRuntimeID(t), status, sessionID).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert pending chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
func decodePendingTasks(t *testing.T, w *httptest.ResponseRecorder) PendingChatTasksResponse {
|
|
t.Helper()
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp PendingChatTasksResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode pending tasks: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func decodeHasPending(t *testing.T, w *httptest.ResponseRecorder) bool {
|
|
t.Helper()
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp HasPendingChatTasksResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode has-any: %v", err)
|
|
}
|
|
return resp.HasPending
|
|
}
|
|
|
|
func containsPendingTask(tasks []PendingChatTaskItem, taskID string) bool {
|
|
for _, it := range tasks {
|
|
if it.TaskID == taskID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestListPendingChatTasks_HidesPrivateAgentFromLostAccessCreator verifies the
|
|
// P1 rewrite still enforces the private-agent gate now that filtering keys off
|
|
// the agent_id returned by the query (instead of a second session-list scan).
|
|
// A member who created a chat with a private agent they can no longer access
|
|
// must not see that task in the aggregate, while a task on a workspace-visible
|
|
// agent they still own is returned.
|
|
func TestListPendingChatTasks_HidesPrivateAgentFromLostAccessCreator(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
privateAgentID, _, memberID := privateAgentTestFixture(t)
|
|
publicAgentID := createHandlerTestAgent(t, "PendingPublicAgent", []byte("[]"))
|
|
|
|
// The plain member is the creator of BOTH sessions, so the creator_id
|
|
// filter admits both; only the private-agent gate should drop one.
|
|
privateSession := insertChatSessionAs(t, privateAgentID, memberID)
|
|
publicSession := insertChatSessionAs(t, publicAgentID, memberID)
|
|
privateTask := insertPendingChatTask(t, privateAgentID, privateSession, "running")
|
|
publicTask := insertPendingChatTask(t, publicAgentID, publicSession, "queued")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(memberID, "GET", "/api/chat/pending-tasks", nil), memberID))
|
|
resp := decodePendingTasks(t, w)
|
|
|
|
if containsPendingTask(resp.Tasks, privateTask) {
|
|
t.Fatalf("private-agent task %s leaked to plain member: %+v", privateTask, resp.Tasks)
|
|
}
|
|
if !containsPendingTask(resp.Tasks, publicTask) {
|
|
t.Fatalf("public-agent task %s missing from plain member's pending list: %+v", publicTask, resp.Tasks)
|
|
}
|
|
}
|
|
|
|
// TestListPendingChatTasks_OwnerSeesPrivateAgentTask is the positive control:
|
|
// the agent owner keeps visibility of their own private-agent chat task.
|
|
func TestListPendingChatTasks_OwnerSeesPrivateAgentTask(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
privateAgentID, ownerID, _ := privateAgentTestFixture(t)
|
|
session := insertChatSessionAs(t, privateAgentID, ownerID)
|
|
task := insertPendingChatTask(t, privateAgentID, session, "running")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(ownerID, "GET", "/api/chat/pending-tasks", nil), ownerID))
|
|
resp := decodePendingTasks(t, w)
|
|
|
|
if !containsPendingTask(resp.Tasks, task) {
|
|
t.Fatalf("agent owner did not see their own private-agent task %s: %+v", task, resp.Tasks)
|
|
}
|
|
}
|
|
|
|
// TestHasPendingChatTasks_FalseWhenOnlyInaccessiblePrivateAgent verifies the P3
|
|
// boolean endpoint preserves the same permission filtering as the list: a
|
|
// member whose only in-flight task is on a private agent they can't access
|
|
// gets has_pending=false (the FAB must not light up for a task they've lost
|
|
// access to).
|
|
func TestHasPendingChatTasks_FalseWhenOnlyInaccessiblePrivateAgent(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
privateAgentID, _, memberID := privateAgentTestFixture(t)
|
|
session := insertChatSessionAs(t, privateAgentID, memberID)
|
|
insertPendingChatTask(t, privateAgentID, session, "running")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(memberID, "GET", "/api/chat/pending-tasks/has-any", nil), memberID))
|
|
if decodeHasPending(t, w) {
|
|
t.Fatalf("has-any returned true for a task on a private agent the member cannot access")
|
|
}
|
|
}
|
|
|
|
// TestHasPendingChatTasks_TrueWhenAccessiblePublicAgent verifies the boolean
|
|
// endpoint returns true once the member has an in-flight task on an agent they
|
|
// can access — and that an inaccessible private-agent task alongside it does
|
|
// not change the (already true) answer.
|
|
func TestHasPendingChatTasks_TrueWhenAccessiblePublicAgent(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
privateAgentID, _, memberID := privateAgentTestFixture(t)
|
|
publicAgentID := createHandlerTestAgent(t, "PendingPublicAgentHasAny", []byte("[]"))
|
|
|
|
privateSession := insertChatSessionAs(t, privateAgentID, memberID)
|
|
publicSession := insertChatSessionAs(t, publicAgentID, memberID)
|
|
insertPendingChatTask(t, privateAgentID, privateSession, "running")
|
|
insertPendingChatTask(t, publicAgentID, publicSession, "waiting_local_directory")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(memberID, "GET", "/api/chat/pending-tasks/has-any", nil), memberID))
|
|
if !decodeHasPending(t, w) {
|
|
t.Fatalf("has-any returned false despite an in-flight task on an accessible public agent")
|
|
}
|
|
}
|
|
|
|
// TestHasPendingChatTasks_OwnerOfPrivateAgentSeesTask confirms the boolean
|
|
// endpoint's positive path for a private agent's owner.
|
|
func TestHasPendingChatTasks_OwnerOfPrivateAgentSeesTask(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
privateAgentID, ownerID, _ := privateAgentTestFixture(t)
|
|
session := insertChatSessionAs(t, privateAgentID, ownerID)
|
|
insertPendingChatTask(t, privateAgentID, session, "dispatched")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(ownerID, "GET", "/api/chat/pending-tasks/has-any", nil), ownerID))
|
|
if !decodeHasPending(t, w) {
|
|
t.Fatalf("has-any returned false for the private agent's own owner")
|
|
}
|
|
}
|
|
|
|
// TestHasPendingChatTasks_IgnoresTerminalTasks guards the status predicate: a
|
|
// completed task is not "pending", so the endpoint must return false.
|
|
func TestHasPendingChatTasks_IgnoresTerminalTasks(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Use a freshly-created member (via the private-agent fixture) as the
|
|
// creator so no stray in-flight task from another test can bleed into the
|
|
// assertion — this user exists only for the duration of this test.
|
|
_, _, memberID := privateAgentTestFixture(t)
|
|
publicAgentID := createHandlerTestAgent(t, "PendingTerminalAgent", []byte("[]"))
|
|
session := insertChatSessionAs(t, publicAgentID, memberID)
|
|
insertPendingChatTask(t, publicAgentID, session, "completed")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(memberID, "GET", "/api/chat/pending-tasks/has-any", nil), memberID))
|
|
if decodeHasPending(t, w) {
|
|
t.Fatalf("has-any returned true for a terminal (completed) task")
|
|
}
|
|
}
|
|
|
|
|
|
// TestHasPendingChatTasks_HidesOtherCreatorsTask locks the cs.creator_id gate:
|
|
// user A's in-flight task on a workspace-visible agent — one B can freely
|
|
// access — must still return has_pending=false for B, because B is not the
|
|
// creator. This is the tenant boundary that the agent-visibility filter does
|
|
// NOT cover (both users can see the agent), so it needs its own guard.
|
|
func TestHasPendingChatTasks_HidesOtherCreatorsTask(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// ownerID = user A (task creator), memberID = user B (different member).
|
|
// Both are plain workspace members who can access a workspace-visible agent.
|
|
_, creatorA, otherB := privateAgentTestFixture(t)
|
|
publicAgentID := createHandlerTestAgent(t, "PendingCrossCreatorAgent", []byte("[]"))
|
|
session := insertChatSessionAs(t, publicAgentID, creatorA)
|
|
insertPendingChatTask(t, publicAgentID, session, "running")
|
|
|
|
// B is not the creator → the creator_id filter must drop A's task.
|
|
w := httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(otherB, "GET", "/api/chat/pending-tasks/has-any", nil), otherB))
|
|
if decodeHasPending(t, w) {
|
|
t.Fatalf("has-any leaked user A's task to user B (cs.creator_id gate not enforced)")
|
|
}
|
|
|
|
// Sanity: A (the creator) does see their own task on the same agent.
|
|
w = httptest.NewRecorder()
|
|
testHandler.HasPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(creatorA, "GET", "/api/chat/pending-tasks/has-any", nil), creatorA))
|
|
if !decodeHasPending(t, w) {
|
|
t.Fatalf("has-any returned false for the task's own creator")
|
|
}
|
|
|
|
// The detailed list endpoint enforces the same creator gate.
|
|
w = httptest.NewRecorder()
|
|
testHandler.ListPendingChatTasks(w, chatPendingCtxAs(t, newRequestAs(otherB, "GET", "/api/chat/pending-tasks", nil), otherB))
|
|
if resp := decodePendingTasks(t, w); len(resp.Tasks) != 0 {
|
|
t.Fatalf("list leaked user A's task to user B: %+v", resp.Tasks)
|
|
}
|
|
} |