| 1 | package relay |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol" |
| 14 | "github.com/coder/websocket" |
| 15 | ) |
| 16 | |
| 17 | type testBackend struct{} |
| 18 | |
| 19 | func (testBackend) Publish(context.Context, event.Event, Session) (string, error) { return "" , nil } |
| 20 | func (testBackend) Query(context.Context, []event.Filter, Session) ([]event.Event, error) { |
| 21 | return nil, nil |
| 22 | } |
| 23 | func (testBackend) Count(context.Context, []event.Filter, Session) (any, error) { return 0, nil } |
| 24 | |
| 25 | type resultBackend struct{ reason string } |
| 26 | |
| 27 | func (b resultBackend) Publish(context.Context, event.Event, Session) (string, error) { |
| 28 | return b.reason, nil |
| 29 | } |
| 30 | func (resultBackend) Query(context.Context, []event.Filter, Session) ([]event.Event, error) { |
| 31 | return nil, nil |
| 32 | } |
| 33 | func (resultBackend) Count(context.Context, []event.Filter, Session) (any, error) { return 0, nil } |
| 34 | |
| 35 | type filterBackend struct{ resultBackend } |
| 36 | |
| 37 | func (filterBackend) CanReadFilter(event.Event, Session, *event.Filter) bool { return false } |
| 38 | |
| 39 | type gatedBackend struct { |
| 40 | testBackend |
| 41 | queryStarted chan struct{} |
| 42 | allowQuery chan struct{} |
| 43 | publish chan struct{} |
| 44 | syncItems []syncprotocol.Item |
| 45 | } |
| 46 | |
| 47 | func (b *gatedBackend) Query(context.Context, []event.Filter, Session) ([]event.Event, error) { |
| 48 | close(b.queryStarted) |
| 49 | <-b.allowQuery |
| 50 | return nil, nil |
| 51 | } |
| 52 | func (b *gatedBackend) Publish(context.Context, event.Event, Session) (string, error) { |
| 53 | b.publish <- struct{}{} |
| 54 | return "" , nil |
| 55 | } |
| 56 | func (b *gatedBackend) Sync(context.Context, event.Filter, Session) ([]syncprotocol.Item, error) { |
| 57 | return b.syncItems, nil |
| 58 | } |
| 59 | |
| 60 | func TestPendingQueueRejectsSlowConsumerBeforeUnboundedGrowth(t *testing.T) { |
| 61 | r := New(testBackend{}, Config{MaxPendingBytes: 4}) |
| 62 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 63 | if err := c.enqueue("1234" ); err != nil { |
| 64 | t.Fatalf("first message: %v" , err) |
| 65 | } |
| 66 | if err := c.enqueue("5" ); err == nil { |
| 67 | t.Fatal("expected slow-consumer rejection" ) |
| 68 | } |
| 69 | c.cancel() |
| 70 | } |
| 71 | |
| 72 | func TestPublishDoesNotRebroadcastDuplicate(t *testing.T) { |
| 73 | r := New(resultBackend{reason: "duplicate: already have this event" }, Config{}) |
| 74 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 75 | c.subs["sub" ] = subscription{filters: []event.Filter{{Kinds: []int{1}}}} |
| 76 | r.clientsMu.Lock() |
| 77 | r.clients[c] = struct{}{} |
| 78 | r.clientsMu.Unlock() |
| 79 | _, err := r.Publish(context.Background(), event.Event{ID: strings.Repeat("a" , 64), Kind: 1}, Session{}) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | c.mu.Lock() |
| 84 | defer c.mu.Unlock() |
| 85 | if len(c.queue) != 0 { |
| 86 | t.Fatalf("duplicate publication was rebroadcast: %d queued messages" , len(c.queue)) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestFanoutUsesFilterAwarePrivacyGate(t *testing.T) { |
| 91 | r := New(filterBackend{}, Config{}) |
| 92 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 93 | c.subs["sub" ] = subscription{filters: []event.Filter{{Kinds: []int{1}}}} |
| 94 | r.clientsMu.Lock() |
| 95 | r.clients[c] = struct{}{} |
| 96 | r.clientsMu.Unlock() |
| 97 | r.BroadcastGenerated(event.Event{ID: strings.Repeat("a" , 64), Kind: 1}) |
| 98 | c.mu.Lock() |
| 99 | defer c.mu.Unlock() |
| 100 | if len(c.queue) != 0 { |
| 101 | t.Fatal("filter-aware reader gate was bypassed" ) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestPolicyChangeClosesSubscriptionsAndSyncSessions(t *testing.T) { |
| 106 | r := New(testBackend{}, Config{}) |
| 107 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 108 | c.subs["sub" ] = subscription{filters: []event.Filter{{Kinds: []int{1}}}} |
| 109 | c.syncs["sync" ] = &syncprotocol.Session{} |
| 110 | r.clientsMu.Lock() |
| 111 | r.clients[c] = struct{}{} |
| 112 | r.clientsMu.Unlock() |
| 113 | r.CloseSubscriptions("blocked: revoked" ) |
| 114 | c.mu.Lock() |
| 115 | defer c.mu.Unlock() |
| 116 | if len(c.subs) != 0 || len(c.syncs) != 0 { |
| 117 | t.Fatal("policy change retained a live subscription or sync session" ) |
| 118 | } |
| 119 | if len(c.queue) != 2 || !strings.Contains(string(c.queue[1].data), `"NEG-CLOSE","sync"` ) { |
| 120 | t.Fatalf("policy change did not close both protocol sessions: %+v" , c.queue) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestApplyACLChangeInvalidatesSyncUnderPublicationFence(t *testing.T) { |
| 125 | r := New(testBackend{}, Config{}) |
| 126 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 127 | c.syncs["sync" ] = &syncprotocol.Session{} |
| 128 | r.clientsMu.Lock() |
| 129 | r.clients[c] = struct{}{} |
| 130 | r.clientsMu.Unlock() |
| 131 | called := false |
| 132 | if _, err := r.ApplyACLChange(context.Background(), func(context.Context) (any, error) { |
| 133 | called = true |
| 134 | return "ok" , nil |
| 135 | }, "blocked: membership revoked" ); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if !called { |
| 139 | t.Fatal("ACL mutation callback did not run" ) |
| 140 | } |
| 141 | c.mu.Lock() |
| 142 | defer c.mu.Unlock() |
| 143 | if len(c.syncs) != 0 || len(c.queue) != 1 || !strings.Contains(string(c.queue[0].data), `"NEG-CLOSE","sync"` ) { |
| 144 | t.Fatalf("sync session survived ACL mutation: %+v" , c.queue) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestAcceptedMembershipEventInvalidatesSyncBeforeFanout(t *testing.T) { |
| 149 | r := New(testBackend{}, Config{}) |
| 150 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 151 | c.syncs["sync" ] = &syncprotocol.Session{} |
| 152 | c.subs["sub" ] = subscription{filters: []event.Filter{{Kinds: []int{event.KIND_LEAVE}}}} |
| 153 | r.clientsMu.Lock() |
| 154 | r.clients[c] = struct{}{} |
| 155 | r.clientsMu.Unlock() |
| 156 | _, err := r.Publish(context.Background(), event.Event{ID: strings.Repeat("a" , 64), Kind: event.KIND_LEAVE}, Session{}) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | c.mu.Lock() |
| 161 | defer c.mu.Unlock() |
| 162 | if len(c.syncs) != 0 || len(c.subs) != 0 { |
| 163 | t.Fatal("accepted membership event retained stale transport state" ) |
| 164 | } |
| 165 | if len(c.queue) < 2 || !queueContains(c.queue, `"NEG-CLOSE","sync"` ) { |
| 166 | t.Fatalf("membership revocation did not close sync before fanout: %+v" , c.queue) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func queueContains(queue []outbound, value string) bool { |
| 171 | for _, item := range queue { |
| 172 | if strings.Contains(string(item.data), value) { |
| 173 | return true |
| 174 | } |
| 175 | } |
| 176 | return false |
| 177 | } |
| 178 | |
| 179 | func TestZeroMessageLimitIsUnlimited(t *testing.T) { |
| 180 | r := New(testBackend{}, Config{MaxMessageBytes: 0}) |
| 181 | if r.cfg.MaxMessageBytes != 0 { |
| 182 | t.Fatalf("zero message limit became %d" , r.cfg.MaxMessageBytes) |
| 183 | } |
| 184 | if got := r.cfg.withDefaults().MaxMessageBytes; got != 0 { |
| 185 | t.Fatalf("zero message limit became %d" , got) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestIdlePingPongKeepsSessionAliveForLaterTraffic(t *testing.T) { |
| 190 | r := New(testBackend{}, Config{ReadLimit: 20 * time.Millisecond, PingInterval: 5 * time.Millisecond}) |
| 191 | server := httptest.NewServer(http.HandlerFunc(r.HandleHTTP)) |
| 192 | defer server.Close() |
| 193 | conn, _, err := websocket.Dial(context.Background(), "ws" +strings.TrimPrefix(server.URL, "http" ), nil) |
| 194 | if err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | defer conn.Close(websocket.StatusNormalClosure, "done" ) |
| 198 | |
| 199 | readCtx, cancel := context.WithCancel(context.Background()) |
| 200 | defer cancel() |
| 201 | texts := make(chan []byte, 8) |
| 202 | readErr := make(chan error, 1) |
| 203 | go func() { |
| 204 | for { |
| 205 | typ, data, err := conn.Read(readCtx) |
| 206 | if err != nil { |
| 207 | readErr <- err |
| 208 | return |
| 209 | } |
| 210 | if typ == websocket.MessageText { |
| 211 | texts <- data |
| 212 | } |
| 213 | } |
| 214 | }() |
| 215 | select { |
| 216 | case <-texts: |
| 217 | case err := <-readErr: |
| 218 | t.Fatalf("read AUTH: %v" , err) |
| 219 | case <-time.After(time.Second): |
| 220 | t.Fatal("timed out waiting for AUTH" ) |
| 221 | } |
| 222 | time.Sleep(50 * time.Millisecond) |
| 223 | if err := conn.Write(context.Background(), websocket.MessageText, []byte(`["REQ","idle",{}]` )); err != nil { |
| 224 | t.Fatalf("write REQ after idle: %v" , err) |
| 225 | } |
| 226 | select { |
| 227 | case data := <-texts: |
| 228 | if !strings.Contains(string(data), `"EOSE","idle"` ) { |
| 229 | t.Fatalf("response after idle = %s" , data) |
| 230 | } |
| 231 | case err := <-readErr: |
| 232 | t.Fatalf("session closed during healthy idle: %v" , err) |
| 233 | case <-time.After(time.Second): |
| 234 | t.Fatal("timed out waiting for EOSE after idle" ) |
| 235 | } |
| 236 | secret := strings.Repeat("1" , 64) |
| 237 | e := event.Event{Kind: 1, CreatedAt: time.Now().Unix(), Content: "after idle" } |
| 238 | if err := event.Sign(&e, secret); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | raw, _ := json.Marshal([]any{"EVENT" , e}) |
| 242 | if err := conn.Write(context.Background(), websocket.MessageText, raw); err != nil { |
| 243 | t.Fatalf("write EVENT after idle: %v" , err) |
| 244 | } |
| 245 | select { |
| 246 | case data := <-texts: |
| 247 | if !strings.Contains(string(data), `"OK","` +e.ID+`",true` ) { |
| 248 | t.Fatalf("publish response after idle = %s" , data) |
| 249 | } |
| 250 | case err := <-readErr: |
| 251 | t.Fatalf("session closed before publish response: %v" , err) |
| 252 | case <-time.After(time.Second): |
| 253 | t.Fatal("timed out waiting for publish response after idle" ) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func TestRemoteIPStripsPortWithoutTrustingForwardedHeaders(t *testing.T) { |
| 258 | if got := remoteIP("192.0.2.10:7447" ); got != "192.0.2.10" { |
| 259 | t.Fatalf("remote IP = %q" , got) |
| 260 | } |
| 261 | if got := remoteIP("[2001:db8::1]:7447" ); got != "2001:db8::1" { |
| 262 | t.Fatalf("IPv6 remote IP = %q" , got) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestAuthUsesConnectionRelayURL(t *testing.T) { |
| 267 | r := New(testBackend{}, Config{ |
| 268 | RelayURL: "wss://canonical.example/tenant" , |
| 269 | RequestRelayURL: func(*http.Request) string { |
| 270 | return "wss://custom.example/tenant" |
| 271 | }, |
| 272 | }) |
| 273 | c := newClient(r, nil, httptest.NewRequest("GET" , "https://custom.example/tenant" , nil)) |
| 274 | secret := strings.Repeat("1" , 64) |
| 275 | auth := event.Event{CreatedAt: time.Now().Unix(), Kind: event.KIND_AUTH, Tags: [][]string{{"relay" , "wss://custom.example/tenant/" }, {"challenge" , c.challenge}}, Content: "" } |
| 276 | if err := event.Sign(&auth, secret); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | c.handleAuth([]json.RawMessage{mustRaw(auth)}) |
| 280 | c.mu.Lock() |
| 281 | if !strings.Contains(string(c.queue[len(c.queue)-1].data), `"OK","` +auth.ID+`",true` ) { |
| 282 | t.Fatalf("custom relay AUTH was rejected: %s" , c.queue[len(c.queue)-1].data) |
| 283 | } |
| 284 | c.mu.Unlock() |
| 285 | |
| 286 | bad := auth |
| 287 | bad.Tags = [][]string{{"relay" , "wss://other.example" }, {"challenge" , c.challenge}} |
| 288 | if err := event.Sign(&bad, secret); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | c.handleAuth([]json.RawMessage{mustRaw(bad)}) |
| 292 | c.mu.Lock() |
| 293 | defer c.mu.Unlock() |
| 294 | if !strings.Contains(string(c.queue[len(c.queue)-1].data), `"OK","` +bad.ID+`",false` ) { |
| 295 | t.Fatalf("mismatched relay AUTH was accepted: %s" , c.queue[len(c.queue)-1].data) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func mustRaw(v any) json.RawMessage { |
| 300 | b, _ := json.Marshal(v) |
| 301 | return b |
| 302 | } |
| 303 | |
| 304 | func TestNormalizeRelayIgnoresTrailingSlashOnly(t *testing.T) { |
| 305 | if normalizeRelay("WSS://Relay.Example:8448/path///" ) != "relay.example" { |
| 306 | t.Fatal("relay URL normalization should compare the relay host" ) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func TestREQQueryAndPublishShareTheHistoricalLiveFence(t *testing.T) { |
| 311 | b := &gatedBackend{queryStarted: make(chan struct{}), allowQuery: make(chan struct{}), publish: make(chan struct{}, 1)} |
| 312 | r := New(b, Config{}) |
| 313 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 314 | requestDone := make(chan struct{}) |
| 315 | go func() { |
| 316 | c.handleRequest("REQ" , []json.RawMessage{json.RawMessage(`"sub"` ), json.RawMessage(`{}` )}) |
| 317 | close(requestDone) |
| 318 | }() |
| 319 | <-b.queryStarted |
| 320 | publishDone := make(chan struct{}) |
| 321 | go func() { |
| 322 | r.mu.Lock() |
| 323 | _, _ = r.backend.Publish(context.Background(), event.Event{}, c.snapshot()) |
| 324 | r.mu.Unlock() |
| 325 | close(publishDone) |
| 326 | }() |
| 327 | select { |
| 328 | case <-b.publish: |
| 329 | t.Fatal("publish crossed the historical query fence" ) |
| 330 | case <-time.After(20 * time.Millisecond): |
| 331 | } |
| 332 | close(b.allowQuery) |
| 333 | <-requestDone |
| 334 | <-publishDone |
| 335 | c.cancel() |
| 336 | } |
| 337 | |
| 338 | func TestCommitGeneratedSharesHistoricalLiveFence(t *testing.T) { |
| 339 | b := &gatedBackend{queryStarted: make(chan struct{}), allowQuery: make(chan struct{}), publish: make(chan struct{}, 1)} |
| 340 | r := New(b, Config{}) |
| 341 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 342 | queryDone := make(chan struct{}) |
| 343 | go func() { |
| 344 | c.handleRequest("REQ" , []json.RawMessage{json.RawMessage(`"sub"` ), json.RawMessage(`{}` )}) |
| 345 | close(queryDone) |
| 346 | }() |
| 347 | <-b.queryStarted |
| 348 | commitDone := make(chan struct{}) |
| 349 | go func() { |
| 350 | _, _ = r.CommitGenerated(context.Background(), func(context.Context) (event.Event, error) { |
| 351 | close(commitDone) |
| 352 | return event.Event{}, nil |
| 353 | }) |
| 354 | }() |
| 355 | select { |
| 356 | case <-commitDone: |
| 357 | t.Fatal("generated commit crossed the historical query fence" ) |
| 358 | case <-time.After(20 * time.Millisecond): |
| 359 | } |
| 360 | close(b.allowQuery) |
| 361 | <-queryDone |
| 362 | select { |
| 363 | case <-commitDone: |
| 364 | case <-time.After(time.Second): |
| 365 | t.Fatal("generated commit did not proceed after query" ) |
| 366 | } |
| 367 | c.cancel() |
| 368 | } |
| 369 | |
| 370 | func TestNegOpenUsesAuthorizedSnapshotAndReportsProtocolResult(t *testing.T) { |
| 371 | initiator, err := syncprotocol.NewSession([]syncprotocol.Item{{ID: strings.Repeat("a" , 64), Timestamp: 1}}, true) |
| 372 | if err != nil { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | message, err := initiator.Start() |
| 376 | if err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | b := &gatedBackend{syncItems: []syncprotocol.Item{{ID: strings.Repeat("b" , 64), Timestamp: 2}}} |
| 380 | r := New(b, Config{}) |
| 381 | c := newClient(r, nil, httptest.NewRequest("GET" , "http://relay.example/" , nil)) |
| 382 | c.handleSync("NEG-OPEN" , []json.RawMessage{json.RawMessage(`"sync"` ), json.RawMessage(`{}` ), json.RawMessage(`"` + message + `"` )}) |
| 383 | c.mu.Lock() |
| 384 | defer c.mu.Unlock() |
| 385 | if c.syncs["sync" ] == nil { |
| 386 | t.Fatal("NEG-OPEN did not retain a session after a non-empty response" ) |
| 387 | } |
| 388 | if len(c.queue) == 0 || !strings.Contains(string(c.queue[len(c.queue)-1].data), `"NEG-MSG"` ) { |
| 389 | t.Fatal("NEG-OPEN did not enqueue NEG-MSG" ) |
| 390 | } |
| 391 | } |