Files
multica/server/internal/handler/chat_task_privacy_test.go
2083970b51 MUL-8374: fix: keep private chat content to the session owner (realtime + REST) (#9677)
* MUL-8374: fix(realtime): deliver private chat content only to the session owner

Chat events and chat-task transcript (task:message) frames were fanned out
to every connection in the workspace. Route every chat:* event, and
task:message for chat tasks, to the chat session creator's user scope
instead. Producers stamp the owner on the bus event (from the same
chat_session row they already read); an event without an owner is dropped
rather than widened. Chat-task lifecycle events keep workspace fanout for
agent presence.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): keep chat-task content private on REST task reads

The transcript endpoint now 404s chat tasks for anyone but the chat
session's creator, and the agent run list and workspace task snapshot keep
foreign chat-task rows but blank their result and error.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(realtime): scope private chat delivery to the owner's workspace connections

User-scope delivery reached the chat creator's connections to every
workspace. Those are authorized against their own workspace only, so a
creator removed from the chat's workspace kept receiving its transcript
and reply through a socket to another workspace. Chat content now goes to
a workspace_member scope each connection joins at connect time; it is
re-authorized against the chat's workspace on every write.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-29 19:13:45 +08:00

130 lines
5.1 KiB
Go

package handler
import (
"net/http"
"testing"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type chatTaskPrivacyFixture struct {
ownerID string
agentID string
chatTaskID string
issueTaskID string
}
// seedChatTaskPrivacy builds a plain member's agent with a finished chat task
// the member started and a running issue task, both carrying a result and an
// error. The bystander in these tests is testUserID, the workspace owner, so an
// admin role cannot be what decides access.
func seedChatTaskPrivacy(t *testing.T) chatTaskPrivacyFixture {
t.Helper()
ownerID := dbfx.User(t, "Chat task owner", "[email protected]")
dbfx.Member(t, testWorkspaceID, ownerID, "member")
runtimeID := handlerTestRuntimeID(t)
agentID := dbfx.Agent(t, "chat task privacy agent", runtimeID, testutil.Cols{"owner_id": ownerID})
chatSessionID := dbfx.ChatSession(t, agentID, testutil.Cols{"creator_id": ownerID})
chatTaskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID,
"chat_session_id": chatSessionID,
"status": "completed",
"completed_at": testutil.Raw("now()"),
"result": testutil.Raw(`'{"output":"private chat reply"}'::jsonb`),
"error": "private chat error",
})
dbfx.Insert(t, "task_message", testutil.Cols{
"task_id": chatTaskID,
"seq": 1,
"type": "text",
"content": "private chat transcript",
})
issueID := dbfx.Issue(t, "chat task privacy issue", testutil.Cols{"status": "in_progress"})
issueTaskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID,
"issue_id": issueID,
"status": "running",
"started_at": testutil.Raw("now()"),
"result": testutil.Raw(`'{"output":"issue output"}'::jsonb`),
"error": "issue error",
})
return chatTaskPrivacyFixture{ownerID: ownerID, agentID: agentID, chatTaskID: chatTaskID, issueTaskID: issueTaskID}
}
func TestListTaskMessagesByUserHidesChatTranscriptFromNonOwner(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
f := seedChatTaskPrivacy(t)
read := func(userID, taskID string) *testutil.Response {
req := withURLParam(newRequestAs(userID, http.MethodGet, "/api/tasks/"+taskID+"/messages", nil), "taskId", taskID)
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, db.Member{}))
return testutil.Call(t, testHandler.ListTaskMessagesByUser, req)
}
var history []protocol.TaskMessagePayload
read(f.ownerID, f.chatTaskID).Want(http.StatusOK).JSON(&history)
if len(history) != 1 || history[0].Content != "private chat transcript" {
t.Fatalf("owner transcript = %+v, want the one private message", history)
}
read(testUserID, f.chatTaskID).Want(http.StatusNotFound)
read(testUserID, f.issueTaskID).Want(http.StatusOK)
}
func TestAgentTaskListsRedactForeignChatTaskContent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
f := seedChatTaskPrivacy(t)
lists := map[string]func(t *testing.T, userID string) []AgentTaskResponse{
"agent tasks": func(t *testing.T, userID string) []AgentTaskResponse {
req := withURLParam(newRequestAs(userID, http.MethodGet, "/api/agents/"+f.agentID+"/tasks", nil), "id", f.agentID)
var tasks []AgentTaskResponse
testutil.Call(t, testHandler.ListAgentTasks, req).Want(http.StatusOK).JSON(&tasks)
return tasks
},
"workspace snapshot": func(t *testing.T, userID string) []AgentTaskResponse {
req := newRequestAs(userID, http.MethodGet, "/api/agent-task-snapshot", nil)
var tasks []AgentTaskResponse
testutil.Call(t, testHandler.ListWorkspaceAgentTaskSnapshot, req).Want(http.StatusOK).JSON(&tasks)
return tasks
},
}
for name, list := range lists {
t.Run(name, func(t *testing.T) {
byViewer := map[string]map[string]AgentTaskResponse{}
for _, viewer := range []string{f.ownerID, testUserID} {
byViewer[viewer] = map[string]AgentTaskResponse{}
for _, task := range list(t, viewer) {
byViewer[viewer][task.ID] = task
}
for _, id := range []string{f.chatTaskID, f.issueTaskID} {
if _, ok := byViewer[viewer][id]; !ok {
t.Fatalf("viewer %s: task %s missing; rows must stay listed for presence", viewer, id)
}
}
}
own := byViewer[f.ownerID][f.chatTaskID]
if own.Result == nil || own.Error == nil || *own.Error != "private chat error" {
t.Fatalf("owner sees chat task result=%v error=%v, want full content", own.Result, own.Error)
}
foreign := byViewer[testUserID][f.chatTaskID]
if foreign.Result != nil || foreign.Error != nil {
t.Fatalf("non-owner sees chat task result=%v error=%v, want both redacted", foreign.Result, foreign.Error)
}
if foreign.ChatSessionID == "" || foreign.Status != "completed" {
t.Fatalf("non-owner chat row = %+v, want its metadata kept", foreign)
}
issue := byViewer[testUserID][f.issueTaskID]
if issue.Result == nil || issue.Error == nil || *issue.Error != "issue error" {
t.Fatalf("issue task result=%v error=%v, want it untouched", issue.Result, issue.Error)
}
})
}
}