mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
This commit is contained in:
@@ -59,9 +59,12 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Default to current user as member; allow specifying another user/agent
|
||||
targetUserID := requestUserID(r)
|
||||
targetUserType := "member"
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
// Default target: the caller, derived via resolveActor so an agent caller
|
||||
// (X-Agent-ID set) subscribes itself rather than the underlying member.
|
||||
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
targetUserType := callerActorType
|
||||
targetUserID := callerActorID
|
||||
var req struct {
|
||||
UserID *string `json:"user_id"`
|
||||
UserType *string `json:"user_type"`
|
||||
@@ -76,7 +79,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserType = *req.UserType
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
|
||||
return
|
||||
@@ -93,9 +95,7 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
callerID := requestUserID(r)
|
||||
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
|
||||
h.publish(protocol.EventSubscriberAdded, workspaceID, callerActorType, callerActorID, map[string]any{
|
||||
"issue_id": issueID,
|
||||
"user_type": targetUserType,
|
||||
"user_id": targetUserID,
|
||||
@@ -114,8 +114,12 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := requestUserID(r)
|
||||
targetUserType := "member"
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
// Default target: the caller, derived via resolveActor so an agent caller
|
||||
// (X-Agent-ID set) unsubscribes itself rather than the underlying member.
|
||||
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
targetUserType := callerActorType
|
||||
targetUserID := callerActorID
|
||||
var req struct {
|
||||
UserID *string `json:"user_id"`
|
||||
UserType *string `json:"user_type"`
|
||||
@@ -130,7 +134,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserType = *req.UserType
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
|
||||
return
|
||||
@@ -146,9 +149,7 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
callerID := requestUserID(r)
|
||||
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{
|
||||
h.publish(protocol.EventSubscriberRemoved, workspaceID, callerActorType, callerActorID, map[string]any{
|
||||
"issue_id": issueID,
|
||||
"user_type": targetUserType,
|
||||
"user_id": targetUserID,
|
||||
|
||||
@@ -220,6 +220,78 @@ func TestSubscriberAPI(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AgentCallerSubscribesItself", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
// Look up the agent created by the handler test fixture.
|
||||
var agentID string
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe with X-Agent-ID set — no body, so the handler must default
|
||||
// to subscribing the agent itself (not the member behind X-User-ID).
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
testHandler.SubscribeToIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
agentSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "agent",
|
||||
UserID: parseUUID(agentID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (agent): %v", err)
|
||||
}
|
||||
if !agentSubscribed {
|
||||
t.Fatal("expected agent to be subscribed in DB when X-Agent-ID is set")
|
||||
}
|
||||
|
||||
memberSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "member",
|
||||
UserID: parseUUID(testUserID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (member): %v", err)
|
||||
}
|
||||
if memberSubscribed {
|
||||
t.Fatal("member must not be auto-subscribed when caller is an agent")
|
||||
}
|
||||
|
||||
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
testHandler.UnsubscribeFromIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
agentSubscribed, err = testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "agent",
|
||||
UserID: parseUUID(agentID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (agent, after unsubscribe): %v", err)
|
||||
}
|
||||
if agentSubscribed {
|
||||
t.Fatal("expected agent to be unsubscribed in DB when X-Agent-ID is set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
Reference in New Issue
Block a user