- lighthouse: TestSuppression (Widget silenced, Gadget not, nesting, stale outer ctx), TestBulkEmitsOnce, TestBroadcastEdges (zero-key batch, update actor, id-only delete, method contract, multi-channel, savepoint), TestBroadcastPublishFailure, TestFromSelectsDriver, TestMountSurfaces, TestRegistry under -race, drivers, args JSON, Bind (coverage 91.7%) - centrifugo: TestTokenClaims, TestTokenHandler, TestClientRequests, TestClientLoadConfig and a TestProxy table porting the WinterCMS WS-005, WS-007 and WS-013 cases (coverage 92.4%)
261 lines
9.4 KiB
Go
261 lines
9.4 KiB
Go
package centrifugo
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
|
)
|
|
|
|
type apiCall struct {
|
|
method, path, auth, contentType, body string
|
|
}
|
|
|
|
type fakeAPI struct {
|
|
mu sync.Mutex
|
|
calls []apiCall
|
|
status int
|
|
answer string
|
|
}
|
|
|
|
func newFakeAPI(t *testing.T) (*fakeAPI, *httptest.Server) {
|
|
t.Helper()
|
|
f := &fakeAPI{status: http.StatusOK, answer: `{}`}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, apiCall{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), string(body)})
|
|
status, answer := f.status, f.answer
|
|
f.mu.Unlock()
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, answer)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return f, srv
|
|
}
|
|
|
|
func (f *fakeAPI) set(status int, answer string) {
|
|
f.mu.Lock()
|
|
f.status, f.answer = status, answer
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
func (f *fakeAPI) take() []apiCall {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.calls
|
|
f.calls = nil
|
|
return out
|
|
}
|
|
|
|
func testClient(url, key string) *Client {
|
|
c := NewClient(Config{APIURL: url + "/api", APIKey: key}, nil)
|
|
c.now = func() time.Time { return fixedNow }
|
|
return c
|
|
}
|
|
|
|
// TestClientRequests covers RT-01 and T-11-10: exact paths and bodies,
|
|
// the apikey header, the Carbon +00:00 timestamp, [] for an empty payload,
|
|
// any 2xx as success (including an error body on publish), errors that
|
|
// never carry the key, and no request at all without a key.
|
|
func TestClientRequests(t *testing.T) {
|
|
f, srv := newFakeAPI(t)
|
|
c := testClient(srv.URL, testAPIKey)
|
|
ctx := context.Background()
|
|
|
|
if err := c.Publish(ctx, "collection:5", "created.acme.widget", json.RawMessage(`{"z":1,"a":"x/y<z>"}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Publish(ctx, "collection:5", "pinged", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Broadcast(ctx, []string{"a:1", "b:2"}, "bulk", json.RawMessage(`{"count":2}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Broadcast(ctx, nil, "bulk", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Unsubscribe(ctx, 7, "collection:5"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.set(200, `{"result":{"presence":{"c1":{"user":"7"}}}}`)
|
|
pres, err := c.Presence(ctx, "presence:room:1")
|
|
if err != nil || pres["c1"] == nil {
|
|
t.Fatalf("presence = %v, %v", pres, err)
|
|
}
|
|
f.set(200, `{"result":{"nodes":[{"name":"n1"}]}}`)
|
|
info, err := c.Info(ctx)
|
|
if err != nil || info["nodes"] == nil {
|
|
t.Fatalf("info = %v, %v", info, err)
|
|
}
|
|
want := []apiCall{
|
|
{"POST", "/api/publish", "apikey " + testAPIKey, "application/json", `{"channel":"collection:5","data":{"event":"created.acme.widget","payload":{"z":1,"a":"x/y<z>"},"timestamp":"2026-09-30T12:00:00+00:00"}}`},
|
|
{"POST", "/api/publish", "apikey " + testAPIKey, "application/json", `{"channel":"collection:5","data":{"event":"pinged","payload":[],"timestamp":"2026-09-30T12:00:00+00:00"}}`},
|
|
{"POST", "/api/broadcast", "apikey " + testAPIKey, "application/json", `{"channels":["a:1","b:2"],"data":{"event":"bulk","payload":{"count":2},"timestamp":"2026-09-30T12:00:00+00:00"}}`},
|
|
{"POST", "/api/unsubscribe", "apikey " + testAPIKey, "application/json", `{"user":"7","channel":"collection:5"}`},
|
|
{"POST", "/api/presence", "apikey " + testAPIKey, "application/json", `{"channel":"presence:room:1"}`},
|
|
{"POST", "/api/info", "apikey " + testAPIKey, "application/json", `{}`},
|
|
}
|
|
got := f.take()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("calls = %+v", got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("call %d = %+v\nwant %+v", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
t.Run("2xx_error_body_is_success_for_publish", func(t *testing.T) {
|
|
f.set(200, `{"error":{"code":102,"message":"unknown channel"}}`)
|
|
if err := c.Publish(ctx, "x:1", "e", nil); err != nil {
|
|
t.Fatalf("publish with an error body = %v, want success like WinterCMS", err)
|
|
}
|
|
if _, err := c.Info(ctx); err == nil || !strings.Contains(err.Error(), "error 102: unknown channel") {
|
|
t.Fatalf("info with an error body = %v", err)
|
|
}
|
|
f.set(200, `not json`)
|
|
if _, err := c.Info(ctx); err == nil {
|
|
t.Fatal("unreadable info answer accepted")
|
|
}
|
|
if p, err := c.Presence(ctx, "x"); err == nil || p == nil || len(p) != 0 {
|
|
t.Fatalf("unreadable presence = %v, %v", p, err)
|
|
}
|
|
f.set(202, `{}`)
|
|
if err := c.Unsubscribe(ctx, 1, "x"); err != nil {
|
|
t.Fatalf("202 = %v", err)
|
|
}
|
|
if p, err := c.Presence(ctx, "x"); err != nil || p == nil {
|
|
t.Fatalf("presence without result = %v, %v", p, err)
|
|
}
|
|
f.take()
|
|
})
|
|
|
|
t.Run("non_2xx_is_an_error_without_the_key", func(t *testing.T) {
|
|
f.set(500, `{"secret":"`+testAPIKey+`"}`)
|
|
for name, call := range map[string]func() error{
|
|
"publish": func() error { return c.Publish(ctx, "x:1", "e", nil) },
|
|
"broadcast": func() error { return c.Broadcast(ctx, []string{"x:1"}, "e", nil) },
|
|
"unsubscribe": func() error { return c.Unsubscribe(ctx, 1, "x:1") },
|
|
"presence": func() error { _, err := c.Presence(ctx, "x:1"); return err },
|
|
"info": func() error { _, err := c.Info(ctx); return err },
|
|
} {
|
|
err := call()
|
|
if err == nil || !strings.Contains(err.Error(), "HTTP 500") || strings.Contains(err.Error(), testAPIKey) {
|
|
t.Errorf("%s: err = %v", name, err)
|
|
}
|
|
}
|
|
f.take()
|
|
})
|
|
|
|
t.Run("unreachable_server", func(t *testing.T) {
|
|
dead := httptest.NewServer(http.NotFoundHandler())
|
|
url := dead.URL
|
|
dead.Close()
|
|
err := testClient(url, testAPIKey).Publish(ctx, "x:1", "e", nil)
|
|
if err == nil || strings.Contains(err.Error(), testAPIKey) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if err := testClient("http://[::1", testAPIKey).Publish(ctx, "x:1", "e", nil); err == nil {
|
|
t.Fatal("malformed URL accepted")
|
|
}
|
|
if err := testClient(srv.URL, testAPIKey).Publish(nil, "x:1", "e", json.RawMessage(`{"bad"`)); err == nil {
|
|
t.Fatal("invalid payload JSON accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("empty_key_sends_nothing", func(t *testing.T) {
|
|
off := testClient(srv.URL, "")
|
|
f.take()
|
|
if !errors.Is(off.Publish(ctx, "x", "e", nil), ErrNotConfigured) ||
|
|
!errors.Is(off.Broadcast(ctx, []string{"x"}, "e", nil), ErrNotConfigured) ||
|
|
!errors.Is(off.Unsubscribe(ctx, 1, "x"), ErrNotConfigured) {
|
|
t.Fatal("publishing without a key did not return ErrNotConfigured")
|
|
}
|
|
if p, err := off.Presence(ctx, "x"); !errors.Is(err, ErrNotConfigured) || p == nil {
|
|
t.Fatalf("presence = %v, %v", p, err)
|
|
}
|
|
if i, err := off.Info(ctx); !errors.Is(err, ErrNotConfigured) || i == nil {
|
|
t.Fatalf("info = %v, %v", i, err)
|
|
}
|
|
if n := len(f.take()); n != 0 {
|
|
t.Fatalf("%d requests sent without an API key", n)
|
|
}
|
|
if off.Enabled() || off.DebugInfo().APIKeySet || !c.DebugInfo().Enabled {
|
|
t.Fatal("Enabled/DebugInfo")
|
|
}
|
|
var nilClient *Client
|
|
if nilClient.Enabled() || nilClient.DebugInfo() != (DebugInfo{}) {
|
|
t.Fatal("nil client")
|
|
}
|
|
raw, _ := json.Marshal(c.DebugInfo())
|
|
if strings.Contains(string(raw), testAPIKey) {
|
|
t.Fatal("DebugInfo carries the key")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestClientLoadConfig covers realtime.centrifugo.* parsing and the
|
|
// driver built from it.
|
|
func TestClientLoadConfig(t *testing.T) {
|
|
def := LoadConfig(nil)
|
|
if def.APIURL != DefaultAPIURL || def.TokenTTL != DefaultTokenTTL || def.WSURL != DefaultWSURL || def.TokenPath != DefaultTokenPath || def.SubscribePath != DefaultSubscribePath {
|
|
t.Fatalf("defaults = %+v", def)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, v := range map[string]any{
|
|
"realtime.driver": "centrifugo",
|
|
"realtime.centrifugo.api_url": "http://127.0.0.1:1/api/",
|
|
"realtime.centrifugo.api_key": " k ",
|
|
"realtime.centrifugo.token_secret": "s",
|
|
"realtime.centrifugo.proxy_secret": "p",
|
|
"realtime.centrifugo.token_ttl": "2m",
|
|
"realtime.centrifugo.ws_url": "wss://rt.example.test/ws",
|
|
"realtime.centrifugo.token_path": "/rt/token",
|
|
"realtime.centrifugo.subscribe_path": "/rt/sub",
|
|
"http.trusted_proxies": []any{"10.0.0.0/8"},
|
|
} {
|
|
if err := cfg.Set(k, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got := LoadConfig(cfg)
|
|
if got.APIURL != "http://127.0.0.1:1/api" || got.APIKey != "k" || got.TokenSecret != "s" || got.ProxySecret != "p" ||
|
|
got.TokenTTL != 2*time.Minute || got.WSURL != "wss://rt.example.test/ws" || got.TokenPath != "/rt/token" || got.SubscribePath != "/rt/sub" || len(got.TrustedProxies) != 1 {
|
|
t.Fatalf("config = %+v", got)
|
|
}
|
|
svc, err := lighthouse.From(backpack.New(cfg))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, ok := svc.Driver().(*Driver)
|
|
if !ok || d.Name() != DriverName || !d.Enabled() || d.Config().TokenPath != "/rt/token" || d.Client() == nil || !d.Issuer().Configured() {
|
|
t.Fatalf("driver = %#v", svc.Driver())
|
|
}
|
|
routes := d.Routes()
|
|
if len(routes) != 2 || routes[0].Path != "/rt/token" || routes[0].Surface != lighthouse.UserAuth || routes[1].Path != "/rt/sub" || routes[1].Surface != lighthouse.ServerToServer {
|
|
t.Fatalf("routes = %+v", routes)
|
|
}
|
|
// Publish and Broadcast go through the client; an unreachable API is an
|
|
// error, not a panic.
|
|
if err := d.Publish(context.Background(), "a:1", "e", nil); err == nil {
|
|
t.Fatal("publish to an unreachable API succeeded")
|
|
}
|
|
if err := d.Broadcast(context.Background(), []string{"a:1"}, "e", nil); err == nil {
|
|
t.Fatal("broadcast to an unreachable API succeeded")
|
|
}
|
|
}
|