292 lines
9.6 KiB
Go
292 lines
9.6 KiB
Go
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"))
|
|
}
|
|
}
|