192 lines
7.0 KiB
Go
192 lines
7.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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.
|
|
// ConversationRule delegates to the chat service's membership check, so the
|
|
// realtime channel and the REST endpoints can never disagree about who is in a
|
|
// thread.
|
|
func TestConversationRule(t *testing.T) {
|
|
const member, outsider = "member-1", "outsider-1"
|
|
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
|
|
if conversationID == "boom" {
|
|
return false, errors.New("database down")
|
|
}
|
|
return userID == member && conversationID == "conv-1", nil
|
|
})
|
|
|
|
if rule.Prefix != "private-conversation." {
|
|
t.Errorf("prefix = %q", rule.Prefix)
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
user string
|
|
suffix string
|
|
wantOK bool
|
|
wantErr bool
|
|
}{
|
|
{"participant", member, "conv-1", true, false},
|
|
{"outsider", outsider, "conv-1", false, false},
|
|
{"participant of another thread", member, "conv-2", false, false},
|
|
{"empty conversation id", member, "", false, false},
|
|
{"lookup failure is not a denial", member, "boom", false, true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
|
|
if ok != tc.wantOK || (err != nil) != tc.wantErr {
|
|
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|