package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "go.uber.org/zap" ) const testUser = "user-a" func testAuthHandler(t *testing.T) *BroadcastingAuthHandler { t.Helper() rt := realtime.NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop()) if rt == nil { t.Fatal("realtime client is nil") } // A second rule stands in for the chat conversation channels, proving the // rule table is what decides access rather than anything hardcoded. convRule := ChannelRule{ Prefix: "presence-conversation.", Authorize: func(_ context.Context, userID, suffix string) (bool, error) { return suffix == "open-to-user-a" && userID == testUser, nil }, } return NewBroadcastingAuthHandler(rt, zap.NewNop(), NotificationsRule(), convRule) } // serve posts a form-encoded auth request as userID ("" = unauthenticated). func serve(t *testing.T, h *BroadcastingAuthHandler, userID, socketID, channel string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"socket_id": {socketID}, "channel_name": {channel}} req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if userID != "" { req = req.WithContext(middleware.WithUserID(req.Context(), userID)) } rec := httptest.NewRecorder() h.Handle(rec, req) return rec } func TestBroadcastingAuth_Authorization(t *testing.T) { h := testAuthHandler(t) tests := []struct { name string userID string channel string want int }{ {"own notifications channel", testUser, "private-notifications." + testUser, http.StatusOK}, {"another user's notifications", testUser, "private-notifications.user-b", http.StatusForbidden}, {"unauthenticated", "", "private-notifications." + testUser, http.StatusUnauthorized}, {"unknown channel family", testUser, "private-secrets.42", http.StatusForbidden}, {"legacy public channel is not signable", testUser, "notifications." + testUser, http.StatusForbidden}, {"empty suffix", testUser, "private-notifications.", http.StatusForbidden}, {"presence channel allowed by rule", testUser, "presence-conversation.open-to-user-a", http.StatusOK}, {"presence channel denied by rule", testUser, "presence-conversation.someone-elses", http.StatusForbidden}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rec := serve(t, h, tc.userID, "1234.5678", tc.channel) if rec.Code != tc.want { t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String()) } }) } } // A prefix match must not be mistaken for authorization: "…user-a-evil" starts // with the victim's id but is a different channel. func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) { rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil") if rec.Code != http.StatusForbidden { t.Errorf("status = %d, want 403", rec.Code) } } func TestBroadcastingAuth_SignatureShape(t *testing.T) { rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { Auth string `json:"auth"` ChannelData string `json:"channel_data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if !strings.HasPrefix(body.Auth, "test-key:") { t.Errorf("auth = %q, want a : pair", body.Auth) } // channel_data belongs to presence channels only; sending it for a private // channel would make Soketi reject the subscription. if body.ChannelData != "" { t.Errorf("channel_data = %q, want empty for a private channel", body.ChannelData) } } func TestBroadcastingAuth_PresenceReturnsChannelData(t *testing.T) { rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "presence-conversation.open-to-user-a") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { ChannelData string `json:"channel_data"` } _ = json.Unmarshal(rec.Body.Bytes(), &body) if !strings.Contains(body.ChannelData, `"user_id":"`+testUser+`"`) { t.Errorf("channel_data = %q, want the caller's user_id", body.ChannelData) } } func TestBroadcastingAuth_AcceptsJSONBody(t *testing.T) { body := `{"socket_id":"1234.5678","channel_name":"private-notifications.` + testUser + `"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(middleware.WithUserID(req.Context(), testUser)) rec := httptest.NewRecorder() testAuthHandler(t).Handle(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } } func TestBroadcastingAuth_MissingParams(t *testing.T) { h := testAuthHandler(t) if rec := serve(t, h, testUser, "", "private-notifications."+testUser); rec.Code != http.StatusBadRequest { t.Errorf("missing socket_id: status = %d, want 400", rec.Code) } if rec := serve(t, h, testUser, "1234.5678", ""); rec.Code != http.StatusBadRequest { t.Errorf("missing channel_name: status = %d, want 400", rec.Code) } } // With realtime disabled there is no secret to sign with; the endpoint must // say so rather than hand back an unusable signature. func TestBroadcastingAuth_UnconfiguredRealtime(t *testing.T) { h := NewBroadcastingAuthHandler(nil, zap.NewNop(), NotificationsRule()) if rec := serve(t, h, testUser, "1234.5678", "private-notifications."+testUser); rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rec.Code) } }