diff --git a/server/internal/handler/subscriber.go b/server/internal/handler/subscriber.go index a97363015..ee2996a41 100644 --- a/server/internal/handler/subscriber.go +++ b/server/internal/handler/subscriber.go @@ -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, diff --git a/server/internal/handler/subscriber_test.go b/server/internal/handler/subscriber_test.go index b73c32d6c..6ac12bc72 100644 --- a/server/internal/handler/subscriber_test.go +++ b/server/internal/handler/subscriber_test.go @@ -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)