test(11-07): cover lighthouse realtime and the Centrifugo driver

- 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%)
This commit is contained in:
Jakub Zych
2026-09-30 14:21:03 +02:00
parent 35ac96d664
commit 33194a1f98
9 changed files with 1593 additions and 33 deletions

View File

@@ -0,0 +1,260 @@
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")
}
}

View File

@@ -0,0 +1,222 @@
package centrifugo
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lighthouse"
)
const (
proxyTestSecret = "proxy-secret-test-only-4b1d"
wrongSecret = "not-the-proxy-secret-9c2e"
denyBody = `{"error":{"code":403,"message":"Access denied"}}`
allowEmptyInfo = `{"result":{"info":[]}}`
)
type lockedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// acmeAuthorizer allows user 7 on acme:room:1 and records every call.
type acmeAuthorizer struct {
mu sync.Mutex
calls []string
}
func (a *acmeAuthorizer) Authorize(ctx context.Context, userID uint, channel string) lighthouse.Result {
a.mu.Lock()
a.calls = append(a.calls, strings.Join([]string{uintString(userID), channel, lighthouse.ClientID(ctx)}, "|"))
a.mu.Unlock()
switch {
case userID == 7 && (channel == "acme:room:1" || channel == "presence:acme:room:1"):
return lighthouse.Allowed(nil)
case userID == 7 && channel == "acme:room:info":
return lighthouse.Allowed(map[string]any{"role": "owner"})
case userID == 7 && channel == "acme:room:bad-info":
return lighthouse.Allowed(map[string]any{"bad": make(chan int)})
case userID == 7 && channel == "presence:acme:room:caps":
r := lighthouse.Allowed(nil)
r.Capabilities = []string{"prs", "sub"}
r.Overrides = map[string]any{"join_leave": map[string]bool{"value": false}, "zeta": 1, "alpha": "a"}
return r
case channel == "acme:room:silent":
return lighthouse.Result{}
}
return lighthouse.Denied("not a member of " + channel)
}
func (a *acmeAuthorizer) last() string {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.calls) == 0 {
return ""
}
return a.calls[len(a.calls)-1]
}
func uintString(v uint) string { return strconv.FormatUint(uint64(v), 10) }
func proxyService(t *testing.T) (*lighthouse.Service, *acmeAuthorizer, *lockedBuffer) {
t.Helper()
app := backpack.New(nil)
logs := &lockedBuffer{}
if err := app.Publish(slog.New(slog.NewJSONHandler(logs, nil))); err != nil {
t.Fatal(err)
}
svc, err := lighthouse.From(app)
if err != nil {
t.Fatal(err)
}
auth := &acmeAuthorizer{}
if err := svc.Registry().Register("acme", auth); err != nil {
t.Fatal(err)
}
return svc, auth, logs
}
func proxyCall(h http.HandlerFunc, secret *string, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body))
req.RemoteAddr = "203.0.113.9:4242"
if secret != nil {
req.Header.Set("X-Centrifugo-Secret", *secret)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func strp(s string) *string { return &s }
// TestProxy covers RT-02, T-11-01 and T-11-02, porting the WinterCMS
// websockets security tests (WS-005, WS-007, WS-013): the proxy secret is
// compared in constant time and an empty configured secret denies
// everything; empty, zero and non-scalar users deny; channels are parsed
// with the presence and segment rules and routed byte-exactly to their
// namespace authorizer with the PHP (int) user id and the client id;
// allows answer the exact info, allow and override bytes; every deny is
// the same HTTP 200 body with the reason only in the logs, and no secret
// is ever logged.
func TestProxy(t *testing.T) {
svc, auth, logs := proxyService(t)
h := ProxyHandler(svc, Config{ProxySecret: proxyTestSecret})
good := strp(proxyTestSecret)
cases := []struct {
name string
secret *string
body string
want string
reason string
authCall string
}{
{"missing_secret", nil, `{"user":"7","channel":"acme:room:1"}`, denyBody, "Invalid or missing proxy secret", ""},
{"wrong_secret", strp(wrongSecret), `{"user":"7","channel":"acme:room:1"}`, denyBody, "Invalid or missing proxy secret", ""},
{"secret_prefix", strp(proxyTestSecret[:10]), `{"user":"7","channel":"acme:room:1"}`, denyBody, "Invalid or missing proxy secret", ""},
{"malformed_json", good, `{"user":`, denyBody, "Malformed proxy request", ""},
{"empty_user", good, `{"user":"","channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"zero_user_string", good, `{"user":"0","channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"zero_user_number", good, `{"user":0,"channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"zero_user_float", good, `{"user":0.0,"channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"bool_user", good, `{"user":true,"channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"object_user", good, `{"user":{"id":7},"channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"missing_user", good, `{"channel":"acme:room:1"}`, denyBody, "Authentication required", ""},
{"missing_channel", good, `{"user":"7"}`, denyBody, "Missing channel", ""},
{"empty_channel", good, `{"user":"7","channel":""}`, denyBody, "Missing channel", ""},
{"double_presence_ws005", good, `{"user":"7","channel":"presence:presence:acme:room:1"}`, denyBody, "Unknown channel namespace", ""},
{"four_segments_ws005", good, `{"user":"7","channel":"acme:room:1:extra"}`, denyBody, "Unknown channel namespace", ""},
{"leading_colon_ws005", good, `{"user":"7","channel":":acme:room"}`, denyBody, "Unknown channel namespace", ""},
{"unknown_namespace", good, `{"user":"7","channel":"other:1"}`, denyBody, "Unknown channel namespace", ""},
{"case_mismatched_namespace", good, `{"user":"7","channel":"ACME:room:1"}`, denyBody, "Unknown channel namespace", ""},
{"allow_string_user", good, `{"user":"7","channel":"acme:room:1","client":"c-1"}`, allowEmptyInfo, "", "7|acme:room:1|c-1"},
{"allow_number_user", good, `{"user":7,"channel":"acme:room:1"}`, allowEmptyInfo, "", "7|acme:room:1|"},
{"php_int_cast_user", good, `{"user":"7abc","channel":"acme:room:1"}`, allowEmptyInfo, "", "7|acme:room:1|"},
{"negative_user_is_zero", good, `{"user":"-5","channel":"acme:room:1"}`, denyBody, "not a member of acme:room:1", "0|acme:room:1|"},
{"authorizer_deny", good, `{"user":"8","channel":"acme:room:1"}`, denyBody, "not a member of acme:room:1", "8|acme:room:1|"},
{"authorizer_deny_without_reason", good, `{"user":"8","channel":"acme:room:silent"}`, denyBody, "Access denied", "8|acme:room:silent|"},
{"allow_with_info", good, `{"user":"7","channel":"acme:room:info"}`, `{"result":{"info":{"role":"owner"}}}`, "", ""},
{"unencodable_info_denies", good, `{"user":"7","channel":"acme:room:bad-info"}`, denyBody, "", ""},
{"presence_defaults_ws013", good, `{"user":"7","channel":"presence:acme:room:1"}`,
`{"result":{"info":[],"allow":["prs"],"override":{"presence":{"value":true},"join_leave":{"value":true},"force_push_join_leave":{"value":false}}}}`, "", "7|presence:acme:room:1|"},
{"presence_override_merge", good, `{"user":"7","channel":"presence:acme:room:caps"}`,
`{"result":{"info":[],"allow":["prs","sub"],"override":{"presence":{"value":true},"join_leave":{"value":false},"force_push_join_leave":{"value":false},"alpha":"a","zeta":1}}}`, "", ""},
{"oversized_body", good, `{"user":"7","channel":"acme:room:1","pad":"` + strings.Repeat("x", 64<<10) + `"}`, denyBody, "Malformed proxy request", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := auth.last()
rec := proxyCall(h, c.secret, c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (Centrifugo reads non-200 as an internal error)", rec.Code)
}
if got := rec.Body.String(); got != c.want {
t.Fatalf("body = %s\nwant %s", got, c.want)
}
if rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-cache, private" {
t.Fatalf("headers = %v", rec.Header())
}
if c.reason != "" && !strings.Contains(logs.String(), `"reason":"`+c.reason+`"`) {
t.Fatalf("no deny log with reason %q:\n%s", c.reason, logs.String())
}
if c.authCall != "" && auth.last() != c.authCall {
t.Fatalf("authorizer call = %q, want %q", auth.last(), c.authCall)
}
if c.authCall == "" && c.want == denyBody && c.reason != "" && !strings.HasPrefix(c.reason, "not a member") && c.reason != "Access denied" && auth.last() != before {
t.Fatalf("authorizer was consulted for a request refused before it: %q", auth.last())
}
})
}
if out := logs.String(); strings.Contains(out, proxyTestSecret) || strings.Contains(out, wrongSecret) || strings.Contains(out, proxyTestSecret[:10]) {
t.Fatalf("a secret reached the logs:\n%s", out)
}
if !strings.Contains(logs.String(), `"ip":"203.0.113.9"`) {
t.Fatal("secret failures do not log the client IP")
}
t.Run("empty_configured_secret_denies_everything", func(t *testing.T) {
off := ProxyHandler(svc, Config{})
for _, secret := range []*string{nil, strp(""), strp(proxyTestSecret)} {
if rec := proxyCall(off, secret, `{"user":"7","channel":"acme:room:1"}`); rec.Body.String() != denyBody {
t.Fatalf("secret %v: body %s", secret, rec.Body.String())
}
}
})
t.Run("concurrent_subscribes", func(t *testing.T) {
var wg sync.WaitGroup
for i := range 16 {
wg.Add(1)
go func() {
defer wg.Done()
body, want := `{"user":"7","channel":"acme:room:1"}`, allowEmptyInfo
if i%2 == 1 {
body, want = `{"user":"8","channel":"acme:room:1"}`, denyBody
}
if rec := proxyCall(h, good, body); rec.Body.String() != want {
t.Errorf("concurrent %d: %s", i, rec.Body.String())
}
}()
}
wg.Wait()
})
}

View File

@@ -0,0 +1,210 @@
package centrifugo
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"github.com/golang-jwt/jwt/v5"
)
const tokenTestSecret = "test-only-centrifugo-token-secret-0123456789"
var fixedNow = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
// jwtParts returns the decoded header and claims segments of token after
// checking its HS256 signature with secret.
func jwtParts(t *testing.T, token, secret string) (string, string) {
t.Helper()
parsed, err := jwt.Parse(token, func(tok *jwt.Token) (any, error) { return []byte(secret), nil },
jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
if err != nil || !parsed.Valid {
t.Fatalf("token does not verify: %v", err)
}
seg := strings.Split(token, ".")
if len(seg) != 3 {
t.Fatalf("token has %d segments", len(seg))
}
dec := func(s string) string {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return string(b)
}
return dec(seg[0]), dec(seg[1])
}
// TestTokenClaims covers RT-01 and T-11-11: every generator signs HS256 with
// the exact WinterCMS claim set and order, the user token carries only the
// name, and an empty secret signs nothing.
func TestTokenClaims(t *testing.T) {
iss := NewTokenIssuer(tokenTestSecret, time.Hour)
iss.Now = func() time.Time { return fixedNow }
exp := fixedNow.Add(time.Hour).Unix()
name := "Ann"
cases := []struct {
name string
sign func() (string, error)
want string
}{
{"for_user", func() (string, error) { return iss.ForUser(lighthouse.User{ID: 7, Name: &name}) },
fmt.Sprintf(`{"sub":"7","exp":%d,"info":{"name":"Ann"}}`, exp)},
{"for_user_null_name", func() (string, error) { return iss.ForUser(lighthouse.User{ID: 8}) },
fmt.Sprintf(`{"sub":"8","exp":%d,"info":{"name":null}}`, exp)},
{"subscription", func() (string, error) { return iss.Subscription(lighthouse.User{ID: 7}, "collection:5") },
fmt.Sprintf(`{"sub":"7","channel":"collection:5","exp":%d}`, exp)},
{"anonymous", iss.Anonymous,
fmt.Sprintf(`{"sub":"","exp":%d}`, fixedNow.Add(5*time.Minute).Unix())},
{"for_identifier_empty_info", func() (string, error) { return iss.ForIdentifier("kiosk-1", nil) },
fmt.Sprintf(`{"sub":"kiosk-1","exp":%d,"info":[]}`, exp)},
{"for_identifier_info", func() (string, error) { return iss.ForIdentifier("kiosk-1", map[string]any{"room": "a/b"}) },
fmt.Sprintf(`{"sub":"kiosk-1","exp":%d,"info":{"room":"a/b"}}`, exp)},
{"subscription_for_identifier", func() (string, error) { return iss.SubscriptionForIdentifier("kiosk-1", "room:1") },
fmt.Sprintf(`{"sub":"kiosk-1","channel":"room:1","exp":%d}`, exp)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tok, err := c.sign()
if err != nil {
t.Fatal(err)
}
header, claims := jwtParts(t, tok, tokenTestSecret)
if header != `{"alg":"HS256","typ":"JWT"}` {
t.Fatalf("header = %s", header)
}
if claims != c.want {
t.Fatalf("claims = %s, want %s", claims, c.want)
}
})
}
empty := NewTokenIssuer("", 0)
if empty.Configured() || empty.ttl != DefaultTokenTTL {
t.Fatalf("empty issuer configured=%v ttl=%s", empty.Configured(), empty.ttl)
}
for name, sign := range map[string]func() (string, error){
"ForUser": func() (string, error) { return empty.ForUser(lighthouse.User{ID: 1}) },
"Subscription": func() (string, error) { return empty.Subscription(lighthouse.User{ID: 1}, "a:1") },
"Anonymous": empty.Anonymous,
"ForIdentifier": func() (string, error) { return empty.ForIdentifier("x", nil) },
"SubscriptionForIdentifier": func() (string, error) { return empty.SubscriptionForIdentifier("x", "a:1") },
} {
if tok, err := sign(); !errors.Is(err, ErrNotConfigured) || tok != "" {
t.Errorf("%s with an empty secret = %q, %v; want ErrNotConfigured", name, tok, err)
}
}
var nilIss *TokenIssuer
if nilIss.Configured() {
t.Fatal("nil issuer is configured")
}
if _, err := iss.ForIdentifier("x", map[string]any{"bad": make(chan int)}); err == nil {
t.Fatal("unencodable info accepted")
}
// The default clock is used when Now is nil.
live := NewTokenIssuer(tokenTestSecret, time.Minute)
tok, err := live.Anonymous()
if err != nil {
t.Fatal(err)
}
if _, claims := jwtParts(t, tok, tokenTestSecret); !strings.Contains(claims, `"exp":`) {
t.Fatalf("claims = %s", claims)
}
}
func tokenService(t *testing.T, lookup lighthouse.UserLookup) *lighthouse.Service {
t.Helper()
svc, err := lighthouse.From(backpack.New(nil))
if err != nil {
t.Fatal(err)
}
svc.SetUserLookup(lookup)
return svc
}
// TestTokenHandler covers RT-01: 401 without a principal or user, 503 with
// an empty secret only after the user check, and a 200 {"token"} body with
// the Laravel JSON headers and no trailing newline, safe under concurrency.
func TestTokenHandler(t *testing.T) {
name := "Ann"
svc := tokenService(t, func(_ context.Context, id uint) (lighthouse.User, bool, error) {
switch id {
case 7:
return lighthouse.User{ID: 7, Name: &name}, true, nil
case 9:
return lighthouse.User{}, false, errors.New("database down")
}
return lighthouse.User{}, false, nil
})
iss := NewTokenIssuer(tokenTestSecret, time.Hour)
h := TokenHandler(svc, iss)
noSecret := TokenHandler(svc, NewTokenIssuer("", time.Hour))
call := func(h http.HandlerFunc, p *bouncer.Principal) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/realtime/token", nil)
if p != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), p))
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
cases := []struct {
name string
h http.HandlerFunc
p *bouncer.Principal
status int
body string
}{
{"no_principal", h, nil, 401, `{"error":"Unauthorized"}`},
{"zero_id", h, &bouncer.Principal{ID: 0}, 401, `{"error":"Unauthorized"}`},
{"unknown_user", h, &bouncer.Principal{ID: 5}, 401, `{"error":"Unauthorized"}`},
{"lookup_error", h, &bouncer.Principal{ID: 9}, 401, `{"error":"Unauthorized"}`},
{"unknown_user_before_secret", noSecret, &bouncer.Principal{ID: 5}, 401, `{"error":"Unauthorized"}`},
{"empty_secret", noSecret, &bouncer.Principal{ID: 7}, 503, `{"error":"WebSocket not configured"}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := call(c.h, c.p)
if rec.Code != c.status || rec.Body.String() != c.body {
t.Fatalf("got %d %s, want %d %s", rec.Code, rec.Body.String(), c.status, c.body)
}
if rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-cache, private" {
t.Fatalf("headers = %v", rec.Header())
}
})
}
t.Run("ok", func(t *testing.T) {
rec := call(h, &bouncer.Principal{ID: 7})
body := rec.Body.String()
if rec.Code != 200 || !strings.HasPrefix(body, `{"token":"`) || !strings.HasSuffix(body, `"}`) || strings.HasSuffix(body, "\n") {
t.Fatalf("got %d %q", rec.Code, body)
}
tok := strings.TrimSuffix(strings.TrimPrefix(body, `{"token":"`), `"}`)
if _, claims := jwtParts(t, tok, tokenTestSecret); !strings.HasPrefix(claims, `{"sub":"7","exp":`) || !strings.HasSuffix(claims, `,"info":{"name":"Ann"}}`) {
t.Fatalf("claims = %s", claims)
}
})
t.Run("concurrent", func(t *testing.T) {
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
if rec := call(h, &bouncer.Principal{ID: 7}); rec.Code != 200 {
t.Errorf("concurrent status %d", rec.Code)
}
}()
}
wg.Wait()
})
}