snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
747
internal/api/api_test.go
Normal file
747
internal/api/api_test.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/metrics"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Initialize metrics with a custom registry for tests to avoid conflicts
|
||||
registry := prometheus.NewRegistry()
|
||||
_ = metrics.Init(metrics.Config{
|
||||
Registerer: registry,
|
||||
})
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func nodeWithMemoryEngine() *centrifuge.Node {
|
||||
n, err := centrifuge.New(centrifuge.Config{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = n.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type testSurveyCaller struct{}
|
||||
|
||||
func (t testSurveyCaller) Channels(_ context.Context, _ *ChannelsRequest) (map[string]*ChannelInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (t testSurveyCaller) Connections(_ context.Context, _ *ConnectionsRequest) (map[string]*ConnectionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPublishAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Publish(context.Background(), &PublishRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test"})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test", Data: []byte("test")})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test:test", Data: []byte("test")})
|
||||
require.Equal(t, ErrorUnknownChannel, resp.Error)
|
||||
}
|
||||
|
||||
// TestBatchAPINilCommand guards against a crash on a nil batch command (e.g. a
|
||||
// JSON `null` array element). In parallel mode each command runs in its own
|
||||
// goroutine, so a nil-deref would escape net/http's recover and crash the node.
|
||||
func TestBatchAPINilCommand(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
for _, parallel := range []bool{false, true} {
|
||||
resp := api.Batch(context.Background(), &BatchRequest{
|
||||
Parallel: parallel,
|
||||
Commands: []*Command{
|
||||
nil,
|
||||
{Publish: &PublishRequest{Channel: "test", Data: []byte("x")}},
|
||||
},
|
||||
})
|
||||
require.Len(t, resp.Replies, 2)
|
||||
require.Equal(t, ErrorBadRequest, resp.Replies[0].Error)
|
||||
require.Nil(t, resp.Replies[1].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Broadcast(context.Background(), &BroadcastRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
|
||||
// Empty data validation now happens per-channel
|
||||
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
|
||||
|
||||
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}, Data: []byte("test")})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test:test"}, Data: []byte("test")})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[0].Error)
|
||||
|
||||
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test", "test:test"}, Data: []byte("test")})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[1].Error)
|
||||
}
|
||||
|
||||
func TestPublishAPI_DataFormat_JSON(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// Valid JSON should succeed
|
||||
resp := api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`{"key": "value"}`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Valid JSON array should succeed
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`[1, 2, 3]`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Valid JSON primitives should succeed
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`"string"`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`123`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`true`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`null`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Invalid JSON should fail
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte(`not valid json`),
|
||||
})
|
||||
require.NotNil(t, resp.Error)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
|
||||
|
||||
// Empty data with JSON format should fail
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte{},
|
||||
})
|
||||
require.NotNil(t, resp.Error)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
|
||||
}
|
||||
|
||||
func TestPublishAPI_DataFormat_Binary(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// Binary data should succeed
|
||||
resp := api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte{0x01, 0x02, 0x03},
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Empty data with binary format should succeed
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte{},
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Any data should succeed with binary format
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte("any text data"),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestPublishAPI_DataFormat_Default(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
// Default format (empty string) - should reject empty data
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// Non-empty data should succeed
|
||||
resp := api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte("any data"),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
// Empty data should fail (current behavior preserved)
|
||||
resp = api.Publish(context.Background(), &PublishRequest{
|
||||
Channel: "test",
|
||||
Data: []byte{},
|
||||
})
|
||||
require.NotNil(t, resp.Error)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
|
||||
}
|
||||
|
||||
func TestBroadcastAPI_DataFormat_JSON(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// Valid JSON should succeed
|
||||
resp := api.Broadcast(context.Background(), &BroadcastRequest{
|
||||
Channels: []string{"test", "test2"},
|
||||
Data: []byte(`{"key": "value"}`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Nil(t, resp.Result.Responses[0].Error)
|
||||
require.Nil(t, resp.Result.Responses[1].Error)
|
||||
|
||||
// Invalid JSON should fail for all channels
|
||||
resp = api.Broadcast(context.Background(), &BroadcastRequest{
|
||||
Channels: []string{"test", "test2"},
|
||||
Data: []byte(`not valid json`),
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
|
||||
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[1].Error.Code)
|
||||
}
|
||||
|
||||
func TestBroadcastAPI_DataFormat_Binary(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// Empty data should succeed with binary format
|
||||
resp := api.Broadcast(context.Background(), &BroadcastRequest{
|
||||
Channels: []string{"test", "test2"},
|
||||
Data: []byte{},
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Nil(t, resp.Result.Responses[0].Error)
|
||||
require.Nil(t, resp.Result.Responses[1].Error)
|
||||
}
|
||||
|
||||
func TestHistoryAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.History(context.Background(), &HistoryRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
|
||||
require.Equal(t, ErrorNotAvailable, resp.Error)
|
||||
|
||||
cfg = cfgContainer.Config()
|
||||
cfg.Channel.WithoutNamespace.HistorySize = 1
|
||||
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(1 * time.Second)
|
||||
_ = cfgContainer.Reload(cfg)
|
||||
|
||||
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestHistoryRemoveAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.HistoryRemove(context.Background(), &HistoryRemoveRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
|
||||
require.Equal(t, ErrorNotAvailable, resp.Error)
|
||||
|
||||
cfg = cfgContainer.Config()
|
||||
cfg.Channel.WithoutNamespace.HistorySize = 1
|
||||
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(time.Second)
|
||||
_ = cfgContainer.Reload(cfg)
|
||||
|
||||
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestPresenceAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Presence(context.Background(), &PresenceRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
|
||||
|
||||
require.Equal(t, ErrorNotAvailable, resp.Error)
|
||||
|
||||
cfg = cfgContainer.Config()
|
||||
cfg.Channel.WithoutNamespace.Presence = true
|
||||
_ = cfgContainer.Reload(cfg)
|
||||
|
||||
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestPresenceStatsAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.PresenceStats(context.Background(), &PresenceStatsRequest{})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
|
||||
require.Equal(t, ErrorNotAvailable, resp.Error)
|
||||
|
||||
cfg = cfgContainer.Config()
|
||||
cfg.Channel.WithoutNamespace.Presence = true
|
||||
_ = cfgContainer.Reload(cfg)
|
||||
|
||||
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestDisconnectAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Disconnect(context.Background(), &DisconnectRequest{
|
||||
User: "test",
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestUnsubscribeAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Unsubscribe(context.Background(), &UnsubscribeRequest{
|
||||
User: "test",
|
||||
Channel: "test",
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestRefreshAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Refresh(context.Background(), &RefreshRequest{
|
||||
User: "test",
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestSubscribeAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Subscribe(context.Background(), &SubscribeRequest{
|
||||
User: "test",
|
||||
Channel: "test",
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestInfoAPI(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Info(context.Background(), &InfoRequest{})
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
|
||||
// configWithNamespace returns a DefaultConfig with a single namespace having the
|
||||
// given name and subscription_type. Used to test the subscription_type gates on
|
||||
// publish/map_*/shared_poll_* API endpoints. Fills the minimum mandatory fields
|
||||
// (map.mode for map types, global shared_poll.hmac_secret_key for shared_poll)
|
||||
// so the config passes Validate().
|
||||
func configWithNamespace(name, subscriptionType string) config.Config {
|
||||
cfg := config.DefaultConfig()
|
||||
chOpts := configtypes.ChannelOptions{
|
||||
SubscriptionType: subscriptionType,
|
||||
}
|
||||
switch subscriptionType {
|
||||
case "map":
|
||||
// Persistent mode requires no key_ttl and is the simplest minimum.
|
||||
chOpts.Map = configtypes.MapConfig{Mode: "persistent"}
|
||||
case "map_clients", "map_users":
|
||||
// Persistent mode is forbidden here (presence entries need TTL-based
|
||||
// cleanup), so use ephemeral with a key_ttl.
|
||||
chOpts.Map = configtypes.MapConfig{
|
||||
Mode: "ephemeral",
|
||||
KeyTTL: configtypes.Duration(time.Minute),
|
||||
}
|
||||
case "shared_poll":
|
||||
// Global HMAC secret required for any shared_poll namespace.
|
||||
cfg.SharedPoll.HMACSecretKey = "test-secret"
|
||||
// Default refresh proxy must have an endpoint when no explicit
|
||||
// proxy_name is set on the namespace.
|
||||
cfg.Channel.Proxy.SharedPollRefresh = configtypes.Proxy{
|
||||
Endpoint: "http://127.0.0.1:0/test",
|
||||
Timeout: configtypes.Duration(time.Second),
|
||||
}
|
||||
chOpts.SharedPoll = configtypes.SharedPollConfig{Mode: "versioned"}
|
||||
}
|
||||
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
|
||||
{
|
||||
Name: name,
|
||||
ChannelOptions: chOpts,
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestPublishAPI_RejectsMapAndSharedPollNamespaces ensures the stream Publish
|
||||
// endpoint rejects calls aimed at namespaces configured for map or shared poll
|
||||
// subscriptions — those have their own dedicated APIs.
|
||||
func TestPublishAPI_RejectsMapAndSharedPollNamespaces(t *testing.T) {
|
||||
cases := []struct {
|
||||
subscriptionType string
|
||||
}{
|
||||
{"map"},
|
||||
{"map_clients"},
|
||||
{"map_users"},
|
||||
{"shared_poll"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.subscriptionType, func(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := configWithNamespace("ns", c.subscriptionType)
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.Publish(context.Background(), &PublishRequest{Channel: "ns:test", Data: []byte("data")})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapPublishAPI_RejectsNonMapNamespaces ensures map_publish rejects calls
|
||||
// against namespaces that aren't configured for map subscriptions.
|
||||
func TestMapPublishAPI_RejectsNonMapNamespaces(t *testing.T) {
|
||||
cases := []struct {
|
||||
subscriptionType string
|
||||
}{
|
||||
{""},
|
||||
{"stream"},
|
||||
{"shared_poll"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run("type="+c.subscriptionType, func(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := configWithNamespace("ns", c.subscriptionType)
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.MapPublish(context.Background(), &MapPublishRequest{
|
||||
Channel: "ns:test",
|
||||
Key: "k",
|
||||
Data: []byte(`{"v":1}`),
|
||||
})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapPublishAPI_RejectsInvalidKeyMode ensures key_mode is validated against
|
||||
// the allowed set ("", "if_new", "if_exists").
|
||||
func TestMapPublishAPI_RejectsInvalidKeyMode(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := configWithNamespace("ns", "map")
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
resp := api.MapPublish(context.Background(), &MapPublishRequest{
|
||||
Channel: "ns:test",
|
||||
Key: "k",
|
||||
Data: []byte(`{"v":1}`),
|
||||
KeyMode: "bogus",
|
||||
})
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
}
|
||||
|
||||
// TestMapAPIs_RejectNonMapNamespaces ensures the other map API endpoints
|
||||
// (remove / read_state / read_stream / stats / clear) reject calls against
|
||||
// namespaces that aren't configured for map subscriptions, instead of leaking
|
||||
// internal errors from the broker.
|
||||
func TestMapAPIs_RejectNonMapNamespaces(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := configWithNamespace("ns", "") // default = stream
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "ns:test", Key: "k"})
|
||||
require.Equal(t, ErrorBadRequest, rmResp.Error)
|
||||
|
||||
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "ns:test"})
|
||||
require.Equal(t, ErrorBadRequest, rsResp.Error)
|
||||
|
||||
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "ns:test"})
|
||||
require.Equal(t, ErrorBadRequest, rstrResp.Error)
|
||||
|
||||
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "ns:test"})
|
||||
require.Equal(t, ErrorBadRequest, stResp.Error)
|
||||
|
||||
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "ns:test"})
|
||||
require.Equal(t, ErrorBadRequest, clResp.Error)
|
||||
}
|
||||
|
||||
// TestMapAPIs_RejectUnknownChannel ensures the map API endpoints return
|
||||
// ErrorUnknownChannel instead of leaking internal errors when the channel's
|
||||
// namespace doesn't exist.
|
||||
func TestMapAPIs_RejectUnknownChannel(t *testing.T) {
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
|
||||
|
||||
// "missing:test" — namespace "missing" not configured.
|
||||
pubResp := api.MapPublish(context.Background(), &MapPublishRequest{Channel: "missing:test", Key: "k", Data: []byte(`{}`)})
|
||||
require.Equal(t, ErrorUnknownChannel, pubResp.Error)
|
||||
|
||||
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "missing:test", Key: "k"})
|
||||
require.Equal(t, ErrorUnknownChannel, rmResp.Error)
|
||||
|
||||
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "missing:test"})
|
||||
require.Equal(t, ErrorUnknownChannel, rsResp.Error)
|
||||
|
||||
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "missing:test"})
|
||||
require.Equal(t, ErrorUnknownChannel, rstrResp.Error)
|
||||
|
||||
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "missing:test"})
|
||||
require.Equal(t, ErrorUnknownChannel, stResp.Error)
|
||||
|
||||
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "missing:test"})
|
||||
require.Equal(t, ErrorUnknownChannel, clResp.Error)
|
||||
}
|
||||
|
||||
// nodeWithMapSupport builds a node able to actually serve map operations for
|
||||
// channels of the given config container, so that valid publishes really reach
|
||||
// the map broker instead of failing on options resolution.
|
||||
func nodeWithMapSupport(cfgContainer *config.Container) *centrifuge.Node {
|
||||
n, err := centrifuge.New(centrifuge.Config{
|
||||
Map: centrifuge.MapConfig{
|
||||
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
|
||||
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
|
||||
if err != nil || !ok {
|
||||
return centrifuge.MapChannelOptions{}
|
||||
}
|
||||
var mode centrifuge.MapMode
|
||||
switch chOpts.Map.Mode {
|
||||
case "ephemeral":
|
||||
mode = centrifuge.MapModeEphemeral
|
||||
case "recoverable":
|
||||
mode = centrifuge.MapModeRecoverable
|
||||
case "persistent":
|
||||
mode = centrifuge.MapModePersistent
|
||||
}
|
||||
return centrifuge.MapChannelOptions{Mode: mode, KeyTTL: chOpts.Map.KeyTTL.ToDuration()}
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = n.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestMapPublishAPI_DataFormat ensures map_publish applies the namespace
|
||||
// publication_data_format policy, same as the stream Publish endpoint does.
|
||||
func TestMapPublishAPI_DataFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
format string
|
||||
data []byte
|
||||
b64Data string
|
||||
wantReject bool
|
||||
}{
|
||||
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), "", false},
|
||||
{configtypes.PublicationDataFormatJSONObject, []byte(`[1,2]`), "", true},
|
||||
{configtypes.PublicationDataFormatJSONObject, []byte(`not-json-object`), "", true},
|
||||
{configtypes.PublicationDataFormatJSON, []byte(`[1,2]`), "", false},
|
||||
{configtypes.PublicationDataFormatJSON, []byte(`nope`), "", true},
|
||||
{configtypes.PublicationDataFormatBinary, nil, "", false},
|
||||
{"", []byte(`whatever`), "", false},
|
||||
{"", nil, "", true}, // default format rejects empty data
|
||||
// b64data must be validated after decoding, not before.
|
||||
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`{"v":1}`)), false},
|
||||
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`nope`)), true},
|
||||
}
|
||||
for i, c := range cases {
|
||||
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
|
||||
cfg := configWithNamespace("ns", "map")
|
||||
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
node := nodeWithMapSupport(cfgContainer)
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
|
||||
resp := api.MapPublish(context.Background(), &MapPublishRequest{
|
||||
Channel: "ns:test", Key: "k", Data: c.data, B64Data: c.b64Data,
|
||||
})
|
||||
if c.wantReject {
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
} else {
|
||||
require.Nil(t, resp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedPollPublishAPI_DataFormat ensures shared_poll_publish applies the
|
||||
// namespace publication_data_format policy.
|
||||
func TestSharedPollPublishAPI_DataFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
format string
|
||||
data []byte
|
||||
wantReject bool
|
||||
}{
|
||||
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), false},
|
||||
{configtypes.PublicationDataFormatJSONObject, []byte(`nope`), true},
|
||||
{configtypes.PublicationDataFormatJSON, []byte(`nope`), true},
|
||||
{configtypes.PublicationDataFormatBinary, nil, false},
|
||||
{"", nil, true}, // default format rejects empty data
|
||||
}
|
||||
for i, c := range cases {
|
||||
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
|
||||
cfg := configWithNamespace("ns", "shared_poll")
|
||||
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
|
||||
cfg.Channel.Namespaces[0].SharedPoll.PublishEnabled = true
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
node := nodeWithMemoryEngine()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
|
||||
resp := api.SharedPollPublish(context.Background(), &SharedPollPublishRequest{
|
||||
Channel: "ns:test", Key: "k", Version: 1, Data: c.data,
|
||||
})
|
||||
if c.wantReject {
|
||||
require.Equal(t, ErrorBadRequest, resp.Error)
|
||||
} else {
|
||||
// The test node has no shared poll manager, so a valid payload
|
||||
// gets past validation and fails later — the point is that it is
|
||||
// not rejected as a bad request.
|
||||
require.NotEqual(t, ErrorBadRequest, resp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user