feat(11-03): re-authorize every Centrifugo subscribe through a namespace registry

- lighthouse: Registry of namespace authorizers (Result, Allowed, Denied),
  ParseChannel, ChannelID with PHP (int)-cast semantics (PHPInt, pinned by
  a php -r table test), FormatChannels, WithClientID/ClientID
- centrifugo: ProxyHandler (constant-time X-Centrifugo-Secret, HTTP 200
  generic deny, info [] on allow, presence allow/override merge, 64 KiB
  body cap) mounted as the ServerToServer subscribe route
- README: proxy contract, registry and channel rules
This commit is contained in:
Jakub Zych
2026-09-30 12:29:09 +02:00
parent cada7a4442
commit 79fd705680
8 changed files with 699 additions and 11 deletions

View File

@@ -0,0 +1,120 @@
package lighthouse
import (
"context"
"math"
"reflect"
"testing"
)
// TestChannelIDMatchesPHP pins ChannelID to PHP's (int) cast of the id
// segment. The expected values were printed by PHP 8.5.10:
//
// php -r 'foreach ([...] as $s) var_dump((int)$s);'
func TestChannelIDMatchesPHP(t *testing.T) {
cases := []struct {
segment string
want int64
}{
{"5", 5}, {"05", 5}, {" 5", 5}, {"5abc", 5}, {"abc", 0}, {"-3", -3}, {"", 0},
{"1e3", 1000}, {"0x1A", 0}, {"9999999999999999999", math.MaxInt64},
{"\t5", 5}, {"\n5", 5}, {"5 ", 5}, {"+5", 5}, {" -7x", -7}, {"1.9", 1}, {"-0", 0},
{"1e20", math.MaxInt64}, {"0b11", 0}, {"٥", 0}, {" ", 0},
{"-9999999999999999999", math.MinInt64}, {"-1e20", math.MinInt64}, {"1e-3", 0},
{".5", 0}, {"5.", 5}, {"1e", 1}, {"1e+2", 100}, {"\v5", 5}, {"\f5", 5}, {"\r5", 5},
{"NaN", 0}, {"INF", 0}, {"1E3", 1000}, {"9223372036854775807", math.MaxInt64},
{"9223372036854775808", math.MaxInt64}, {"1.5e3", 1500}, {"-.5e1", -5}, {"0005", 5},
{"00", 0}, {"- 5", 0}, {"5e3abc", 5000}, {"12.7e1x", 127}, {"1e400", 0},
}
for _, tc := range cases {
if got := ChannelID("collection:" + tc.segment); got != tc.want {
t.Errorf("ChannelID(collection:%q) = %d, want %d", tc.segment, got, tc.want)
}
}
if got := ChannelID("collection"); got != 0 {
t.Errorf("missing segment = %d, want 0", got)
}
if got := ChannelID("presence:collection:5"); got != 0 {
t.Errorf("presence:collection:5 = %d, want 0 (segment 1 is \"collection\")", got)
}
if got := ChannelID("wishlist:12:extra"); got != 12 {
t.Errorf("wishlist:12:extra = %d, want 12", got)
}
}
func TestParseChannel(t *testing.T) {
cases := []struct {
channel string
namespace string
presence bool
}{
{"collection:5", "collection", false},
{"ns:entity:id", "ns", false},
{"presence:acme:room:1", "acme", true},
{"presence:collection:5", "collection", true},
{"presence:presence:x", "", true},
{"a:b:c:d", "", false},
{"presence:a:b:c:d", "", true},
{"", "", false},
{":5", "", false},
{"Collection:5", "Collection", false},
}
for _, tc := range cases {
ns, presence := ParseChannel(tc.channel)
if ns != tc.namespace || presence != tc.presence {
t.Errorf("ParseChannel(%q) = (%q, %t), want (%q, %t)", tc.channel, ns, presence, tc.namespace, tc.presence)
}
}
}
func TestFormatChannels(t *testing.T) {
got := FormatChannels("Acme", []string{"Collection:5", "acme:user:1", "ACME:x"})
want := []string{"acme:collection:5", "acme:user:1", "acme:x"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("FormatChannels = %v, want %v", got, want)
}
if got := FormatChannels("", []string{"Collection:5"}); !reflect.DeepEqual(got, []string{"collection:5"}) {
t.Fatalf("no namespace = %v", got)
}
}
func TestClientID(t *testing.T) {
if ClientID(context.Background()) != "" {
t.Fatal("empty ctx has a client id")
}
if got := ClientID(WithClientID(context.Background(), "c-1")); got != "c-1" {
t.Fatalf("ClientID = %q", got)
}
}
func TestRegistry(t *testing.T) {
r := NewRegistry()
allow := AuthorizerFunc(func(context.Context, uint, string) Result { return Allowed(nil) })
if err := r.Register("b", allow); err != nil {
t.Fatal(err)
}
if err := r.Register("a", allow); err != nil {
t.Fatal(err)
}
for _, bad := range []struct {
ns string
a Authorizer
}{{"", allow}, {"x:y", allow}, {"c", nil}, {"a", allow}} {
if err := r.Register(bad.ns, bad.a); err == nil {
t.Errorf("Register(%q) accepted", bad.ns)
}
}
if got := r.Namespaces(); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("Namespaces = %v", got)
}
if _, ok := r.Get("A"); ok {
t.Fatal("lookup is not case-sensitive")
}
if a, ok := r.Get("a"); !ok || !a.Authorize(context.Background(), 1, "a:1").Allowed {
t.Fatal("Get(a) failed")
}
d := Denied("why")
if d.Allowed || d.Reason() != "why" {
t.Fatalf("Denied = %+v", d)
}
}