package group import ( "context" "testing" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore/slicestore" ) 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) } // evalOK applies an event and fails if the relay rejects it. func evalOK(t *testing.T, gs *State, evt nostr.Event) { t.Helper() if reject, msg := gs.Evaluate(evt); reject { t.Fatalf("kind %d unexpectedly rejected: %s", evt.Kind, msg) } } // evalRejected applies an event and fails if it is allowed. func evalRejected(t *testing.T, gs *State, evt nostr.Event) string { t.Helper() reject, msg := gs.Evaluate(evt) if !reject { t.Fatalf("kind %d unexpectedly allowed", evt.Kind) } return msg } func TestChatRejectedWithoutGroup(t *testing.T) { gs := NewState(nostr.Generate()) // A chat message to a group that was never created/joined is rejected. evt := nostr.Event{ Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", "nonexistent"}}, } reject, msg := gs.Evaluate(evt) if !reject { t.Fatalf("expected rejection for chat to unknown group, got allow (%q)", msg) } } func TestChatMissingHTagRejected(t *testing.T) { gs := NewState(nostr.Generate()) evt := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage} reject, _ := gs.Evaluate(evt) if !reject { t.Fatal("expected rejection for chat with no h tag") } } func TestJoinThenChatAllowed(t *testing.T) { gs := NewState(nostr.Generate()) alice := nostr.Generate() alicePK := nostr.GetPublicKey(alice) join := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", "g1"}}} if reject, msg := gs.Evaluate(join); reject { t.Fatalf("join rejected: %s", msg) } chat := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", "g1"}}} if reject, msg := gs.Evaluate(chat); reject { t.Fatalf("chat after join rejected: %s", msg) } if !gs.IsMember("g1", alicePK) { t.Fatal("alice should be a member after joining") } } // The creator of a group is its administrator; a plain joiner is not, and only // the admin may add or remove users. func TestAdminModerationInClosedGroup(t *testing.T) { gs := NewState(nostr.Generate()) admin := nostr.Generate() // creates the group → admin + member alice := nostr.Generate() // will be admitted by the admin bob := nostr.Generate() // will try to moderate without permission mallory := nostr.Generate() const g = "staff" adminPK, alicePK, bobPK, malloryPK := hpk(admin), hpk(alice), hpk(bob), hpk(mallory) // Admin creates a CLOSED group. evalOK(t, gs, nostr.Event{ PubKey: adminPK, Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}, {"closed"}}, }) if !gs.IsAdmin(g, adminPK) { t.Fatal("creator should be an admin") } if !gs.IsMember(g, adminPK) { t.Fatal("creator should be a member") } // A join request to a closed group is accepted as an event but does NOT // grant membership — it awaits admin approval. evalOK(t, gs, nostr.Event{ PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", g}}, }) if gs.IsMember(g, alicePK) { t.Fatal("closed group: join request alone must not grant membership") } // A non-member/non-admin cannot post to the closed group. evalRejected(t, gs, nostr.Event{ PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}}, }) // A non-admin (bob) cannot add users. msg := evalRejected(t, gs, nostr.Event{ PubKey: bobPK, Kind: nostr.KindSimpleGroupPutUser, Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}}, }) if msg == "" { t.Fatal("expected a rejection reason for non-admin add") } // The admin admits alice (kind 9000). evalOK(t, gs, nostr.Event{ PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser, Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}}, }) if !gs.IsMember(g, alicePK) { t.Fatal("admin add (9000) should grant alice membership") } // Now alice, a member, can post. evalOK(t, gs, nostr.Event{ PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}}, }) // The admin can add multiple users in one event. evalOK(t, gs, nostr.Event{ PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser, Tags: nostr.Tags{{"h", g}, {"p", bobPK.Hex()}, {"p", malloryPK.Hex()}}, }) if !gs.IsMember(g, bobPK) || !gs.IsMember(g, malloryPK) { t.Fatal("admin should add multiple tagged users") } // The admin removes mallory (kind 9001); she can no longer post. evalOK(t, gs, nostr.Event{ PubKey: adminPK, Kind: nostr.KindSimpleGroupRemoveUser, Tags: nostr.Tags{{"h", g}, {"p", malloryPK.Hex()}}, }) if gs.IsMember(g, malloryPK) { t.Fatal("admin remove (9001) should revoke mallory's membership") } evalRejected(t, gs, nostr.Event{ PubKey: malloryPK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}}, }) // A non-admin (bob) cannot remove users either. evalRejected(t, gs, nostr.Event{ PubKey: bobPK, Kind: nostr.KindSimpleGroupRemoveUser, Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}}, }) if !gs.IsMember(g, alicePK) { t.Fatal("a rejected removal must not change membership") } } // Open groups let anyone post without admin approval (public channels), which // contrasts with the closed-group moderation above. func TestOpenGroupAllowsNonMemberPosts(t *testing.T) { gs := NewState(nostr.Generate()) admin := nostr.Generate() stranger := nostr.Generate() const g = "lobby" evalOK(t, gs, nostr.Event{ PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}}, }) // A stranger who never joined can still post to an open group. evalOK(t, gs, nostr.Event{ PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}}, }) } // Group membership and metadata must survive a relay restart: state is rebuilt // from the persisted event store rather than lost. This reproduces the bug // where an in-memory-only State silently dropped all groups on restart. func TestStateSurvivesRebuild(t *testing.T) { store := &slicestore.SliceStore{} if err := store.Init(); err != nil { t.Fatalf("store init: %v", err) } admin := nostr.Generate() alice := nostr.Generate() const g = "persisted" // Simulate the live path: Evaluate accepts the event, then it is stored // (khatru stores accepted events; we do it explicitly here). apply := func(gs *State, evt nostr.Event) { if reject, msg := gs.Evaluate(evt); reject { t.Fatalf("kind %d rejected: %s", evt.Kind, msg) } if err := store.SaveEvent(evt); err != nil { t.Fatalf("save: %v", err) } } signed := func(sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) nostr.Event { e := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags} mustSign(t, sk, &e) return e } // Build state in the "first boot": admin creates a closed group and admits alice. first := NewState(nostr.Generate()) apply(first, signed(admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}})) apply(first, signed(admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}})) if !first.IsMember(g, hpk(alice)) || !first.IsAdmin(g, hpk(admin)) { t.Fatal("precondition: alice member + admin admin should hold before restart") } // "Restart": a fresh State that knows nothing until it replays the store. second := NewState(nostr.Generate()) if second.IsMember(g, hpk(alice)) { t.Fatal("fresh state should be empty before rebuild") } replayed := second.Rebuild(store) if replayed != 2 { t.Fatalf("expected 2 management events replayed, got %d", replayed) } // Membership, admin rights, and closed-ness must be restored. if !second.IsMember(g, hpk(admin)) { t.Fatal("admin membership lost across rebuild") } if !second.IsMember(g, hpk(alice)) { t.Fatal("alice membership lost across rebuild") } if !second.IsAdmin(g, hpk(admin)) { t.Fatal("admin rights lost across rebuild") } // Closed-group enforcement must still apply after rebuild: a non-member is rejected. stranger := nostr.Generate() if reject, _ := second.Evaluate(signed(stranger, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); !reject { t.Fatal("closed-group enforcement lost across rebuild: stranger post should be rejected") } // A restored member can still post. if reject, msg := second.Evaluate(signed(alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); reject { t.Fatalf("restored member alice should be able to post: %s", msg) } } // Only admins can edit group metadata (topic), and the edit is applied to state. func TestEditMetadataAdminOnly(t *testing.T) { gs := NewState(nostr.Generate()) admin := nostr.Generate() stranger := nostr.Generate() const g = "topictest" // Admin creates the group. evalOK(t, gs, nostr.Event{ PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}}, }) // A non-admin cannot set the topic. evalRejected(t, gs, nostr.Event{ PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupEditMetadata, Tags: nostr.Tags{{"h", g}, {"about", "hostile takeover"}}, }) // The admin sets name + topic, and state reflects it. evalOK(t, gs, nostr.Event{ PubKey: hpk(admin), Kind: nostr.KindSimpleGroupEditMetadata, Tags: nostr.Tags{{"h", g}, {"name", "Topic Test"}, {"about", "the topic"}}, }) meta, ok := gs.MetadataEvent(context.Background(), g) if !ok { t.Fatal("expected metadata event") } if firstTagValue(meta, "name") != "Topic Test" { t.Fatalf("name not applied: %q", firstTagValue(meta, "name")) } if firstTagValue(meta, "about") != "the topic" { t.Fatalf("about (topic) not applied: %q", firstTagValue(meta, "about")) } }