package main import ( "context" "net/http/httptest" "testing" "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore/slicestore" "github.com/nosterm/relay/internal/federation" ) // startTestRelay spins up a fully-wired relay backed by an in-memory store, // federating per peersSpec (empty = no federation). It returns the relay's ws:// // URL. The federator, if any, is started and cleaned up automatically. This is // the integration seam: it exercises exactly the wiring buildRelay produces for // main(), over a real in-process khatru websocket server. func startTestRelay(t *testing.T, peersSpec string) string { t.Helper() store := &slicestore.SliceStore{} if err := store.Init(); err != nil { t.Fatalf("store init: %v", err) } fedCfg, _ := federation.ParseConfig(peersSpec) app := buildRelay(relayConfig{ secretKey: nostr.Generate(), store: store, name: "test-relay", fedCfg: fedCfg, }) srv := httptest.NewServer(app.handler) t.Cleanup(srv.Close) if app.federator != nil { app.federator.Start() t.Cleanup(app.federator.Stop) } return "ws" + srv.URL[len("http"):] } func mustSign(t *testing.T, sk nostr.SecretKey, evt *nostr.Event) { t.Helper() if err := evt.Sign(sk); err != nil { t.Fatalf("sign: %v", err) } } func hpk(sk nostr.SecretKey) nostr.PubKey { return nostr.GetPublicKey(sk) } func TestGroupJoinSendReceive(t *testing.T) { url := startTestRelay(t, "") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Two independent identities: alice writes, bob reads. aliceSK := nostr.Generate() bobSK := nostr.Generate() const groupID = "general" // Bob connects and subscribes to the group's chat messages first. bob, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { t.Fatalf("bob connect: %v", err) } sub, err := bob.Subscribe(ctx, nostr.Filter{ Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage}, Tags: nostr.TagMap{"h": []string{groupID}}, }, nostr.SubscriptionOptions{}) if err != nil { t.Fatalf("bob subscribe: %v", err) } // Alice connects, joins the group, then sends a chat message. alice, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { t.Fatalf("alice connect: %v", err) } join := nostr.Event{ Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}, } mustSign(t, aliceSK, &join) if err := alice.Publish(ctx, join); err != nil { t.Fatalf("alice join publish: %v", err) } msg := nostr.Event{ Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "hello nosterm relay", Tags: nostr.Tags{{"h", groupID}}, } mustSign(t, aliceSK, &msg) if err := alice.Publish(ctx, msg); err != nil { t.Fatalf("alice message publish: %v", err) } // Bob must receive alice's message via the subscription (cross-user). select { case got := <-sub.Events: if got.Content != "hello nosterm relay" { t.Fatalf("unexpected content: %q", got.Content) } if got.PubKey != nostr.GetPublicKey(aliceSK) { t.Fatalf("unexpected author") } case <-time.After(5 * time.Second): t.Fatal("timed out waiting for the group message") } _ = bobSK // bob reads without needing to sign in this open-group MVP } // End-to-end over a real websocket: an admin and two members interact, and the // relay rejects a non-admin's moderation attempt on the wire. // // NOTE: run the suite without -race. go-nostr's unsafe-based JSON serializer // trips the race detector's checkptr under this test's concurrent signing; it // is an upstream library issue, not a data race in the relay. The relay's own // group-state locking is race-clean — verify with: // // go test ./internal/group/ -race func TestMultiUserAdminOverWebsocket(t *testing.T) { url := startTestRelay(t, "") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() admin := nostr.Generate() alice := nostr.Generate() const g = "ops" adminConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { t.Fatalf("admin connect: %v", err) } aliceConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { t.Fatalf("alice connect: %v", err) } publish := func(conn *nostr.Relay, sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) error { evt := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags} mustSign(t, sk, &evt) return conn.Publish(ctx, evt) } // Admin creates a closed group. if err := publish(adminConn, admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}}); err != nil { t.Fatalf("create closed group: %v", err) } // Alice (not yet a member) cannot post — the relay rejects it on the wire. if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err == nil { t.Fatal("expected the relay to reject a non-member's post to a closed group") } // Alice cannot moderate (add herself) — she is not an admin. if err := publish(aliceConn, alice, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err == nil { t.Fatal("expected the relay to reject a non-admin moderation event") } // The admin admits alice. if err := publish(adminConn, admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err != nil { t.Fatalf("admin add user: %v", err) } // Alice can now post, and the admin — subscribed — receives it. sub, err := adminConn.Subscribe(ctx, nostr.Filter{ Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage}, Tags: nostr.TagMap{"h": []string{g}}, }, nostr.SubscriptionOptions{}) if err != nil { t.Fatalf("admin subscribe: %v", err) } if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err != nil { t.Fatalf("member post after admission should succeed: %v", err) } select { case got := <-sub.Events: if got.PubKey != hpk(alice) { t.Fatal("admin received a message from an unexpected author") } case <-time.After(5 * time.Second): t.Fatal("admin did not receive the admitted member's message") } }