package relay import ( "context" "encoding/json" "net/http" "runtime" "strings" "testing" "time" "git.mleku.dev/mleku/dendrite/pkg/nostr" ) // TestPublishAndSubscribe verifies the basic relay contract: // publish events, subscribe, receive them back. func TestPublishAndSubscribe(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Create identity and compose events. id, err := nostr.NewIdentity() if err != nil { t.Fatal(err) } meta, err := id.ComposeMetadata(0, 1, "deadbeef") if err != nil { t.Fatal(err) } status, err := id.ComposeStatus(0, 1, 2922, 980, 0.335, 0.284, 3) if err != nil { t.Fatal(err) } // Connect and publish. client, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer client.Disconnect() go client.Listen(ctx) for _, ev := range []*nostr.Event{meta, status} { if err := client.Publish(ctx, ev); err != nil { t.Fatalf("publish: %v", err) } } // Wait for OK responses. for i := 0; i < 2; i++ { select { case ok := <-client.OKs: if !ok.Accepted { t.Fatalf("relay rejected event %s: %s", ok.EventID[:16], ok.Message) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for OK") } } if r.EventCount() != 2 { t.Fatalf("expected 2 stored events, got %d", r.EventCount()) } // Subscribe from a second client. sub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer sub.Disconnect() go sub.Listen(ctx) limit := 10 if err := sub.Subscribe(ctx, "test-sub", nostr.Filter{ Authors: []string{id.PubKey}, Limit: &limit, }); err != nil { t.Fatal(err) } // Collect replayed events. received := make(map[string]bool) deadline := time.After(2 * time.Second) for len(received) < 2 { select { case ev := <-sub.Events: received[ev.ID] = true case <-deadline: t.Fatalf("timeout: received %d/2 events", len(received)) } } if !received[meta.ID] { t.Fatal("missing metadata event") } if !received[status.ID] { t.Fatal("missing status event") } } // TestBroadcast verifies that new events are pushed to active subscribers. func TestBroadcast(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id, _ := nostr.NewIdentity() // Subscriber connects first and sets up subscription. sub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer sub.Disconnect() go sub.Listen(ctx) if err := sub.Subscribe(ctx, "live", nostr.Filter{ Kinds: []int{1}, }); err != nil { t.Fatal(err) } // Wait for EOSE (subscription registered). time.Sleep(100 * time.Millisecond) // Publisher connects and publishes. pub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer pub.Disconnect() go pub.Listen(ctx) status, _ := id.ComposeStatus(0, 1, 2922, 980, 0.335, 0.284, 3) if err := pub.Publish(ctx, status); err != nil { t.Fatal(err) } // Subscriber should receive the event via broadcast. select { case ev := <-sub.Events: if ev.ID != status.ID { t.Fatalf("wrong event: got %s, want %s", ev.ID[:16], status.ID[:16]) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for broadcast event") } } // TestInvalidEventRejected verifies that events with bad signatures are rejected. func TestInvalidEventRejected(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id, _ := nostr.NewIdentity() ev, _ := id.ComposeStatus(0, 1, 100, 50, 0.5, 0.1, 1) // Tamper with the content (invalidates signature). ev.Content = "tampered content" client, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer client.Disconnect() go client.Listen(ctx) if err := client.Publish(ctx, ev); err != nil { t.Fatal(err) } select { case ok := <-client.OKs: if ok.Accepted { t.Fatal("relay accepted invalid event") } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for OK") } if r.EventCount() != 0 { t.Fatal("invalid event was stored") } } // TestFilterMatching verifies kind and author filter logic. func TestFilterMatching(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id1, _ := nostr.NewIdentity() id2, _ := nostr.NewIdentity() // Publish events from two different authors. pub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer pub.Disconnect() go pub.Listen(ctx) ev1, _ := id1.ComposeStatus(0, 1, 100, 50, 0.5, 0.1, 1) ev2, _ := id2.ComposeStatus(1, 1, 200, 100, 0.5, 0.2, 2) meta1, _ := id1.ComposeMetadata(0, 1, "aabbccdd") for _, ev := range []*nostr.Event{ev1, ev2, meta1} { pub.Publish(ctx, ev) } // Wait for all OKs. for i := 0; i < 3; i++ { select { case <-pub.OKs: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for OK") } } // Subscribe for only id1's kind 1 events. sub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer sub.Disconnect() go sub.Listen(ctx) limit := 10 if err := sub.Subscribe(ctx, "filter-test", nostr.Filter{ Authors: []string{id1.PubKey}, Kinds: []int{1}, Limit: &limit, }); err != nil { t.Fatal(err) } // Should receive only ev1 (id1's kind 1). var received []*nostr.Event deadline := time.After(2 * time.Second) loop: for { select { case ev := <-sub.Events: received = append(received, ev) case <-deadline: break loop case <-time.After(500 * time.Millisecond): break loop } } if len(received) != 1 { t.Fatalf("expected 1 event, got %d", len(received)) } if received[0].ID != ev1.ID { t.Fatalf("wrong event: got %s, want %s", received[0].ID[:16], ev1.ID[:16]) } } // TestRoundTrip verifies byte-level event fidelity through the relay. func TestRoundTrip(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id, _ := nostr.NewIdentity() original, _ := id.ComposeStatus(0, 1, 2922, 980, 0.335, 0.284, 3) origJSON, _ := json.Marshal(original) // Publish. pub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer pub.Disconnect() go pub.Listen(ctx) pub.Publish(ctx, original) select { case ok := <-pub.OKs: if !ok.Accepted { t.Fatalf("rejected: %s", ok.Message) } case <-time.After(2 * time.Second): t.Fatal("timeout") } // Subscribe and receive. sub, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer sub.Disconnect() go sub.Listen(ctx) limit := 1 sub.Subscribe(ctx, "rt", nostr.Filter{ IDs: []string{original.ID}, Limit: &limit, }) select { case ev := <-sub.Events: recvJSON, _ := json.Marshal(ev) if string(origJSON) != string(recvJSON) { t.Fatalf("DIVERGENCE:\norig: %s\nrecv: %s", origJSON, recvJSON) } t.Log("round-trip: ZERO DIVERGENCE") case <-time.After(2 * time.Second): t.Fatal("timeout waiting for event") } } // TestNoGoroutineLeak verifies that the relay doesn't leak goroutines // when clients connect and disconnect. func TestNoGoroutineLeak(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } ctx := context.Background() // Baseline goroutine count. runtime.GC() time.Sleep(50 * time.Millisecond) baseline := runtime.NumGoroutine() // Connect and disconnect 10 clients. for i := 0; i < 10; i++ { cctx, cancel := context.WithTimeout(ctx, time.Second) c, err := nostr.Connect(cctx, "ws://"+addr) if err != nil { cancel() t.Fatal(err) } go c.Listen(cctx) time.Sleep(10 * time.Millisecond) c.Disconnect() cancel() } r.Shutdown(ctx) // Wait for goroutines to settle. time.Sleep(200 * time.Millisecond) runtime.GC() time.Sleep(50 * time.Millisecond) after := runtime.NumGoroutine() leaked := after - baseline if leaked > 5 { t.Fatalf("goroutine leak: %d goroutines before, %d after (%d leaked)", baseline, after, leaked) } } // TestNIP11 verifies the relay information document. func TestNIP11(t *testing.T) { r := New("dendrite-test") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) // Fetch NIP-11 document via HTTP. req, _ := http.NewRequest("GET", "http://"+addr+"/", nil) req.Header.Set("Accept", "application/nostr+json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var info map[string]any if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { t.Fatal(err) } if info["name"] != "dendrite-test" { t.Fatalf("wrong name: %v", info["name"]) } if info["software"] != "dendrite" { t.Fatalf("wrong software: %v", info["software"]) } } // TestNIP09Deletion verifies that kind 5 events delete referenced events // by the same author, and that cross-author deletion is rejected. func TestNIP09Deletion(t *testing.T) { r := New("test-relay") addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() author, _ := nostr.NewIdentity() other, _ := nostr.NewIdentity() // Publish an event from author. client, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer client.Disconnect() go client.Listen(ctx) ev, _ := author.ComposeStatus(0, 1, 100, 50, 0.5, 0.1, 1) if err := client.Publish(ctx, ev); err != nil { t.Fatal(err) } <-client.OKs if r.EventCount() != 1 { t.Fatalf("expected 1 event, got %d", r.EventCount()) } // Other author tries to delete it — should have no effect. delByOther := &nostr.Event{ CreatedAt: time.Now().Unix(), Kind: 5, Tags: [][]string{{"e", ev.ID}}, Content: "", } delByOther.Sign(other.PrivKeyHex()) client.Publish(ctx, delByOther) <-client.OKs // Event count should be 2 (original + deletion event), original not removed. if r.EventCount() != 2 { t.Fatalf("after cross-author deletion attempt: expected 2 events, got %d", r.EventCount()) } // Author deletes their own event. delBySelf := &nostr.Event{ CreatedAt: time.Now().Unix(), Kind: 5, Tags: [][]string{{"e", ev.ID}}, Content: "", } delBySelf.Sign(author.PrivKeyHex()) client.Publish(ctx, delBySelf) <-client.OKs // Original event should be gone: 2 deletion events + 1 other's event attempt = 3 minus 1 deleted = 2. // Actually: original deleted, other's deletion event stored, self's deletion event stored = 2. if r.EventCount() != 2 { t.Fatalf("after self-deletion: expected 2 events (deletion events only), got %d", r.EventCount()) } } // TestNIP42Auth verifies that an authenticated relay rejects events // from unauthenticated clients and accepts after AUTH. func TestNIP42Auth(t *testing.T) { r := New("auth-relay") r.RequireAuth = true addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id, _ := nostr.NewIdentity() client, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer client.Disconnect() go client.Listen(ctx) // The relay sends an AUTH challenge on connect. Read it. // Give the relay a moment to send the challenge. time.Sleep(100 * time.Millisecond) // Try to publish without authenticating — should be rejected. ev, _ := id.ComposeStatus(0, 1, 100, 50, 0.5, 0.1, 1) client.Publish(ctx, ev) select { case ok := <-client.OKs: if ok.Accepted { t.Fatal("relay accepted event from unauthenticated client") } if !strings.Contains(ok.Message, "auth-required") { t.Fatalf("expected auth-required message, got: %s", ok.Message) } case <-time.After(2 * time.Second): t.Fatal("timeout") } if r.EventCount() != 0 { t.Fatal("unauthenticated event was stored") } } // TestCoherenceFilter verifies that the relay rejects events whose // content doesn't bond to any known lattice structure. func TestCoherenceFilter(t *testing.T) { r := New("filter-relay") // Only admit events that decompose into "hashtag" elements. r.ContentFilter = &CoherenceFilter{ KnownTypes: map[string]bool{"hashtag": true}, } addr, err := r.Listen("127.0.0.1:0") if err != nil { t.Fatal(err) } defer r.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() id, _ := nostr.NewIdentity() client, err := nostr.Connect(ctx, "ws://"+addr) if err != nil { t.Fatal(err) } defer client.Disconnect() go client.Listen(ctx) // Event WITHOUT hashtag — should be rejected. evNoHash := &nostr.Event{ CreatedAt: 1700000000, Kind: 1, Content: "just plain text without any tags", } evNoHash.Sign(id.PrivKeyHex()) client.Publish(ctx, evNoHash) select { case ok := <-client.OKs: if ok.Accepted { t.Fatal("relay accepted incoherent event") } case <-time.After(2 * time.Second): t.Fatal("timeout") } // Event WITH hashtag — should be accepted. evHash := &nostr.Event{ CreatedAt: 1700000000, Kind: 1, Content: "talking about nostr", Tags: [][]string{{"t", "nostr"}}, } evHash.Sign(id.PrivKeyHex()) client.Publish(ctx, evHash) select { case ok := <-client.OKs: if !ok.Accepted { t.Fatalf("relay rejected coherent event: %s", ok.Message) } case <-time.After(2 * time.Second): t.Fatal("timeout") } }