clone

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

feat/standalone-relay
tinyrelay / internal / daemon / rooms_test.go
rooms_test.go 22.8K
1package daemon23import (4 "bufio"5 "context"6 "encoding/json"7 "fmt"8 "net/http"9 "net/http/httptest"10 "strings"11 "testing"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/community"15 "github.com/FelineStateMachine/tinyrelay/internal/event"16 "github.com/FelineStateMachine/tinyrelay/internal/relay"17)1819type roomHarness struct {20 t *testing.T21 app *App22 tenant *Tenant23 ctx context.Context24 secrets map[string]string25 keys map[string]string26}2728func 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 h42}4344func rawJSON(v any) json.RawMessage {45 b, _ := json.Marshal(v)46 return b47}4849func (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 e56}5758func (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, err63}6465func (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 e72}7374// projectRooms waits for the tenant worker to finish the queued room75// 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 int81 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 return86 }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}9394func (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 rows105}106107func (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, err116 }117 raw, _ := json.Marshal(result)118 var out map[string]any119 _ = json.Unmarshal(raw, &out)120 return out, nil121}122123func 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)] = row190 }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}257258func 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)] = true278 }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}296297func 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 _ = other318 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 _ = second322}323324func 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] = true350 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 break367 }368 if page.NextCursor == cursor || len(page.Messages) == 0 {369 t.Fatal("pagination failed to advance through visible messages")370 }371 cursor = page.NextCursor372 }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}384385func TestReadRoomRemainsReadableWhenThreadExceedsTraversalLimit(t *testing.T) {386 h := newRoomHarness(t)387 root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")388 parent := root.ID389 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.ID392 }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 return403 }404 }405 t.Fatal("root missing from room")406}407408func 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()413414 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.Event423 for i := range typed.Messages {424 if typed.Messages[i].ID == message.ID {425 typedMessage = &typed.Messages[i]426 break427 }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]any440 for _, value := range legacyMessages {441 candidate, _ := value.(map[string]any)442 if candidate["id"] == message.ID {443 legacyMessage = candidate444 break445 }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}455456func 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}496497func 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()503504 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 }528529 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 return559 }560 lines <- line561 }562 }()563 var got event.Event564 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 := <-lines572 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}