clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / community / rooms_test.go
rooms_test.go 15.2K
1package community23import (4 "context"5 "database/sql"6 "strings"7 "testing"8 "time"910 "github.com/FelineStateMachine/tinyrelay/internal/event"11 "github.com/FelineStateMachine/tinyrelay/internal/policy"12 "github.com/FelineStateMachine/tinyrelay/internal/storage"13)1415type roomFixture struct {16 ctx context.Context17 store *storage.Store18 svc *Service19 rooms int20 keys map[string]string21}2223func newRoomFixture(t *testing.T) *roomFixture {24 t.Helper()25 ctx := context.Background()26 st, err := storage.Open(ctx, t.TempDir()+"/relay.db")27 if err != nil {28 t.Fatal(err)29 }30 t.Cleanup(func() { _ = st.Close() })31 f := &roomFixture{ctx: ctx, store: st, rooms: 64, keys: map[string]string{}}32 for i, name := range []string{"owner", "alice", "bob", "carol", "dave", "stranger"} {33 f.keys[name] = strings.Repeat("0", 63) + string(rune('1'+i))34 }35 f.svc, err = New(ctx, st, f.pub("owner"))36 if err != nil {37 t.Fatal(err)38 }39 f.svc.ConfigurePolicy(func() policy.Policy {40 p := policy.Defaults(f.pub("owner"))41 p.Rooms = f.rooms42 return p43 })44 if err := f.svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil {45 t.Fatal(err)46 }47 for _, name := range []string{"alice", "bob", "carol", "dave"} {48 if _, err := f.svc.Execute(ctx, f.pub("owner"), "setmember", raws(f.pub(name), map[string]any{"role": "member"})); err != nil {49 t.Fatal(err)50 }51 }52 return f53}5455func (f *roomFixture) pub(name string) string {56 pk, _ := event.PublicKey(f.keys[name])57 return pk58}5960func (f *roomFixture) event(t *testing.T, who string, kind int, tags [][]string, content string) event.Event {61 t.Helper()62 e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}63 if err := event.Sign(&e, f.keys[who]); err != nil {64 t.Fatal(err)65 }66 return e67}6869func (f *roomFixture) persist(e event.Event) func(*sql.Tx) error {70 return func(tx *sql.Tx) error {71 _, err := storage.SaveTx(f.ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()})72 return err73 }74}7576// send applies a room management event and returns the handler error.77func (f *roomFixture) send(t *testing.T, who string, kind int, tags [][]string) (MembershipResult, error) {78 t.Helper()79 e := f.event(t, who, kind, tags, "")80 return f.svc.HandleRoomEventTx(f.ctx, e, f.persist(e))81}8283func (f *roomFixture) message(t *testing.T, who, room, text string) event.Event {84 t.Helper()85 e := f.event(t, who, event.KIND_CHAT, [][]string{{"h", room}}, text)86 if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err != nil {87 t.Fatalf("message from %s in %s: %v", who, room, err)88 }89 return e90}9192func (f *roomFixture) role(t *testing.T, room, who string) string {93 t.Helper()94 role, err := f.svc.RoomRole(f.ctx, room, f.pub(who))95 if err != nil {96 t.Fatal(err)97 }98 return role99}100101func TestRoomCreationHonoursMembershipAndCap(t *testing.T) {102 f := newRoomFixture(t)103 f.rooms = 1104 if _, err := f.send(t, "stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {105 t.Fatalf("stranger created a room: %v", err)106 }107 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "Bad Room"}, {"name", "x"}}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {108 t.Fatalf("invalid id accepted: %v", err)109 }110 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "main"}}); err == nil {111 t.Fatal("slug room recreated")112 }113 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}, {"about", "Talk"}, {"channel_type", "public"}}); err != nil {114 t.Fatal(err)115 }116 room, err := f.svc.Room(f.ctx, "general")117 if err != nil || !room.Live() || room.Name != "General" || room.About != "Talk" || room.Access != RoomOpen || room.CreatedBy != f.pub("alice") || room.EventID == "" {118 t.Fatalf("room = %+v err=%v", room, err)119 }120 if f.role(t, "general", "alice") != RoomRoleOwner {121 t.Fatal("creator is not the room owner")122 }123 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}); err == nil || !strings.HasPrefix(err.Error(), "duplicate:") {124 t.Fatalf("duplicate room: %v", err)125 }126 if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "second"}}); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") {127 t.Fatalf("cap not applied: %v", err)128 }129 f.rooms = 0130 if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "third"}}); err == nil || !strings.Contains(err.Error(), "switched off") {131 t.Fatalf("rooms off not applied: %v", err)132 }133 f.rooms = 64134 if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"visibility", "members"}}); err != nil {135 t.Fatal(err)136 }137 if room, _ := f.svc.Room(f.ctx, "team"); room.Access != RoomMembers {138 t.Fatalf("visibility tag ignored: %+v", room)139 }140 rooms, err := f.svc.Rooms(f.ctx)141 if err != nil || len(rooms) != 3 {142 t.Fatalf("rooms = %v err=%v", rooms, err)143 }144}145146func TestRoomAdminEventsFollowRoomRoles(t *testing.T) {147 f := newRoomFixture(t)148 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"closed"}}); err != nil {149 t.Fatal(err)150 }151 // Metadata edits belong to room admins; the tenant owner may always moderate.152 if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Hijacked"}}); err == nil {153 t.Fatal("outsider edited the room")154 }155 if _, err := f.send(t, "owner", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"about", "Moderated"}}); err != nil {156 t.Fatal(err)157 }158 if _, err := f.send(t, "alice", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Team Chat"}}); err != nil {159 t.Fatal(err)160 }161 if room, _ := f.svc.Room(f.ctx, "team"); room.Name != "Team Chat" || room.About != "Moderated" || room.Access != RoomMembers {162 t.Fatalf("edit lost: %+v", room)163 }164 // Adding people: tenant membership is required and owner appointments are reserved.165 if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("stranger")}}); err == nil {166 t.Fatal("non-member added to a room")167 }168 if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "admin"}}); err != nil {169 t.Fatal(err)170 }171 if f.role(t, "team", "bob") != RoomRoleAdmin {172 t.Fatal("admin role not applied")173 }174 if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol"), "owner"}}); err == nil {175 t.Fatal("admin appointed an owner")176 }177 if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil {178 t.Fatal(err)179 }180 if _, err := f.send(t, "carol", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("dave")}}); err == nil {181 t.Fatal("member added someone")182 }183 // Removal: admins cannot remove owners and the last owner stays.184 if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("alice")}}); err == nil {185 t.Fatal("admin removed the owner")186 }187 if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "at least one owner") {188 t.Fatalf("last owner left: %v", err)189 }190 if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "owner"}}); err != nil {191 t.Fatal(err)192 }193 if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err != nil {194 t.Fatal(err)195 }196 if f.role(t, "team", "alice") != "" || f.role(t, "team", "bob") != RoomRoleOwner {197 t.Fatal("ownership hand-over failed")198 }199 if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("bob")}}); err == nil {200 t.Fatal("last owner removed themselves")201 }202 if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil {203 t.Fatal(err)204 }205 // Join requests: members-only rooms refuse them, open rooms take tenant members.206 if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "members-only") {207 t.Fatalf("joined a members-only room: %v", err)208 }209 if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"open"}}); err != nil {210 t.Fatal(err)211 }212 if _, err := f.send(t, "stranger", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil {213 t.Fatal("stranger joined")214 }215 if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil {216 t.Fatal(err)217 }218 if res, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil || res.Stored || !strings.HasPrefix(res.Message, "duplicate:") {219 t.Fatalf("second join = %+v %v", res, err)220 }221 // Deleting events: authors and admins only, and only inside this room.222 own := f.message(t, "carol", "team", "mine")223 other := f.message(t, "bob", "team", "theirs")224 if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err == nil {225 t.Fatal("member deleted another member's message")226 }227 if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", own.ID}}); err != nil {228 t.Fatal(err)229 }230 if _, err := f.send(t, "bob", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err != nil {231 t.Fatal(err)232 }233 var stored int234 if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id IN (?,?)`, own.ID, other.ID).Scan(&stored); err != nil || stored != 0 {235 t.Fatalf("messages remain: %d %v", stored, err)236 }237 // Deleting the room: owners only, never the slug room, and the history goes.238 left := f.message(t, "carol", "team", "still here")239 if _, err := f.send(t, "carol", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err == nil {240 t.Fatal("member deleted the room")241 }242 if _, err := f.send(t, "owner", event.KIND_DELETE_GROUP, [][]string{{"h", "main"}}); err == nil {243 t.Fatal("slug room deleted")244 }245 if _, err := f.send(t, "bob", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err != nil {246 t.Fatal(err)247 }248 if room, _ := f.svc.Room(f.ctx, "team"); room.Live() {249 t.Fatal("room still live")250 }251 if n, _ := f.svc.RoomMemberCount(f.ctx, "team"); n != 0 {252 t.Fatalf("members remain: %d", n)253 }254 if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id=?`, left.ID).Scan(&stored); err != nil || stored != 0 {255 t.Fatalf("history remains: %d %v", stored, err)256 }257 if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil {258 t.Fatal("joined a deleted room")259 }260 if _, err := f.send(t, "dave", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Reborn"}}); err != nil {261 t.Fatalf("deleted id not reusable: %v", err)262 }263 if f.role(t, "team", "dave") != RoomRoleOwner || f.role(t, "team", "bob") != "" {264 t.Fatal("recreated room kept old members")265 }266}267268func TestRoomMessagesJoinOpenRoomsAndRespectMembersOnly(t *testing.T) {269 f := newRoomFixture(t)270 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "open"}}); err != nil {271 t.Fatal(err)272 }273 if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "closed"}, {"closed"}}); err != nil {274 t.Fatal(err)275 }276 f.message(t, "bob", "open", "hello")277 if f.role(t, "open", "bob") != RoomRoleMember {278 t.Fatal("first message did not join the open room")279 }280 e := f.event(t, "bob", event.KIND_CHAT, [][]string{{"h", "closed"}}, "psst")281 if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {282 t.Fatalf("outsider wrote into a members-only room: %v", err)283 }284 e = f.event(t, "stranger", event.KIND_CHAT, [][]string{{"h", "open"}}, "hi")285 if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil {286 t.Fatal("stranger wrote into an open room")287 }288 allowed, joins, err := f.svc.RoomWriteAllowed(f.ctx, "open", f.pub("carol"))289 if err != nil || !allowed || !joins {290 t.Fatalf("open room write = %v %v %v", allowed, joins, err)291 }292 if allowed, _, _ := f.svc.RoomWriteAllowed(f.ctx, "closed", f.pub("carol")); allowed {293 t.Fatal("members-only room open to tenant members")294 }295 if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub("carol")}); visible {296 t.Fatal("members-only room visible to outsiders")297 }298 for _, who := range []string{"alice", "owner"} {299 if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub(who)}); !visible {300 t.Fatalf("members-only room hidden from %s", who)301 }302 }303 if visible, _ := f.svc.RoomVisible(f.ctx, "open", nil); !visible {304 t.Fatal("open room hidden")305 }306}307308func TestSlugRoomMirrorsTenantRoster(t *testing.T) {309 f := newRoomFixture(t)310 room, err := f.svc.Room(f.ctx, "main")311 if err != nil || !room.Live() || room.Access != RoomOpen {312 t.Fatalf("slug room = %+v %v", room, err)313 }314 if f.svc.SlugRoom() != "main" || f.role(t, "main", "owner") != RoomRoleOwner || f.role(t, "main", "alice") != RoomRoleMember {315 t.Fatal("roster not mirrored")316 }317 if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "setmember", raws(f.pub("alice"), map[string]any{"role": "moderator"})); err != nil {318 t.Fatal(err)319 }320 if f.role(t, "main", "alice") != RoomRoleAdmin {321 t.Fatal("moderator is not a room admin")322 }323 if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "side"}}); err != nil {324 t.Fatal(err)325 }326 if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "removemember", raws(f.pub("bob"))); err != nil {327 t.Fatal(err)328 }329 var rows int330 if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM room_members WHERE pubkey=?`, f.pub("bob")).Scan(&rows); err != nil || rows != 0 {331 t.Fatalf("removed member still in rooms: %d %v", rows, err)332 }333 if err := f.svc.EnsureSlugRoom(f.ctx, "main", RoomMembers); err != nil {334 t.Fatal(err)335 }336 if room, _ := f.svc.Room(f.ctx, "main"); room.Access != RoomMembers {337 t.Fatal("slug access did not follow the read rule")338 }339 if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "transferowner", raws(f.pub("alice"))); err != nil {340 t.Fatal(err)341 }342 if err := f.svc.SetOwner(f.pub("alice")); err != nil {343 t.Fatal(err)344 }345 if f.role(t, "main", "alice") != RoomRoleOwner || f.role(t, "main", "owner") != RoomRoleAdmin {346 t.Fatalf("transfer not mirrored: alice=%s owner=%s", f.role(t, "main", "alice"), f.role(t, "main", "owner"))347 }348}349350func TestSlugRoomFirstMirrorIsSilent(t *testing.T) {351 ctx := context.Background()352 st, err := storage.Open(ctx, t.TempDir()+"/relay.db")353 if err != nil {354 t.Fatal(err)355 }356 defer st.Close()357 owner := strings.Repeat("a", 64)358 svc, err := New(ctx, st, owner)359 if err != nil {360 t.Fatal(err)361 }362 if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("b", 64), map[string]any{"role": "member"})); err != nil {363 t.Fatal(err)364 }365 if err := svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil {366 t.Fatal(err)367 }368 var notices int369 if err := st.DB().QueryRow(`SELECT COUNT(*) FROM work_intents WHERE target='room' AND json_extract(payload,'$.pubkey') IS NOT NULL`).Scan(&notices); err != nil || notices != 0 {370 t.Fatalf("initial mirror queued %d member notices (%v)", notices, err)371 }372 if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("c", 64), map[string]any{"role": "member"})); err != nil {373 t.Fatal(err)374 }375 if err := st.DB().QueryRow(`SELECT COUNT(*) FROM work_intents WHERE target='room' AND json_extract(payload,'$.pubkey')=?`, strings.Repeat("c", 64)).Scan(&notices); err != nil || notices != 1 {376 t.Fatalf("later join queued %d notices (%v)", notices, err)377 }378}