| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 19 | type 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 | |
| 28 | func 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 | |
| 44 | func rawJSON(v any) json.RawMessage { |
| 45 | b, _ := json.Marshal(v) |
| 46 | return b |
| 47 | } |
| 48 | |
| 49 | func (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 | |
| 58 | func (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 | |
| 65 | func (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. |
| 76 | func (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 | |
| 94 | func (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 | |
| 107 | func (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 | |
| 123 | func 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 | |
| 258 | func 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 | |
| 297 | func 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 | |
| 324 | func 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 | |
| 385 | func 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 | |
| 408 | func 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 | |
| 456 | func 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 | |
| 497 | func 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 | } |