fresh relay seeds configured open channels (RELAY_DEFAULT_CHANNELS, default general) so a client always has a room. relay-signed 9007 through the normal pipeline; idempotent; never reopens a closed group. also adds mission/roadmap and separation rationale to the readme.
243 lines
7.9 KiB
Go
243 lines
7.9 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
// SeedDefaultChannels creates configured channels that don't exist yet, signed
|
|
// by the relay, and is idempotent: seeding an already-present channel is a
|
|
// no-op. A seeded open channel accepts chat without an explicit create.
|
|
func TestSeedDefaultChannels(t *testing.T) {
|
|
store := &slicestore.SliceStore{}
|
|
if err := store.Init(); err != nil {
|
|
t.Fatalf("store init: %v", err)
|
|
}
|
|
app := buildRelay(relayConfig{
|
|
secretKey: nostr.Generate(),
|
|
store: store,
|
|
name: "test-relay",
|
|
defaultChannels: []string{"general", "general", ""}, // dupes/blanks ignored
|
|
})
|
|
|
|
ctx := context.Background()
|
|
app.SeedDefaultChannels(ctx)
|
|
if !app.groups.Has("general") {
|
|
t.Fatal("expected default channel 'general' to be seeded")
|
|
}
|
|
// The seed event is signed by the relay identity.
|
|
if !app.groups.IsAdmin("general", app.pubKey) {
|
|
t.Fatal("expected the relay to be admin of the seeded channel")
|
|
}
|
|
|
|
// Idempotent: a second seed doesn't error or duplicate.
|
|
app.SeedDefaultChannels(ctx)
|
|
|
|
// Seeding never clobbers an existing channel: pre-create one closed, then a
|
|
// seed of the same id must leave it closed.
|
|
closed := nostr.Event{Kind: nostr.KindSimpleGroupCreateGroup, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}, {"closed"}}}
|
|
mustSign(t, app.secretKey, &closed)
|
|
if _, err := app.relay.AddEvent(ctx, closed); err != nil {
|
|
t.Fatalf("pre-create closed group: %v", err)
|
|
}
|
|
app.defaultChannels = []string{"ops"}
|
|
app.SeedDefaultChannels(ctx)
|
|
// A non-member posting to the still-closed 'ops' must be rejected.
|
|
stranger := nostr.Generate()
|
|
if reject, _ := app.groups.Evaluate(func() nostr.Event {
|
|
e := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}}}
|
|
mustSign(t, stranger, &e)
|
|
return e
|
|
}()); !reject {
|
|
t.Fatal("seed must not reopen a pre-existing closed channel")
|
|
}
|
|
}
|