package main import ( "context" "testing" "time" "fiatjaf.com/nostr" ) // TestFederationIngest is an end-to-end test: relay B hosts an open channel; // relay A ingests it. A message posted to B by a member appears on A even though // the author is not a member of A — the chat-only, membership-bypass contract. func TestFederationIngest(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() const groupID = "general" // Relay B: a normal relay hosting the channel. bURL := startTestRelay(t, "") // Relay A: ingests channel "general" from B (read-only). aURL := startTestRelay(t, bURL+"|ingest:"+groupID) // A subscriber on relay A watches the channel. aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{}) if err != nil { t.Fatalf("connect reader to A: %v", err) } sub, err := aReader.Subscribe(ctx, nostr.Filter{ Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage}, Tags: nostr.TagMap{"h": []string{groupID}}, }, nostr.SubscriptionOptions{}) if err != nil { t.Fatalf("subscribe on A: %v", err) } defer sub.Unsub() // Alice joins + posts on relay B. aliceSK := nostr.Generate() bWriter, err := nostr.RelayConnect(ctx, bURL, nostr.RelayOptions{}) if err != nil { t.Fatalf("connect alice to B: %v", err) } join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}} mustSign(t, aliceSK, &join) if err := bWriter.Publish(ctx, join); err != nil { t.Fatalf("alice join on B: %v", err) } msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "hello from B", Tags: nostr.Tags{{"h", groupID}}} mustSign(t, aliceSK, &msg) if err := bWriter.Publish(ctx, msg); err != nil { t.Fatalf("alice post on B: %v", err) } // The message should arrive on relay A via federation. select { case got := <-sub.Events: if got.ID != msg.ID { t.Fatalf("got event %s, want %s", got.ID.Hex(), msg.ID.Hex()) } if got.Content != "hello from B" { t.Errorf("content = %q", got.Content) } case <-ctx.Done(): t.Fatal("timed out waiting for federated message on relay A") } } // TestFederationMirrorNoDuplicate proves the loop guard end-to-end: A mirrors B, // so a post on A is forwarded to B, which would echo it back to A. The echo is a // store duplicate and is dropped, not re-delivered — the message arrives on A's // subscription exactly once. func TestFederationMirrorNoDuplicate(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() const groupID = "general" // B must start first (A needs B's URL); then A starts mirroring B. We assert // the guard on the A→B→(echo)→A path. bURL := startTestRelay(t, "") aURL := startTestRelay(t, bURL+"|mirror:"+groupID) // Reader on A counts how many times the event is delivered. aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{}) if err != nil { t.Fatalf("connect reader to A: %v", err) } sub, err := aReader.Subscribe(ctx, nostr.Filter{ Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage}, Tags: nostr.TagMap{"h": []string{groupID}}, }, nostr.SubscriptionOptions{}) if err != nil { t.Fatalf("subscribe on A: %v", err) } defer sub.Unsub() // Alice joins + posts on A. A forwards to B (mirror); B could echo back to A. aliceSK := nostr.Generate() aWriter, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{}) if err != nil { t.Fatalf("connect alice to A: %v", err) } join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}} mustSign(t, aliceSK, &join) if err := aWriter.Publish(ctx, join); err != nil { t.Fatalf("alice join on A: %v", err) } msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "once only", Tags: nostr.Tags{{"h", groupID}}} mustSign(t, aliceSK, &msg) if err := aWriter.Publish(ctx, msg); err != nil { t.Fatalf("alice post on A: %v", err) } // Count deliveries of our message id for a short window; must be exactly 1. deadline := time.After(3 * time.Second) count := 0 for { select { case got := <-sub.Events: if got.ID == msg.ID { count++ } case <-deadline: if count != 1 { t.Fatalf("expected message delivered exactly once, got %d", count) } return case <-ctx.Done(): t.Fatal("context canceled") } } }