tinyrelayfile
tinyrelay/internal/daemon/rooms_test.go/

internal/daemon/rooms_test.go raw
1package daemon
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/community"
15 "github.com/FelineStateMachine/tinyrelay/internal/event"
16 "github.com/FelineStateMachine/tinyrelay/internal/relay"
17)
18
19type roomHarness struct {
20 t *testing.T
21 app *App
22 tenant *Tenant
23 ctx context.Context
24 secrets map[string]string
25 keys map[string]string
26}
27
28func newRoomHarness(t *testing.T) *roomHarness {
29 t.Helper()
30 app, tenant := testTenant(t)
31 h := &roomHarness{t: t, app: app, tenant: tenant, ctx: context.Background(), secrets: map[string]string{}, keys: map[string]string{}}
32 for i, name := range []string{"owner", "alice", "bob", "stranger"} {
33 h.secrets[name] = strings.Repeat("0", 63) + string(rune('1'+i))
34 h.keys[name], _ = event.PublicKey(h.secrets[name])
35 }
36 for _, name := range []string{"alice", "bob"} {
37 if _, err := tenant.Execute(h.ctx, h.keys["owner"], "setmember", []json.RawMessage{rawJSON(h.keys[name]), rawJSON(map[string]any{"role": "member"})}); err != nil {
38 t.Fatal(err)
39 }
40 }
41 return h
42}
43
44func rawJSON(v any) json.RawMessage {
45 b, _ := json.Marshal(v)
46 return b
47}
48
49func (h *roomHarness) sign(who string, kind int, tags [][]string, content string) event.Event {
50 h.t.Helper()
51 e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}
52 if err := event.Sign(&e, h.secrets[who]); err != nil {
53 h.t.Fatal(err)
54 }
55 return e
56}
57
58func (h *roomHarness) publish(who string, kind int, tags [][]string, content string) (event.Event, error) {
59 h.t.Helper()
60 e := h.sign(who, kind, tags, content)
61 _, err := h.tenant.router.Publish(h.ctx, e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: h.tenant.RelayURL()})
62 return e, err
63}
64
65func (h *roomHarness) must(who string, kind int, tags [][]string, content string) event.Event {
66 h.t.Helper()
67 e, err := h.publish(who, kind, tags, content)
68 if err != nil {
69 h.t.Fatalf("publish kind %d from %s: %v", kind, who, err)
70 }
71 return e
72}
73
74// projectRooms waits for the tenant worker to finish the queued room
75// projections, so records and notices are observed the way clients see them.
76func (h *roomHarness) projectRooms() {
77 h.t.Helper()
78 deadline := time.Now().Add(15 * time.Second)
79 for {
80 var pending int
81 if err := h.tenant.store.DB().QueryRowContext(h.ctx, `SELECT COUNT(*) FROM work_intents WHERE kind='records-projection' AND target='room' AND state IN ('pending','running')`).Scan(&pending); err != nil {
82 h.t.Fatal(err)
83 }
84 if pending == 0 {
85 return
86 }
87 if time.Now().After(deadline) {
88 h.t.Fatalf("%d room projections still queued", pending)
89 }
90 time.Sleep(50 * time.Millisecond)
91 }
92}
93
94func (h *roomHarness) query(who string, f event.Filter) []event.Event {
95 h.t.Helper()
96 s := relay.Session{RelayURL: h.tenant.RelayURL()}
97 if who != "" {
98 s.PubKeys = []string{h.keys[who]}
99 }
100 rows, err := h.tenant.Query(h.ctx, []event.Filter{f}, s)
101 if err != nil {
102 h.t.Fatal(err)
103 }
104 return rows
105}
106
107func (h *roomHarness) browse(who, method string, params map[string]any) (map[string]any, error) {
108 h.t.Helper()
109 actor := ""
110 if who != "" {
111 actor = h.keys[who]
112 }
113 result, err := h.tenant.Execute(h.ctx, actor, method, []json.RawMessage{rawJSON(params)})
114 if err != nil {
115 return nil, err
116 }
117 raw, _ := json.Marshal(result)
118 var out map[string]any
119 _ = json.Unmarshal(raw, &out)
120 return out, nil
121}
122
123func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) {
124 h := newRoomHarness(t)
125 if _, err := h.publish("stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, ""); err == nil {
126 t.Fatal("stranger created a room")
127 }
128 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "")
129 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"name", "Secret"}, {"closed"}}, "")
130 // A tenant member joins an open room on their first message; a members-only room refuses them.
131 hello := h.must("bob", event.KIND_CHAT, [][]string{{"h", "general"}}, "hello")
132 if role, _ := h.tenant.community.RoomRole(h.ctx, "general", h.keys["bob"]); role != community.RoomRoleMember {
133 t.Fatalf("bob role = %q", role)
134 }
135 if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "secret"}}, "psst"); err == nil {
136 t.Fatal("outsider wrote into a members-only room")
137 }
138 if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "missing"}}, "?"); err == nil {
139 t.Fatal("unknown room accepted")
140 }
141 if _, err := h.publish("alice", event.KIND_ROOM_MEMBER_ADDED, [][]string{{"h", "general"}, {"p", h.keys["bob"]}}, ""); err == nil {
142 t.Fatal("client published a member notice")
143 }
144 // Presence is accepted from members and never stored.
145 h.must("bob", event.KIND_ROOM_PRESENCE, [][]string{{"h", "general"}}, "")
146 if rows := h.query("bob", event.Filter{Kinds: []int{event.KIND_ROOM_PRESENCE}}); len(rows) != 0 {
147 t.Fatalf("presence stored: %d", len(rows))
148 }
149 // Room-scoped metadata edits use room roles, not tenant moderation.
150 h.must("alice", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"about", "Everyone"}}, "")
151 if room, _ := h.tenant.community.Room(h.ctx, "general"); room.About != "Everyone" {
152 t.Fatalf("room edit lost: %+v", room)
153 }
154 if _, err := h.publish("bob", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"name", "Nope"}}, ""); err == nil {
155 t.Fatal("member edited the room")
156 }
157 // Relay records and member notices follow the durable projection.
158 h.projectRooms()
159 meta := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"general"}}})
160 if len(meta) != 1 || meta[0].PubKey != h.tenant.records.PublicKey() || event.Tag(meta[0], "name") != "General" || event.Tag(meta[0], "about") != "Everyone" {
161 t.Fatalf("39000 = %+v", meta)
162 }
163 admins := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_ADMINS}, Tags: map[string][]string{"d": {"general"}}})
164 if len(admins) != 1 || len(admins[0].Tags) < 3 || admins[0].Tags[2][1] != h.keys["alice"] || admins[0].Tags[2][2] != "owner" {
165 t.Fatalf("39001 = %+v", admins)
166 }
167 members := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_MEMBERS}, Tags: map[string][]string{"d": {"general"}}})
168 if len(members) != 1 || len(event.TagValues(members[0], "p")) != 2 {
169 t.Fatalf("39002 = %+v", members)
170 }
171 notices := h.query("", event.Filter{Kinds: []int{event.KIND_ROOM_MEMBER_ADDED}, Tags: map[string][]string{"h": {"general"}}})
172 if len(notices) != 2 || notices[0].PubKey != h.tenant.records.PublicKey() {
173 t.Fatalf("44100 notices = %+v", notices)
174 }
175 if hidden := h.query("bob", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(hidden) != 0 {
176 t.Fatal("members-only room record served to an outsider")
177 }
178 if shown := h.query("owner", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(shown) != 1 {
179 t.Fatal("members-only room record hidden from the tenant owner")
180 }
181 // Browse calls apply the same visibility.
182 list, err := h.browse("bob", "browserooms", nil)
183 if err != nil {
184 t.Fatal(err)
185 }
186 ids := map[string]map[string]any{}
187 for _, item := range list["items"].([]any) {
188 row := item.(map[string]any)
189 ids[row["id"].(string)] = row
190 }
191 if _, ok := ids["secret"]; ok || ids["general"] == nil || ids["main"] == nil {
192 t.Fatalf("rooms for bob = %v", ids)
193 }
194 if ids["general"]["members"].(float64) != 2 || ids["general"]["role"] != "member" || ids["general"]["last_message_at"].(float64) < float64(hello.CreatedAt) {
195 t.Fatalf("general summary = %v", ids["general"])
196 }
197 ownerList, _ := h.browse("owner", "browserooms", nil)
198 if len(ownerList["items"].([]any)) != 3 {
199 t.Fatalf("owner sees %d rooms", len(ownerList["items"].([]any)))
200 }
201 if _, err := h.browse("bob", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
202 t.Fatalf("members-only room browsed by an outsider: %v", err)
203 }
204 if _, err := h.browse("", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {
205 t.Fatalf("members-only room browsed anonymously: %v", err)
206 }
207 room, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2})
208 if err != nil {
209 t.Fatal(err)
210 }
211 if len(room["members"].([]any)) != 2 || room["room"].(map[string]any)["name"] != "General" {
212 t.Fatalf("room detail = %v", room)
213 }
214 messages := room["messages"].([]any)
215 if len(messages) != 2 || room["next_cursor"] == "" {
216 t.Fatalf("messages = %d cursor=%v", len(messages), room["next_cursor"])
217 }
218 more, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2, "cursor": room["next_cursor"]})
219 if err != nil || len(more["messages"].([]any)) == 0 {
220 t.Fatalf("second page = %v %v", more, err)
221 }
222 // Threads: a kind 11 root and its kind 12 replies.
223 root := h.must("alice", event.KIND_THREAD, [][]string{{"h", "general"}, {"subject", "Plans"}}, "What next?")
224 reply := h.must("bob", event.KIND_THREAD_REPLY, [][]string{{"h", "general"}, {"e", root.ID}, {"p", h.keys["alice"]}}, "Ship it")
225 thread, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID})
226 if err != nil {
227 t.Fatal(err)
228 }
229 if thread["root"].(map[string]any)["id"] != root.ID || len(thread["replies"].([]any)) != 1 || thread["replies"].([]any)[0].(map[string]any)["id"] != reply.ID {
230 t.Fatalf("thread = %v", thread)
231 }
232 if _, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": hello.ID}); err != nil {
233 t.Fatalf("chat roots are threadable: %v", err)
234 }
235 // Device notices name the room and link to it.
236 mention := h.sign("bob", event.KIND_CHAT, [][]string{{"h", "general"}, {"p", h.keys["alice"]}}, "ping alice")
237 pushes := h.tenant.pushNotices(h.ctx, mention)
238 if len(pushes) != 1 || pushes[0].recipient != h.keys["alice"] || pushes[0].category != pushMentions || pushes[0].body != "Mentioned you in General: ping alice" || !strings.HasSuffix(pushes[0].url, "/rooms/general") {
239 t.Fatalf("push notices = %+v", pushes)
240 }
241 if pushes := h.tenant.pushNotices(h.ctx, reply); len(pushes) != 1 || pushes[0].category != pushReplies || !strings.HasPrefix(pushes[0].body, "Replied to you in General:") {
242 t.Fatalf("reply notices = %+v", pushes)
243 }
244 // Deleting the room retires its records and history.
245 if _, err := h.publish("bob", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, ""); err == nil {
246 t.Fatal("member deleted the room")
247 }
248 h.must("alice", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, "")
249 h.projectRooms()
250 if rows := h.query("owner", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA, event.KIND_CHAT}, Tags: map[string][]string{"d": {"general"}}}); len(rows) != 0 {
251 t.Fatalf("deleted room records remain: %d", len(rows))
252 }
253 if _, err := h.browse("alice", "browseroom", map[string]any{"id": "general"}); err == nil {
254 t.Fatal("deleted room still browsable")
255 }
256}
257
258func TestBuzzKindNineRepliesUseNIP10ThreadRoots(t *testing.T) {
259 h := newRoomHarness(t)
260 root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "Buzz root")
261 parent := h.must("bob", event.KIND_CHAT, [][]string{
262 {"h", "main"}, {"e", root.ID, "", "reply"}, {"e", root.ID, "", "root"}, {"p", h.keys["alice"]},
263 }, "Buzz reply")
264 nested := h.must("alice", event.KIND_CHAT, [][]string{
265 {"h", "main"}, {"e", parent.ID, "", "reply"}, {"p", h.keys["bob"]},
266 }, "Nested reply")
267 thread, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 10})
268 if err != nil {
269 t.Fatal(err)
270 }
271 replies := thread["replies"].([]any)
272 if len(replies) != 2 {
273 t.Fatalf("Buzz thread replies = %d, want 2: %v", len(replies), replies)
274 }
275 seen := map[string]bool{}
276 for _, row := range replies {
277 seen[row.(map[string]any)["id"].(string)] = true
278 }
279 if !seen[parent.ID] || !seen[nested.ID] {
280 t.Fatalf("Buzz thread omitted replies: %v", seen)
281 }
282 room, err := h.browse("bob", "browseroom", map[string]any{"id": "main", "limit": 100})
283 if err != nil {
284 t.Fatal(err)
285 }
286 roomSeen := map[string]int{}
287 for _, row := range room["messages"].([]any) {
288 roomSeen[row.(map[string]any)["id"].(string)]++
289 }
290 for _, id := range []string{root.ID, parent.ID, nested.ID} {
291 if roomSeen[id] != 1 {
292 t.Fatalf("room timeline count for %s = %d, want 1", id, roomSeen[id])
293 }
294 }
295}
296
297func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T) {
298 h := newRoomHarness(t)
299 root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")
300 first := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "first")
301 second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", first.ID, "", "reply"}}, "second")
302 page, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1})
303 if err != nil {
304 t.Fatal(err)
305 }
306 if len(page["replies"].([]any)) != 1 || page["next_cursor"] == "" {
307 t.Fatalf("first thread page = %v", page)
308 }
309 more, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1, "cursor": page["next_cursor"]})
310 if err != nil || len(more["replies"].([]any)) != 1 {
311 t.Fatalf("second thread page = %v (%v)", more, err)
312 }
313 if page["replies"].([]any)[0].(map[string]any)["id"] == more["replies"].([]any)[0].(map[string]any)["id"] {
314 t.Fatal("thread cursor repeated a reply")
315 }
316 other := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "other"}, {"visibility", "open"}}, "")
317 _ = other
318 if _, err := h.browse("bob", "browsethread", map[string]any{"id": "other", "event": root.ID}); err == nil {
319 t.Fatal("cross-room root was accepted")
320 }
321 _ = second
322}
323
324func TestReadRoomPagesTopLevelRootsPastFoldedReplies(t *testing.T) {
325 h := newRoomHarness(t)
326 older := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "older root")
327 root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root before reply burst")
328 for i := 0; i < 105; i++ {
329 h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, fmt.Sprintf("folded reply %d", i))
330 }
331 h.must("alice", 7, [][]string{{"h", "main"}, {"e", root.ID}}, "+")
332 olderReply := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", older.ID, "", "reply"}}, "reply to older root")
333 h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", olderReply.ID, "", "reply"}}, "nested reply to older root")
334 h.must("bob", 7, [][]string{{"h", "main"}, {"e", older.ID}}, "heart")
335 seen := map[string]bool{}
336 cursor := ""
337 for pages := 0; ; pages++ {
338 page, err := h.tenant.ReadRoom(h.ctx, h.keys["bob"], "main", cursor, 1)
339 if err != nil {
340 t.Fatal(err)
341 }
342 if len(page.Messages) > 1 || pages > 20 {
343 t.Fatalf("unbounded timeline page: %d messages on page %d", len(page.Messages), pages)
344 }
345 for _, row := range page.Messages {
346 if seen[row.ID] {
347 t.Fatalf("repeated message across tied timestamp pages: %s", row.ID)
348 }
349 seen[row.ID] = true
350 switch row.ID {
351 case root.ID:
352 if page.ReplyCounts[root.ID] != 105 || page.ReactionCounts[root.ID]["+1"] != 1 {
353 t.Fatalf("burst summaries missing: %+v", page)
354 }
355 case older.ID:
356 if page.ReplyCounts[older.ID] != 2 || page.ReactionCounts[older.ID]["heart"] != 1 {
357 t.Fatalf("older root summaries missing: %+v", page)
358 }
359 default:
360 if row.Kind != event.KIND_ROOM_MEMBER_ADDED && row.Kind != event.KIND_ROOM_MEMBER_REMOVED {
361 t.Fatalf("unfolded event on timeline: %d %s", row.Kind, row.ID)
362 }
363 }
364 }
365 if page.NextCursor == "" {
366 break
367 }
368 if page.NextCursor == cursor || len(page.Messages) == 0 {
369 t.Fatal("pagination failed to advance through visible messages")
370 }
371 cursor = page.NextCursor
372 }
373 if !seen[root.ID] || !seen[older.ID] {
374 t.Fatalf("timeline skipped roots after folded reply burst: %v", seen)
375 }
376 thread, err := h.tenant.ReadThread(h.ctx, h.keys["bob"], "main", root.ID, "", 1)
377 if err != nil {
378 t.Fatal(err)
379 }
380 if thread.ReplyCounts[root.ID] != 105 || len(thread.Replies) != 1 {
381 t.Fatalf("thread folded count = %d replies=%d", thread.ReplyCounts[root.ID], len(thread.Replies))
382 }
383}
384
385func TestReadRoomRemainsReadableWhenThreadExceedsTraversalLimit(t *testing.T) {
386 h := newRoomHarness(t)
387 root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")
388 parent := root.ID
389 for i := 0; i < 65; i++ {
390 row := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", parent, "", "reply"}}, "deep reply")
391 parent = row.ID
392 }
393 page, err := h.tenant.ReadRoom(h.ctx, h.keys["alice"], "main", "", 50)
394 if err != nil {
395 t.Fatalf("one bounded thread blocked the room: %v", err)
396 }
397 for _, row := range page.Messages {
398 if row.ID == root.ID {
399 if _, found := page.ReplyCounts[root.ID]; found {
400 t.Fatal("a partial reply count was presented as complete")
401 }
402 return
403 }
404 }
405 t.Fatal("root missing from room")
406}
407
408func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) {
409 h := newRoomHarness(t)
410 message := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"imeta", "url https://files.example/report.pdf", "m application/pdf", "filename report.pdf"}}, "typed room read")
411 edit := h.must("alice", event.KIND_CONTENT_EDIT, [][]string{{"h", "main"}, {"e", message.ID}}, "typed room edit")
412 h.projectRooms()
413
414 typed, err := h.tenant.ReadRoom(h.ctx, h.keys["alice"], "main", "", 100)
415 if err != nil {
416 t.Fatal(err)
417 }
418 legacyValue, err := h.browse(h.keys["alice"], "browseroom", map[string]any{"id": "main", "limit": 100})
419 if err != nil {
420 t.Fatal(err)
421 }
422 var typedMessage *event.Event
423 for i := range typed.Messages {
424 if typed.Messages[i].ID == message.ID {
425 typedMessage = &typed.Messages[i]
426 break
427 }
428 }
429 if typedMessage == nil || typedMessage.Sig != message.Sig || len(typedMessage.Tags) != 2 {
430 t.Fatalf("typed messages lost signed event: %+v", typed.Messages)
431 }
432 if len(typed.Edits) != 1 || typed.Edits[0].ID != edit.ID || typed.Edits[0].Sig != edit.Sig {
433 t.Fatalf("typed edits lost latest signed edit: %+v", typed.Edits)
434 }
435 legacyMessages := legacyValue["messages"].([]any)
436 if len(legacyMessages) == 0 {
437 t.Fatal("legacy messages empty")
438 }
439 var legacyMessage map[string]any
440 for _, value := range legacyMessages {
441 candidate, _ := value.(map[string]any)
442 if candidate["id"] == message.ID {
443 legacyMessage = candidate
444 break
445 }
446 }
447 if legacyMessage == nil || legacyMessage["sig"] != message.Sig {
448 t.Fatalf("legacy messages lost signed event: %v", legacyMessages)
449 }
450 legacyEdits := legacyValue["edits"].([]any)
451 if len(legacyEdits) != 1 || legacyEdits[0].(map[string]any)["sig"] != edit.Sig {
452 t.Fatalf("legacy edits lost latest signed edit: %v", legacyEdits)
453 }
454}
455
456func TestRoomsPolicyCapAndSlugMirror(t *testing.T) {
457 h := newRoomHarness(t)
458 if h.tenant.Policy().Rooms != 64 {
459 t.Fatalf("default room allowance = %d", h.tenant.Policy().Rooms)
460 }
461 if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": 1})}); err != nil {
462 t.Fatal(err)
463 }
464 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "one"}}, "")
465 if _, err := h.publish("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "two"}}, ""); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") {
466 t.Fatalf("cap not enforced: %v", err)
467 }
468 if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": -1})}); err == nil {
469 t.Fatal("negative room allowance accepted")
470 }
471 slug, err := h.tenant.community.Room(h.ctx, "main")
472 if err != nil || !slug.Live() || slug.Access != community.RoomOpen {
473 t.Fatalf("slug room = %+v %v", slug, err)
474 }
475 if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != community.RoomRoleMember {
476 t.Fatalf("alice slug role = %q", role)
477 }
478 p := h.tenant.Policy()
479 p.Reads = "members"
480 if err := h.tenant.applyPolicy(h.ctx, p); err != nil {
481 t.Fatal(err)
482 }
483 if slug, _ := h.tenant.community.Room(h.ctx, "main"); slug.Access != community.RoomMembers {
484 t.Fatalf("slug access after policy change = %q", slug.Access)
485 }
486 if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "removemember", []json.RawMessage{rawJSON(h.keys["alice"])}); err != nil {
487 t.Fatal(err)
488 }
489 if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != "" {
490 t.Fatalf("removed member keeps slug role %q", role)
491 }
492 if role, _ := h.tenant.community.RoomRole(h.ctx, "one", h.keys["alice"]); role != "" {
493 t.Fatalf("removed member keeps room role %q", role)
494 }
495}
496
497func TestRoomStreamDeliversLiveMessages(t *testing.T) {
498 h := newRoomHarness(t)
499 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "")
500 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")
501 server := httptest.NewServer(h.app)
502 defer server.Close()
503
504 anonymous, err := http.Get(server.URL + "/rooms/general/stream")
505 if err != nil {
506 t.Fatal(err)
507 }
508 anonymous.Body.Close()
509 if anonymous.StatusCode != http.StatusOK {
510 t.Fatalf("open room stream without a session: %d", anonymous.StatusCode)
511 }
512 closed, err := http.Get(server.URL + "/rooms/secret/stream")
513 if err != nil {
514 t.Fatal(err)
515 }
516 closed.Body.Close()
517 if closed.StatusCode != http.StatusUnauthorized {
518 t.Fatalf("members-only room stream without a session: %d", closed.StatusCode)
519 }
520 missing, err := http.Get(server.URL + "/rooms/missing/stream")
521 if err != nil {
522 t.Fatal(err)
523 }
524 missing.Body.Close()
525 if missing.StatusCode != http.StatusNotFound {
526 t.Fatalf("unknown room stream: %d", missing.StatusCode)
527 }
528
529 ctx, cancel := context.WithCancel(context.Background())
530 defer cancel()
531 req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/rooms/general/stream", nil)
532 if err != nil {
533 t.Fatal(err)
534 }
535 signRequest(t, req, "")
536 res, err := http.DefaultClient.Do(req)
537 if err != nil {
538 t.Fatal(err)
539 }
540 defer res.Body.Close()
541 if res.StatusCode != http.StatusOK || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/event-stream") {
542 t.Fatalf("stream %d %s", res.StatusCode, res.Header.Get("Content-Type"))
543 }
544 reader := bufio.NewReader(res.Body)
545 line, err := reader.ReadString('\n')
546 if err != nil || line != ": room general\n" {
547 t.Fatalf("opening comment %q %v", line, err)
548 }
549 sent := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "live")
550 h.must("alice", event.KIND_CHAT, [][]string{{"h", "secret"}}, "elsewhere")
551 deadline := time.After(5 * time.Second)
552 lines := make(chan string, 8)
553 go func() {
554 for {
555 line, err := reader.ReadString('\n')
556 if err != nil {
557 close(lines)
558 return
559 }
560 lines <- line
561 }
562 }()
563 var got event.Event
564 for got.ID == "" {
565 select {
566 case line, ok := <-lines:
567 if !ok {
568 t.Fatal("stream ended early")
569 }
570 if line == "event: message\n" {
571 data := <-lines
572 if err := json.Unmarshal([]byte(strings.TrimPrefix(data, "data: ")), &got); err != nil {
573 t.Fatalf("frame %q: %v", data, err)
574 }
575 }
576 case <-deadline:
577 t.Fatal("no message within 5s")
578 }
579 }
580 if got.ID != sent.ID || got.Content != "live" {
581 t.Fatalf("streamed %+v, want %s", got, sent.ID)
582 }
583 cancel()
584 select {
585 case _, ok := <-lines:
586 if ok {
587 // Drain until the server closes the body.
588 for range lines {
589 }
590 }
591 case <-time.After(5 * time.Second):
592 t.Fatal("stream did not close after cancel")
593 }
594}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs