mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
130 lines
5.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|