Files
multica/server/internal/handler/subscriber_test.go
devv-eve 8c0708bb5d fix(server): validate workspace membership for subscriptions and uploads (#935)
* fix(server): validate workspace membership for subscription targets and file uploads

Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(server): add negative tests for cross-workspace subscription and upload

Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
  subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
  unknown user types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:22:03 -07:00

255 lines
7.6 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestSubscriberAPI(t *testing.T) {
ctx := context.Background()
// Helper: create an issue for subscriber tests
createIssue := func(t *testing.T) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Subscriber test issue",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
return issue.ID
}
// Helper: delete an issue
deleteIssue := func(t *testing.T, issueID string) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(w, req)
}
t.Run("Subscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]bool
json.NewDecoder(w.Body).Decode(&resp)
if !resp["subscribed"] {
t.Fatal("SubscribeToIssue: expected subscribed=true")
}
// Verify in DB
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(testUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
}
if !subscribed {
t.Fatal("expected user to be subscribed in DB")
}
})
t.Run("SubscribeIdempotent", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
// Subscribe first time
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue (1st): expected 200, got %d: %s", w.Code, w.Body.String())
}
// Subscribe second time — should also succeed
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue (2nd): expected 200, got %d: %s", w.Code, w.Body.String())
}
})
t.Run("ListSubscribers", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
// Subscribe first
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
// List
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueSubscribers(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
}
var subscribers []SubscriberResponse
json.NewDecoder(w.Body).Decode(&subscribers)
if len(subscribers) == 0 {
t.Fatal("ListIssueSubscribers: expected at least 1 subscriber")
}
found := false
for _, s := range subscribers {
if s.UserID == testUserID && s.UserType == "member" && s.Reason == "manual" {
found = true
break
}
}
if !found {
t.Fatalf("ListIssueSubscribers: expected to find test user subscriber, got %+v", subscribers)
}
})
t.Run("Unsubscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
// Subscribe first
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Unsubscribe
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]bool
json.NewDecoder(w.Body).Decode(&resp)
if resp["subscribed"] {
t.Fatal("UnsubscribeFromIssue: expected subscribed=false")
}
// Verify in DB
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(testUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
}
if subscribed {
t.Fatal("expected user to NOT be subscribed in DB")
}
})
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(foreignUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
}
if subscribed {
t.Fatal("cross-workspace user should NOT be subscribed in DB")
}
})
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
})
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
// Subscribe
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
// Unsubscribe
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
req = withURLParam(req, "id", issueID)
testHandler.UnsubscribeFromIssue(w, req)
// List should be empty
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueSubscribers(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
}
var subscribers []SubscriberResponse
json.NewDecoder(w.Body).Decode(&subscribers)
if len(subscribers) != 0 {
t.Fatalf("ListIssueSubscribers: expected 0 subscribers after unsubscribe, got %d", len(subscribers))
}
})
}