Files
spsays-bff/internal/handler/broadcasting_auth_test.go
T

153 lines
5.8 KiB
Go

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 <key>:<signature> 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)
}
}