summaryrefslogtreecommitdiffstats
path: root/internal/handler
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-15 10:06:18 -0800
committerbndw <ben@bdw.to>2026-02-15 10:06:18 -0800
commitd744c32f1bc7411e04c97a9d14c172baaa0e4a89 (patch)
treee9758c48d38798f2922ffbeaccd5f6105821a83a /internal/handler
parent8ae69fde76945377189281182954c946ff9ad419 (diff)
test: add integration tests for NIP-42 AUTH and rate limiting
Add comprehensive WebSocket handler integration tests that verify: - NIP-42 authentication flow (auth required, challenge/response) - Allowlist enforcement (reject unauthorized pubkeys) - Rate limiting by IP address - Rate limiting by authenticated pubkey - No-auth mode works correctly These tests use real WebSocket connections and would have caught the AUTH timeout bug and other protocol issues. Tests cover: - TestAuthRequired: Verifies AUTH challenge sent, client authenticates, publish succeeds - TestAuthNotInAllowlist: Verifies pubkeys not in allowlist are rejected - TestRateLimitByIP: Verifies unauthenticated clients are rate limited by IP - TestRateLimitByPubkey: Verifies authenticated clients are rate limited by pubkey - TestNoAuthWhenDisabled: Verifies publishing works when auth is disabled
Diffstat (limited to 'internal/handler')
-rw-r--r--internal/handler/websocket/handler_test.go526
1 files changed, 526 insertions, 0 deletions
diff --git a/internal/handler/websocket/handler_test.go b/internal/handler/websocket/handler_test.go
new file mode 100644
index 0000000..9f02510
--- /dev/null
+++ b/internal/handler/websocket/handler_test.go
@@ -0,0 +1,526 @@
1package websocket
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http/httptest"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "northwest.io/muxstr/internal/ratelimit"
14 "northwest.io/muxstr/internal/storage"
15 "northwest.io/muxstr/internal/subscription"
16 ws "northwest.io/muxstr/internal/websocket"
17 pb "northwest.io/muxstr/api/nostr/v1"
18
19 "fiatjaf.com/nostr"
20)
21
22// mockAuthStore implements the auth methods needed for testing
23type mockAuthStore struct {
24 challenges map[string]time.Time
25 mu sync.Mutex
26}
27
28func newMockAuthStore() *mockAuthStore {
29 return &mockAuthStore{
30 challenges: make(map[string]time.Time),
31 }
32}
33
34func (m *mockAuthStore) CreateAuthChallenge(ctx context.Context) (string, error) {
35 m.mu.Lock()
36 defer m.mu.Unlock()
37 challenge := fmt.Sprintf("test-challenge-%d", time.Now().UnixNano())
38 m.challenges[challenge] = time.Now()
39 return challenge, nil
40}
41
42func (m *mockAuthStore) ValidateAndConsumeChallenge(ctx context.Context, challenge string) error {
43 m.mu.Lock()
44 defer m.mu.Unlock()
45 if _, exists := m.challenges[challenge]; !exists {
46 return fmt.Errorf("invalid challenge")
47 }
48 delete(m.challenges, challenge)
49 return nil
50}
51
52func (m *mockAuthStore) StoreEvent(ctx context.Context, event *storage.EventData) error {
53 return nil
54}
55
56func (m *mockAuthStore) QueryEvents(ctx context.Context, filters []*pb.Filter, opts *storage.QueryOptions) ([]*pb.Event, error) {
57 return []*pb.Event{}, nil
58}
59
60func (m *mockAuthStore) ProcessDeletion(ctx context.Context, event *pb.Event) error {
61 return nil
62}
63
64// mockMetrics implements metrics recording for testing
65type mockMetrics struct {
66 mu sync.Mutex
67 connections int
68 requests map[string]int
69 blockedEvents map[int32]int
70}
71
72func newMockMetrics() *mockMetrics {
73 return &mockMetrics{
74 requests: make(map[string]int),
75 blockedEvents: make(map[int32]int),
76 }
77}
78
79func (m *mockMetrics) IncrementConnections() {
80 m.mu.Lock()
81 defer m.mu.Unlock()
82 m.connections++
83}
84
85func (m *mockMetrics) DecrementConnections() {
86 m.mu.Lock()
87 defer m.mu.Unlock()
88 m.connections--
89}
90
91func (m *mockMetrics) IncrementSubscriptions() {}
92func (m *mockMetrics) DecrementSubscriptions() {}
93func (m *mockMetrics) SetActiveSubscriptions(count int) {}
94func (m *mockMetrics) RecordRequest(method, status string, duration float64) {
95 m.mu.Lock()
96 defer m.mu.Unlock()
97 key := fmt.Sprintf("%s:%s", method, status)
98 m.requests[key]++
99}
100func (m *mockMetrics) RecordBlockedEvent(kind int32) {
101 m.mu.Lock()
102 defer m.mu.Unlock()
103 m.blockedEvents[kind]++
104}
105
106func (m *mockMetrics) getRequestCount(method, status string) int {
107 m.mu.Lock()
108 defer m.mu.Unlock()
109 return m.requests[fmt.Sprintf("%s:%s", method, status)]
110}
111
112// testServer sets up a test WebSocket server with the handler
113type testServer struct {
114 server *httptest.Server
115 handler *Handler
116 store *mockAuthStore
117 metrics *mockMetrics
118 limiter *ratelimit.Limiter
119}
120
121func newTestServer(authConfig *AuthConfig, enableRateLimit bool) *testServer {
122 store := newMockAuthStore()
123 metrics := newMockMetrics()
124 subs := subscription.NewManager()
125
126 handler := NewHandler(store, subs)
127 handler.SetMetrics(metrics)
128
129 if authConfig != nil {
130 handler.SetAuth(store)
131 handler.SetAuthConfig(authConfig)
132 }
133
134 var limiter *ratelimit.Limiter
135 if enableRateLimit {
136 limiter = ratelimit.New(&ratelimit.Config{
137 RequestsPerSecond: 2, // Low limit for easy testing
138 BurstSize: 2,
139 })
140 handler.SetRateLimiter(limiter)
141 }
142
143 server := httptest.NewServer(handler)
144
145 return &testServer{
146 server: server,
147 handler: handler,
148 store: store,
149 metrics: metrics,
150 limiter: limiter,
151 }
152}
153
154func (ts *testServer) Close() {
155 ts.server.Close()
156}
157
158func (ts *testServer) wsURL() string {
159 return "ws" + strings.TrimPrefix(ts.server.URL, "http")
160}
161
162// connectWS creates a WebSocket connection to the test server
163func (ts *testServer) connectWS(t *testing.T) *ws.Conn {
164 conn, err := ws.Dial(context.Background(), ts.wsURL())
165 if err != nil {
166 t.Fatalf("Failed to connect: %v", err)
167 }
168 return conn
169}
170
171// sendMessage sends a JSON message over WebSocket
172func sendMessage(t *testing.T, conn *ws.Conn, msg interface{}) {
173 data, err := json.Marshal(msg)
174 if err != nil {
175 t.Fatalf("Failed to marshal message: %v", err)
176 }
177 if err := conn.Write(context.Background(), ws.MessageText, data); err != nil {
178 t.Fatalf("Failed to send message: %v", err)
179 }
180}
181
182// sendEvent sends an EVENT message and returns the response
183func sendEvent(t *testing.T, conn *ws.Conn, event *nostr.Event) []interface{} {
184 msg := []interface{}{"EVENT", event}
185 sendMessage(t, conn, msg)
186 return readMessage(t, conn)
187}
188
189// sendAuth sends an AUTH message
190func sendAuth(t *testing.T, conn *ws.Conn, authEvent *nostr.Event) {
191 msg := []interface{}{"AUTH", authEvent}
192 sendMessage(t, conn, msg)
193}
194
195// readMessage reads a message from the WebSocket
196func readMessage(t *testing.T, conn *ws.Conn) []interface{} {
197 _, data, err := conn.Read(context.Background())
198 if err != nil {
199 t.Fatalf("Failed to read message: %v", err)
200 }
201 var msg []interface{}
202 if err := json.Unmarshal(data, &msg); err != nil {
203 t.Fatalf("Failed to unmarshal message: %v", err)
204 }
205 return msg
206}
207
208// readMessageWithTimeout reads a message with timeout
209func readMessageWithTimeout(conn *ws.Conn, timeout time.Duration) ([]interface{}, error) {
210 ctx, cancel := context.WithTimeout(context.Background(), timeout)
211 defer cancel()
212
213 _, data, err := conn.Read(ctx)
214 if err != nil {
215 return nil, err
216 }
217
218 var msg []interface{}
219 if err := json.Unmarshal(data, &msg); err != nil {
220 return nil, err
221 }
222 return msg, nil
223}
224
225// createTestEvent creates a signed test event
226func createTestEvent(sk nostr.SecretKey, content string) *nostr.Event {
227 event := &nostr.Event{
228 PubKey: nostr.GetPublicKey(sk),
229 CreatedAt: nostr.Now(),
230 Kind: nostr.KindTextNote,
231 Tags: nostr.Tags{},
232 Content: content,
233 }
234 event.Sign(sk)
235 return event
236}
237
238// TestAuthRequired verifies that AUTH is required when configured
239func TestAuthRequired(t *testing.T) {
240 sk := nostr.Generate()
241 pubkey := nostr.GetPublicKey(sk)
242
243 authConfig := &AuthConfig{
244 WriteEnabled: true,
245 WriteAllowedPubkeys: []string{fmt.Sprintf("%x", pubkey[:])},
246 }
247
248 ts := newTestServer(authConfig, false)
249 defer ts.Close()
250
251 conn := ts.connectWS(t)
252 defer conn.Close(ws.StatusNormalClosure, "test done")
253
254 // Try to publish without auth
255 event := createTestEvent(sk, "test without auth")
256
257 // Send EVENT
258 sendMessage(t, conn, []interface{}{"EVENT", event})
259
260 // Should receive AUTH challenge
261 msg1 := readMessage(t, conn)
262 if len(msg1) < 2 || msg1[0] != "AUTH" {
263 t.Fatalf("Expected AUTH challenge, got: %v", msg1)
264 }
265 challenge := msg1[1].(string)
266 t.Logf("Received AUTH challenge: %s", challenge)
267
268 // Should also receive OK false
269 msg2 := readMessage(t, conn)
270 if len(msg2) < 4 || msg2[0] != "OK" {
271 t.Fatalf("Expected OK message, got: %v", msg2)
272 }
273 if msg2[2].(bool) != false {
274 t.Errorf("Expected OK false, got true")
275 }
276 if !strings.Contains(msg2[3].(string), "auth-required") {
277 t.Errorf("Expected 'auth-required' message, got: %s", msg2[3])
278 }
279 t.Logf("Received OK false: %v", msg2[3])
280
281 // Now authenticate
282 authEvent := &nostr.Event{
283 PubKey: pubkey,
284 CreatedAt: nostr.Now(),
285 Kind: 22242,
286 Tags: nostr.Tags{
287 {"relay", ts.server.URL},
288 {"challenge", challenge},
289 },
290 Content: "",
291 }
292 authEvent.Sign(sk)
293 sendAuth(t, conn, authEvent)
294
295 // Retry the EVENT
296 event2 := createTestEvent(sk, "test with auth")
297 msg3 := sendEvent(t, conn, event2)
298
299 // Should now succeed
300 if len(msg3) < 4 || msg3[0] != "OK" {
301 t.Fatalf("Expected OK message, got: %v", msg3)
302 }
303 if msg3[2].(bool) != true {
304 t.Errorf("Expected OK true after auth, got false: %v", msg3[3])
305 }
306 t.Logf("Publish succeeded after auth")
307}
308
309// TestAuthNotInAllowlist verifies that pubkeys not in allowlist are rejected
310func TestAuthNotInAllowlist(t *testing.T) {
311 allowedSk := nostr.Generate()
312 allowedPubkey := nostr.GetPublicKey(allowedSk)
313
314 unauthorizedSk := nostr.Generate()
315
316 authConfig := &AuthConfig{
317 WriteEnabled: true,
318 WriteAllowedPubkeys: []string{fmt.Sprintf("%x", allowedPubkey[:])},
319 }
320
321 ts := newTestServer(authConfig, false)
322 defer ts.Close()
323
324 conn := ts.connectWS(t)
325 defer conn.Close(ws.StatusNormalClosure, "test done")
326
327 event := createTestEvent(unauthorizedSk, "unauthorized test")
328
329 // Send EVENT
330 sendMessage(t, conn, []interface{}{"EVENT", event})
331
332 // Receive AUTH challenge
333 msg1 := readMessage(t, conn)
334 if len(msg1) < 2 || msg1[0] != "AUTH" {
335 t.Fatalf("Expected AUTH challenge, got: %v", msg1)
336 }
337 challenge := msg1[1].(string)
338
339 // Receive OK false (auth required)
340 msg2 := readMessage(t, conn)
341 if msg2[0] != "OK" || msg2[2].(bool) != false {
342 t.Fatalf("Expected OK false, got: %v", msg2)
343 }
344
345 // Authenticate with unauthorized key
346 authEvent := &nostr.Event{
347 PubKey: nostr.GetPublicKey(unauthorizedSk),
348 CreatedAt: nostr.Now(),
349 Kind: 22242,
350 Tags: nostr.Tags{
351 {"relay", ts.server.URL},
352 {"challenge", challenge},
353 },
354 Content: "",
355 }
356 authEvent.Sign(unauthorizedSk)
357 sendAuth(t, conn, authEvent)
358
359 // Retry EVENT with unauthorized key
360 event2 := createTestEvent(unauthorizedSk, "retry unauthorized")
361 msg3 := sendEvent(t, conn, event2)
362
363 // Should be rejected - not in allowlist
364 if len(msg3) < 3 || msg3[0] != "OK" {
365 t.Fatalf("Expected OK message, got: %v", msg3)
366 }
367 if msg3[2].(bool) != false {
368 t.Errorf("Expected OK false for unauthorized pubkey, got false: %v", msg3[3])
369 }
370 t.Logf("Unauthorized pubkey correctly rejected: %v", msg3[3])
371}
372
373// TestRateLimitByIP verifies that rate limiting works by IP
374func TestRateLimitByIP(t *testing.T) {
375 ts := newTestServer(nil, true) // No auth, but rate limiting enabled
376 defer ts.Close()
377
378 conn := ts.connectWS(t)
379 defer conn.Close(ws.StatusNormalClosure, "test done")
380
381 sk := nostr.Generate()
382
383 // Rate limit is 2 req/sec with burst 2
384 // So 3rd request should be blocked
385
386 successCount := 0
387 rateLimitCount := 0
388
389 for i := 0; i < 5; i++ {
390 event := createTestEvent(sk, fmt.Sprintf("test event %d", i))
391 msg := sendEvent(t, conn, event)
392
393 if len(msg) < 3 || msg[0] != "OK" {
394 t.Fatalf("Expected OK message, got: %v", msg)
395 }
396
397 if msg[2].(bool) {
398 successCount++
399 t.Logf("Event %d: accepted", i)
400 } else {
401 rateLimitCount++
402 msgStr := ""
403 if len(msg) > 3 {
404 msgStr = msg[3].(string)
405 }
406 if !strings.Contains(msgStr, "rate-limited") {
407 t.Errorf("Expected 'rate-limited' message, got: %v", msgStr)
408 }
409 t.Logf("Event %d: rate limited - %v", i, msgStr)
410 }
411
412 time.Sleep(10 * time.Millisecond)
413 }
414
415 if successCount < 2 {
416 t.Errorf("Expected at least 2 successful requests (burst), got %d", successCount)
417 }
418
419 if rateLimitCount == 0 {
420 t.Errorf("Expected some requests to be rate limited, got 0")
421 }
422
423 t.Logf("Rate limiting working: %d accepted, %d rate limited", successCount, rateLimitCount)
424}
425
426// TestRateLimitByPubkey verifies that rate limiting works by authenticated pubkey
427func TestRateLimitByPubkey(t *testing.T) {
428 sk := nostr.Generate()
429 pubkey := nostr.GetPublicKey(sk)
430
431 authConfig := &AuthConfig{
432 WriteEnabled: true,
433 WriteAllowedPubkeys: []string{fmt.Sprintf("%x", pubkey[:])},
434 }
435
436 ts := newTestServer(authConfig, true) // Auth + rate limiting
437 defer ts.Close()
438
439 conn := ts.connectWS(t)
440 defer conn.Close(ws.StatusNormalClosure, "test done")
441
442 // Authenticate first
443 event := createTestEvent(sk, "trigger auth")
444 sendMessage(t, conn, []interface{}{"EVENT", event})
445
446 // Get AUTH challenge
447 msg1 := readMessage(t, conn)
448 if msg1[0] != "AUTH" {
449 t.Fatalf("Expected AUTH, got: %v", msg1)
450 }
451 challenge := msg1[1].(string)
452
453 // Read OK false
454 readMessage(t, conn)
455
456 // Send AUTH
457 authEvent := &nostr.Event{
458 PubKey: pubkey,
459 CreatedAt: nostr.Now(),
460 Kind: 22242,
461 Tags: nostr.Tags{
462 {"relay", ts.server.URL},
463 {"challenge", challenge},
464 },
465 Content: "",
466 }
467 authEvent.Sign(sk)
468 sendAuth(t, conn, authEvent)
469
470 // Now spam events - should be rate limited by pubkey
471 successCount := 0
472 rateLimitCount := 0
473
474 for i := 0; i < 5; i++ {
475 event := createTestEvent(sk, fmt.Sprintf("spam %d", i))
476 msg := sendEvent(t, conn, event)
477
478 if len(msg) < 3 || msg[0] != "OK" {
479 t.Fatalf("Expected OK, got: %v", msg)
480 }
481
482 if msg[2].(bool) {
483 successCount++
484 t.Logf("Event %d: accepted", i)
485 } else {
486 rateLimitCount++
487 msgStr := ""
488 if len(msg) > 3 {
489 msgStr = msg[3].(string)
490 }
491 t.Logf("Event %d: rate limited - %v", i, msgStr)
492 }
493
494 time.Sleep(10 * time.Millisecond)
495 }
496
497 if rateLimitCount == 0 {
498 t.Errorf("Expected rate limiting by pubkey, but all requests succeeded")
499 }
500
501 t.Logf("Rate limiting by pubkey working: %d accepted, %d rate limited", successCount, rateLimitCount)
502}
503
504// TestNoAuthWhenDisabled verifies that publishing works without auth when auth is disabled
505func TestNoAuthWhenDisabled(t *testing.T) {
506 ts := newTestServer(nil, false) // No auth, no rate limiting
507 defer ts.Close()
508
509 conn := ts.connectWS(t)
510 defer conn.Close(ws.StatusNormalClosure, "test done")
511
512 sk := nostr.Generate()
513 event := createTestEvent(sk, "test without auth required")
514
515 msg := sendEvent(t, conn, event)
516
517 if len(msg) < 3 || msg[0] != "OK" {
518 t.Fatalf("Expected OK message, got: %v", msg)
519 }
520
521 if msg[2].(bool) != true {
522 t.Errorf("Expected OK true when auth disabled, got false: %v", msg[3])
523 }
524
525 t.Logf("Publishing without auth succeeded as expected")
526}