tinyrelayfile
tinyrelay/internal/community/rooms_test.go/

internal/community/rooms_test.go raw
1package community
2
3import (
4 "context"
5 "database/sql"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/FelineStateMachine/tinyrelay/internal/event"
11 "github.com/FelineStateMachine/tinyrelay/internal/policy"
12 "github.com/FelineStateMachine/tinyrelay/internal/storage"
13)
14
15type roomFixture struct {
16 ctx context.Context
17 store *storage.Store
18 svc *Service
19 rooms int
20 keys map[string]string
21}
22
23func 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.rooms
42 return p
43 })
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 f
53}
54
55func (f *roomFixture) pub(name string) string {
56 pk, _ := event.PublicKey(f.keys[name])
57 return pk
58}
59
60func (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 e
67}
68
69func (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 err
73 }
74}
75
76// 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}
82
83func (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 e
90}
91
92func (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 role
99}
100
101func TestRoomCreationHonoursMembershipAndCap(t *testing.T) {
102 f := newRoomFixture(t)
103 f.rooms = 1
104 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 = 0
130 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 = 64
134 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}
145
146func 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 int
234 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}
267
268func 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}
307
308func 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 int
330 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}
349
350func 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 int
369 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs