| 1 | diff --git a/internal/community/rooms_test.go b/internal/community/rooms_test.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..270477f |
| 4 | --- /dev/null |
| 5 | +++ b/internal/community/rooms_test.go |
| 6 | @@ -0,0 +1,378 @@ |
| 7 | +package community |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "database/sql" |
| 12 | + "strings" |
| 13 | + "testing" |
| 14 | + "time" |
| 15 | + |
| 16 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 17 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 18 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 19 | +) |
| 20 | + |
| 21 | +type roomFixture struct { |
| 22 | + ctx context.Context |
| 23 | + store *storage.Store |
| 24 | + svc *Service |
| 25 | + rooms int |
| 26 | + keys map[string]string |
| 27 | +} |
| 28 | + |
| 29 | +func newRoomFixture(t *testing.T) *roomFixture { |
| 30 | + t.Helper() |
| 31 | + ctx := context.Background() |
| 32 | + st, err := storage.Open(ctx, t.TempDir()+"/relay.db") |
| 33 | + if err != nil { |
| 34 | + t.Fatal(err) |
| 35 | + } |
| 36 | + t.Cleanup(func() { _ = st.Close() }) |
| 37 | + f := &roomFixture{ctx: ctx, store: st, rooms: 64, keys: map[string]string{}} |
| 38 | + for i, name := range []string{"owner", "alice", "bob", "carol", "dave", "stranger"} { |
| 39 | + f.keys[name] = strings.Repeat("0", 63) + string(rune('1'+i)) |
| 40 | + } |
| 41 | + f.svc, err = New(ctx, st, f.pub("owner")) |
| 42 | + if err != nil { |
| 43 | + t.Fatal(err) |
| 44 | + } |
| 45 | + f.svc.ConfigurePolicy(func() policy.Policy { |
| 46 | + p := policy.Defaults(f.pub("owner")) |
| 47 | + p.Rooms = f.rooms |
| 48 | + return p |
| 49 | + }) |
| 50 | + if err := f.svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil { |
| 51 | + t.Fatal(err) |
| 52 | + } |
| 53 | + for _, name := range []string{"alice", "bob", "carol", "dave"} { |
| 54 | + if _, err := f.svc.Execute(ctx, f.pub("owner"), "setmember", raws(f.pub(name), map[string]any{"role": "member"})); err != nil { |
| 55 | + t.Fatal(err) |
| 56 | + } |
| 57 | + } |
| 58 | + return f |
| 59 | +} |
| 60 | + |
| 61 | +func (f *roomFixture) pub(name string) string { |
| 62 | + pk, _ := event.PublicKey(f.keys[name]) |
| 63 | + return pk |
| 64 | +} |
| 65 | + |
| 66 | +func (f *roomFixture) event(t *testing.T, who string, kind int, tags [][]string, content string) event.Event { |
| 67 | + t.Helper() |
| 68 | + e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content} |
| 69 | + if err := event.Sign(&e, f.keys[who]); err != nil { |
| 70 | + t.Fatal(err) |
| 71 | + } |
| 72 | + return e |
| 73 | +} |
| 74 | + |
| 75 | +func (f *roomFixture) persist(e event.Event) func(*sql.Tx) error { |
| 76 | + return func(tx *sql.Tx) error { |
| 77 | + _, err := storage.SaveTx(f.ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()}) |
| 78 | + return err |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +// send applies a room management event and returns the handler error. |
| 83 | +func (f *roomFixture) send(t *testing.T, who string, kind int, tags [][]string) (MembershipResult, error) { |
| 84 | + t.Helper() |
| 85 | + e := f.event(t, who, kind, tags, "") |
| 86 | + return f.svc.HandleRoomEventTx(f.ctx, e, f.persist(e)) |
| 87 | +} |
| 88 | + |
| 89 | +func (f *roomFixture) message(t *testing.T, who, room, text string) event.Event { |
| 90 | + t.Helper() |
| 91 | + e := f.event(t, who, event.KIND_CHAT, [][]string{{"h", room}}, text) |
| 92 | + if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err != nil { |
| 93 | + t.Fatalf("message from %s in %s: %v", who, room, err) |
| 94 | + } |
| 95 | + return e |
| 96 | +} |
| 97 | + |
| 98 | +func (f *roomFixture) role(t *testing.T, room, who string) string { |
| 99 | + t.Helper() |
| 100 | + role, err := f.svc.RoomRole(f.ctx, room, f.pub(who)) |
| 101 | + if err != nil { |
| 102 | + t.Fatal(err) |
| 103 | + } |
| 104 | + return role |
| 105 | +} |
| 106 | + |
| 107 | +func TestRoomCreationHonoursMembershipAndCap(t *testing.T) { |
| 108 | + f := newRoomFixture(t) |
| 109 | + f.rooms = 1 |
| 110 | + if _, err := f.send(t, "stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { |
| 111 | + t.Fatalf("stranger created a room: %v", err) |
| 112 | + } |
| 113 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "Bad Room"}, {"name", "x"}}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") { |
| 114 | + t.Fatalf("invalid id accepted: %v", err) |
| 115 | + } |
| 116 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "main"}}); err == nil { |
| 117 | + t.Fatal("slug room recreated") |
| 118 | + } |
| 119 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}, {"about", "Talk"}, {"channel_type", "public"}}); err != nil { |
| 120 | + t.Fatal(err) |
| 121 | + } |
| 122 | + room, err := f.svc.Room(f.ctx, "general") |
| 123 | + if err != nil || !room.Live() || room.Name != "General" || room.About != "Talk" || room.Access != RoomOpen || room.CreatedBy != f.pub("alice") || room.EventID == "" { |
| 124 | + t.Fatalf("room = %+v err=%v", room, err) |
| 125 | + } |
| 126 | + if f.role(t, "general", "alice") != RoomRoleOwner { |
| 127 | + t.Fatal("creator is not the room owner") |
| 128 | + } |
| 129 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}); err == nil || !strings.HasPrefix(err.Error(), "duplicate:") { |
| 130 | + t.Fatalf("duplicate room: %v", err) |
| 131 | + } |
| 132 | + if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "second"}}); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") { |
| 133 | + t.Fatalf("cap not applied: %v", err) |
| 134 | + } |
| 135 | + f.rooms = 0 |
| 136 | + if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "third"}}); err == nil || !strings.Contains(err.Error(), "switched off") { |
| 137 | + t.Fatalf("rooms off not applied: %v", err) |
| 138 | + } |
| 139 | + f.rooms = 64 |
| 140 | + if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"visibility", "members"}}); err != nil { |
| 141 | + t.Fatal(err) |
| 142 | + } |
| 143 | + if room, _ := f.svc.Room(f.ctx, "team"); room.Access != RoomMembers { |
| 144 | + t.Fatalf("visibility tag ignored: %+v", room) |
| 145 | + } |
| 146 | + rooms, err := f.svc.Rooms(f.ctx) |
| 147 | + if err != nil || len(rooms) != 3 { |
| 148 | + t.Fatalf("rooms = %v err=%v", rooms, err) |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +func TestRoomAdminEventsFollowRoomRoles(t *testing.T) { |
| 153 | + f := newRoomFixture(t) |
| 154 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"closed"}}); err != nil { |
| 155 | + t.Fatal(err) |
| 156 | + } |
| 157 | + // Metadata edits belong to room admins; the tenant owner may always moderate. |
| 158 | + if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Hijacked"}}); err == nil { |
| 159 | + t.Fatal("outsider edited the room") |
| 160 | + } |
| 161 | + if _, err := f.send(t, "owner", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"about", "Moderated"}}); err != nil { |
| 162 | + t.Fatal(err) |
| 163 | + } |
| 164 | + if _, err := f.send(t, "alice", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Team Chat"}}); err != nil { |
| 165 | + t.Fatal(err) |
| 166 | + } |
| 167 | + if room, _ := f.svc.Room(f.ctx, "team"); room.Name != "Team Chat" || room.About != "Moderated" || room.Access != RoomMembers { |
| 168 | + t.Fatalf("edit lost: %+v", room) |
| 169 | + } |
| 170 | + // Adding people: tenant membership is required and owner appointments are reserved. |
| 171 | + if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("stranger")}}); err == nil { |
| 172 | + t.Fatal("non-member added to a room") |
| 173 | + } |
| 174 | + if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "admin"}}); err != nil { |
| 175 | + t.Fatal(err) |
| 176 | + } |
| 177 | + if f.role(t, "team", "bob") != RoomRoleAdmin { |
| 178 | + t.Fatal("admin role not applied") |
| 179 | + } |
| 180 | + if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol"), "owner"}}); err == nil { |
| 181 | + t.Fatal("admin appointed an owner") |
| 182 | + } |
| 183 | + if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil { |
| 184 | + t.Fatal(err) |
| 185 | + } |
| 186 | + if _, err := f.send(t, "carol", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("dave")}}); err == nil { |
| 187 | + t.Fatal("member added someone") |
| 188 | + } |
| 189 | + // Removal: admins cannot remove owners and the last owner stays. |
| 190 | + if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("alice")}}); err == nil { |
| 191 | + t.Fatal("admin removed the owner") |
| 192 | + } |
| 193 | + if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "at least one owner") { |
| 194 | + t.Fatalf("last owner left: %v", err) |
| 195 | + } |
| 196 | + if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "owner"}}); err != nil { |
| 197 | + t.Fatal(err) |
| 198 | + } |
| 199 | + if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err != nil { |
| 200 | + t.Fatal(err) |
| 201 | + } |
| 202 | + if f.role(t, "team", "alice") != "" || f.role(t, "team", "bob") != RoomRoleOwner { |
| 203 | + t.Fatal("ownership hand-over failed") |
| 204 | + } |
| 205 | + if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("bob")}}); err == nil { |
| 206 | + t.Fatal("last owner removed themselves") |
| 207 | + } |
| 208 | + if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil { |
| 209 | + t.Fatal(err) |
| 210 | + } |
| 211 | + // Join requests: members-only rooms refuse them, open rooms take tenant members. |
| 212 | + if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "members-only") { |
| 213 | + t.Fatalf("joined a members-only room: %v", err) |
| 214 | + } |
| 215 | + if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"open"}}); err != nil { |
| 216 | + t.Fatal(err) |
| 217 | + } |
| 218 | + if _, err := f.send(t, "stranger", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil { |
| 219 | + t.Fatal("stranger joined") |
| 220 | + } |
| 221 | + if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil { |
| 222 | + t.Fatal(err) |
| 223 | + } |
| 224 | + if res, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil || res.Stored || !strings.HasPrefix(res.Message, "duplicate:") { |
| 225 | + t.Fatalf("second join = %+v %v", res, err) |
| 226 | + } |
| 227 | + // Deleting events: authors and admins only, and only inside this room. |
| 228 | + own := f.message(t, "carol", "team", "mine") |
| 229 | + other := f.message(t, "bob", "team", "theirs") |
| 230 | + if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err == nil { |
| 231 | + t.Fatal("member deleted another member's message") |
| 232 | + } |
| 233 | + if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", own.ID}}); err != nil { |
| 234 | + t.Fatal(err) |
| 235 | + } |
| 236 | + if _, err := f.send(t, "bob", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err != nil { |
| 237 | + t.Fatal(err) |
| 238 | + } |
| 239 | + var stored int |
| 240 | + if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id IN (?,?)`, own.ID, other.ID).Scan(&stored); err != nil || stored != 0 { |
| 241 | + t.Fatalf("messages remain: %d %v", stored, err) |
| 242 | + } |
| 243 | + // Deleting the room: owners only, never the slug room, and the history goes. |
| 244 | + left := f.message(t, "carol", "team", "still here") |
| 245 | + if _, err := f.send(t, "carol", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err == nil { |
| 246 | + t.Fatal("member deleted the room") |
| 247 | + } |
| 248 | + if _, err := f.send(t, "owner", event.KIND_DELETE_GROUP, [][]string{{"h", "main"}}); err == nil { |
| 249 | + t.Fatal("slug room deleted") |
| 250 | + } |
| 251 | + if _, err := f.send(t, "bob", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err != nil { |
| 252 | + t.Fatal(err) |
| 253 | + } |
| 254 | + if room, _ := f.svc.Room(f.ctx, "team"); room.Live() { |
| 255 | + t.Fatal("room still live") |
| 256 | + } |
| 257 | + if n, _ := f.svc.RoomMemberCount(f.ctx, "team"); n != 0 { |
| 258 | + t.Fatalf("members remain: %d", n) |
| 259 | + } |
| 260 | + if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id=?`, left.ID).Scan(&stored); err != nil || stored != 0 { |
| 261 | + t.Fatalf("history remains: %d %v", stored, err) |
| 262 | + } |
| 263 | + if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil { |
| 264 | + t.Fatal("joined a deleted room") |
| 265 | + } |
| 266 | + if _, err := f.send(t, "dave", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Reborn"}}); err != nil { |
| 267 | + t.Fatalf("deleted id not reusable: %v", err) |
| 268 | + } |
| 269 | + if f.role(t, "team", "dave") != RoomRoleOwner || f.role(t, "team", "bob") != "" { |
| 270 | + t.Fatal("recreated room kept old members") |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +func TestRoomMessagesJoinOpenRoomsAndRespectMembersOnly(t *testing.T) { |
| 275 | + f := newRoomFixture(t) |
| 276 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "open"}}); err != nil { |
| 277 | + t.Fatal(err) |
| 278 | + } |
| 279 | + if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "closed"}, {"closed"}}); err != nil { |
| 280 | + t.Fatal(err) |
| 281 | + } |
| 282 | + f.message(t, "bob", "open", "hello") |
| 283 | + if f.role(t, "open", "bob") != RoomRoleMember { |
| 284 | + t.Fatal("first message did not join the open room") |
| 285 | + } |
| 286 | + e := f.event(t, "bob", event.KIND_CHAT, [][]string{{"h", "closed"}}, "psst") |
| 287 | + if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { |
| 288 | + t.Fatalf("outsider wrote into a members-only room: %v", err) |
| 289 | + } |
| 290 | + e = f.event(t, "stranger", event.KIND_CHAT, [][]string{{"h", "open"}}, "hi") |
| 291 | + if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil { |
| 292 | + t.Fatal("stranger wrote into an open room") |
| 293 | + } |
| 294 | + allowed, joins, err := f.svc.RoomWriteAllowed(f.ctx, "open", f.pub("carol")) |
| 295 | + if err != nil || !allowed || !joins { |
| 296 | + t.Fatalf("open room write = %v %v %v", allowed, joins, err) |
| 297 | + } |
| 298 | + if allowed, _, _ := f.svc.RoomWriteAllowed(f.ctx, "closed", f.pub("carol")); allowed { |
| 299 | + t.Fatal("members-only room open to tenant members") |
| 300 | + } |
| 301 | + if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub("carol")}); visible { |
| 302 | + t.Fatal("members-only room visible to outsiders") |
| 303 | + } |
| 304 | + for _, who := range []string{"alice", "owner"} { |
| 305 | + if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub(who)}); !visible { |
| 306 | + t.Fatalf("members-only room hidden from %s", who) |
| 307 | + } |
| 308 | + } |
| 309 | + if visible, _ := f.svc.RoomVisible(f.ctx, "open", nil); !visible { |
| 310 | + t.Fatal("open room hidden") |
| 311 | + } |
| 312 | +} |
| 313 | + |
| 314 | +func TestSlugRoomMirrorsTenantRoster(t *testing.T) { |
| 315 | + f := newRoomFixture(t) |
| 316 | + room, err := f.svc.Room(f.ctx, "main") |
| 317 | + if err != nil || !room.Live() || room.Access != RoomOpen { |
| 318 | + t.Fatalf("slug room = %+v %v", room, err) |
| 319 | + } |
| 320 | + if f.svc.SlugRoom() != "main" || f.role(t, "main", "owner") != RoomRoleOwner || f.role(t, "main", "alice") != RoomRoleMember { |
| 321 | + t.Fatal("roster not mirrored") |
| 322 | + } |
| 323 | + if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "setmember", raws(f.pub("alice"), map[string]any{"role": "moderator"})); err != nil { |
| 324 | + t.Fatal(err) |
| 325 | + } |
| 326 | + if f.role(t, "main", "alice") != RoomRoleAdmin { |
| 327 | + t.Fatal("moderator is not a room admin") |
| 328 | + } |
| 329 | + if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "side"}}); err != nil { |
| 330 | + t.Fatal(err) |
| 331 | + } |
| 332 | + if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "removemember", raws(f.pub("bob"))); err != nil { |
| 333 | + t.Fatal(err) |
| 334 | + } |
| 335 | + var rows int |
| 336 | + if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM room_members WHERE pubkey=?`, f.pub("bob")).Scan(&rows); err != nil || rows != 0 { |
| 337 | + t.Fatalf("removed member still in rooms: %d %v", rows, err) |
| 338 | + } |
| 339 | + if err := f.svc.EnsureSlugRoom(f.ctx, "main", RoomMembers); err != nil { |
| 340 | + t.Fatal(err) |
| 341 | + } |
| 342 | + if room, _ := f.svc.Room(f.ctx, "main"); room.Access != RoomMembers { |
| 343 | + t.Fatal("slug access did not follow the read rule") |
| 344 | + } |
| 345 | + if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "transferowner", raws(f.pub("alice"))); err != nil { |
| 346 | + t.Fatal(err) |
| 347 | + } |
| 348 | + if err := f.svc.SetOwner(f.pub("alice")); err != nil { |
| 349 | + t.Fatal(err) |
| 350 | + } |
| 351 | + if f.role(t, "main", "alice") != RoomRoleOwner || f.role(t, "main", "owner") != RoomRoleAdmin { |
| 352 | + t.Fatalf("transfer not mirrored: alice=%s owner=%s", f.role(t, "main", "alice"), f.role(t, "main", "owner")) |
| 353 | + } |
| 354 | +} |
| 355 | + |
| 356 | +func TestSlugRoomFirstMirrorIsSilent(t *testing.T) { |
| 357 | + ctx := context.Background() |
| 358 | + st, err := storage.Open(ctx, t.TempDir()+"/relay.db") |
| 359 | + if err != nil { |
| 360 | + t.Fatal(err) |
| 361 | + } |
| 362 | + defer st.Close() |
| 363 | + owner := strings.Repeat("a", 64) |
| 364 | + svc, err := New(ctx, st, owner) |
| 365 | + if err != nil { |
| 366 | + t.Fatal(err) |
| 367 | + } |
| 368 | + if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("b", 64), map[string]any{"role": "member"})); err != nil { |
| 369 | + t.Fatal(err) |
| 370 | + } |
| 371 | + if err := svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil { |
| 372 | + t.Fatal(err) |
| 373 | + } |
| 374 | + var notices int |
| 375 | + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM work_intents WHERE target='room' AND json_extract(payload,'$.pubkey') IS NOT NULL`).Scan(¬ices); err != nil || notices != 0 { |
| 376 | + t.Fatalf("initial mirror queued %d member notices (%v)", notices, err) |
| 377 | + } |
| 378 | + if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("c", 64), map[string]any{"role": "member"})); err != nil { |
| 379 | + t.Fatal(err) |
| 380 | + } |
| 381 | + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM work_intents WHERE target='room' AND json_extract(payload,'$.pubkey')=?`, strings.Repeat("c", 64)).Scan(¬ices); err != nil || notices != 1 { |
| 382 | + t.Fatalf("later join queued %d notices (%v)", notices, err) |
| 383 | + } |
| 384 | +} |
| 385 | diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go |
| 386 | new file mode 100644 |
| 387 | index 0000000..974cc18 |
| 388 | --- /dev/null |
| 389 | +++ b/internal/daemon/rooms_test.go |
| 390 | @@ -0,0 +1,395 @@ |
| 391 | +package daemon |
| 392 | + |
| 393 | +import ( |
| 394 | + "bufio" |
| 395 | + "context" |
| 396 | + "encoding/json" |
| 397 | + "net/http" |
| 398 | + "net/http/httptest" |
| 399 | + "strings" |
| 400 | + "testing" |
| 401 | + "time" |
| 402 | + |
| 403 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 404 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 405 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 406 | +) |
| 407 | + |
| 408 | +type roomHarness struct { |
| 409 | + t *testing.T |
| 410 | + app *App |
| 411 | + tenant *Tenant |
| 412 | + ctx context.Context |
| 413 | + secrets map[string]string |
| 414 | + keys map[string]string |
| 415 | +} |
| 416 | + |
| 417 | +func newRoomHarness(t *testing.T) *roomHarness { |
| 418 | + t.Helper() |
| 419 | + app, tenant := testTenant(t) |
| 420 | + h := &roomHarness{t: t, app: app, tenant: tenant, ctx: context.Background(), secrets: map[string]string{}, keys: map[string]string{}} |
| 421 | + for i, name := range []string{"owner", "alice", "bob", "stranger"} { |
| 422 | + h.secrets[name] = strings.Repeat("0", 63) + string(rune('1'+i)) |
| 423 | + h.keys[name], _ = event.PublicKey(h.secrets[name]) |
| 424 | + } |
| 425 | + for _, name := range []string{"alice", "bob"} { |
| 426 | + if _, err := tenant.Execute(h.ctx, h.keys["owner"], "setmember", []json.RawMessage{rawJSON(h.keys[name]), rawJSON(map[string]any{"role": "member"})}); err != nil { |
| 427 | + t.Fatal(err) |
| 428 | + } |
| 429 | + } |
| 430 | + return h |
| 431 | +} |
| 432 | + |
| 433 | +func rawJSON(v any) json.RawMessage { |
| 434 | + b, _ := json.Marshal(v) |
| 435 | + return b |
| 436 | +} |
| 437 | + |
| 438 | +func (h *roomHarness) sign(who string, kind int, tags [][]string, content string) event.Event { |
| 439 | + h.t.Helper() |
| 440 | + e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content} |
| 441 | + if err := event.Sign(&e, h.secrets[who]); err != nil { |
| 442 | + h.t.Fatal(err) |
| 443 | + } |
| 444 | + return e |
| 445 | +} |
| 446 | + |
| 447 | +func (h *roomHarness) publish(who string, kind int, tags [][]string, content string) (event.Event, error) { |
| 448 | + h.t.Helper() |
| 449 | + e := h.sign(who, kind, tags, content) |
| 450 | + _, err := h.tenant.router.Publish(h.ctx, e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: h.tenant.RelayURL()}) |
| 451 | + return e, err |
| 452 | +} |
| 453 | + |
| 454 | +func (h *roomHarness) must(who string, kind int, tags [][]string, content string) event.Event { |
| 455 | + h.t.Helper() |
| 456 | + e, err := h.publish(who, kind, tags, content) |
| 457 | + if err != nil { |
| 458 | + h.t.Fatalf("publish kind %d from %s: %v", kind, who, err) |
| 459 | + } |
| 460 | + return e |
| 461 | +} |
| 462 | + |
| 463 | +// projectRooms waits for the tenant worker to finish the queued room |
| 464 | +// projections, so records and notices are observed the way clients see them. |
| 465 | +func (h *roomHarness) projectRooms() { |
| 466 | + h.t.Helper() |
| 467 | + deadline := time.Now().Add(15 * time.Second) |
| 468 | + for { |
| 469 | + var pending int |
| 470 | + 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 { |
| 471 | + h.t.Fatal(err) |
| 472 | + } |
| 473 | + if pending == 0 { |
| 474 | + return |
| 475 | + } |
| 476 | + if time.Now().After(deadline) { |
| 477 | + h.t.Fatalf("%d room projections still queued", pending) |
| 478 | + } |
| 479 | + time.Sleep(50 * time.Millisecond) |
| 480 | + } |
| 481 | +} |
| 482 | + |
| 483 | +func (h *roomHarness) query(who string, f event.Filter) []event.Event { |
| 484 | + h.t.Helper() |
| 485 | + s := relay.Session{RelayURL: h.tenant.RelayURL()} |
| 486 | + if who != "" { |
| 487 | + s.PubKeys = []string{h.keys[who]} |
| 488 | + } |
| 489 | + rows, err := h.tenant.Query(h.ctx, []event.Filter{f}, s) |
| 490 | + if err != nil { |
| 491 | + h.t.Fatal(err) |
| 492 | + } |
| 493 | + return rows |
| 494 | +} |
| 495 | + |
| 496 | +func (h *roomHarness) browse(who, method string, params map[string]any) (map[string]any, error) { |
| 497 | + h.t.Helper() |
| 498 | + actor := "" |
| 499 | + if who != "" { |
| 500 | + actor = h.keys[who] |
| 501 | + } |
| 502 | + result, err := h.tenant.Execute(h.ctx, actor, method, []json.RawMessage{rawJSON(params)}) |
| 503 | + if err != nil { |
| 504 | + return nil, err |
| 505 | + } |
| 506 | + raw, _ := json.Marshal(result) |
| 507 | + var out map[string]any |
| 508 | + _ = json.Unmarshal(raw, &out) |
| 509 | + return out, nil |
| 510 | +} |
| 511 | + |
| 512 | +func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) { |
| 513 | + h := newRoomHarness(t) |
| 514 | + if _, err := h.publish("stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, ""); err == nil { |
| 515 | + t.Fatal("stranger created a room") |
| 516 | + } |
| 517 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "") |
| 518 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"name", "Secret"}, {"closed"}}, "") |
| 519 | + // A tenant member joins an open room on their first message; a members-only room refuses them. |
| 520 | + hello := h.must("bob", event.KIND_CHAT, [][]string{{"h", "general"}}, "hello") |
| 521 | + if role, _ := h.tenant.community.RoomRole(h.ctx, "general", h.keys["bob"]); role != community.RoomRoleMember { |
| 522 | + t.Fatalf("bob role = %q", role) |
| 523 | + } |
| 524 | + if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "secret"}}, "psst"); err == nil { |
| 525 | + t.Fatal("outsider wrote into a members-only room") |
| 526 | + } |
| 527 | + if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "missing"}}, "?"); err == nil { |
| 528 | + t.Fatal("unknown room accepted") |
| 529 | + } |
| 530 | + if _, err := h.publish("alice", event.KIND_ROOM_MEMBER_ADDED, [][]string{{"h", "general"}, {"p", h.keys["bob"]}}, ""); err == nil { |
| 531 | + t.Fatal("client published a member notice") |
| 532 | + } |
| 533 | + // Presence is accepted from members and never stored. |
| 534 | + h.must("bob", event.KIND_ROOM_PRESENCE, [][]string{{"h", "general"}}, "") |
| 535 | + if rows := h.query("bob", event.Filter{Kinds: []int{event.KIND_ROOM_PRESENCE}}); len(rows) != 0 { |
| 536 | + t.Fatalf("presence stored: %d", len(rows)) |
| 537 | + } |
| 538 | + // Room-scoped metadata edits use room roles, not tenant moderation. |
| 539 | + h.must("alice", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"about", "Everyone"}}, "") |
| 540 | + if room, _ := h.tenant.community.Room(h.ctx, "general"); room.About != "Everyone" { |
| 541 | + t.Fatalf("room edit lost: %+v", room) |
| 542 | + } |
| 543 | + if _, err := h.publish("bob", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"name", "Nope"}}, ""); err == nil { |
| 544 | + t.Fatal("member edited the room") |
| 545 | + } |
| 546 | + // Relay records and member notices follow the durable projection. |
| 547 | + h.projectRooms() |
| 548 | + meta := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"general"}}}) |
| 549 | + if len(meta) != 1 || meta[0].PubKey != h.tenant.records.PublicKey() || event.Tag(meta[0], "name") != "General" || event.Tag(meta[0], "about") != "Everyone" { |
| 550 | + t.Fatalf("39000 = %+v", meta) |
| 551 | + } |
| 552 | + admins := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_ADMINS}, Tags: map[string][]string{"d": {"general"}}}) |
| 553 | + if len(admins) != 1 || len(admins[0].Tags) < 3 || admins[0].Tags[2][1] != h.keys["alice"] || admins[0].Tags[2][2] != "owner" { |
| 554 | + t.Fatalf("39001 = %+v", admins) |
| 555 | + } |
| 556 | + members := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_MEMBERS}, Tags: map[string][]string{"d": {"general"}}}) |
| 557 | + if len(members) != 1 || len(event.TagValues(members[0], "p")) != 2 { |
| 558 | + t.Fatalf("39002 = %+v", members) |
| 559 | + } |
| 560 | + notices := h.query("", event.Filter{Kinds: []int{event.KIND_ROOM_MEMBER_ADDED}, Tags: map[string][]string{"h": {"general"}}}) |
| 561 | + if len(notices) != 2 || notices[0].PubKey != h.tenant.records.PublicKey() { |
| 562 | + t.Fatalf("44100 notices = %+v", notices) |
| 563 | + } |
| 564 | + if hidden := h.query("bob", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(hidden) != 0 { |
| 565 | + t.Fatal("members-only room record served to an outsider") |
| 566 | + } |
| 567 | + if shown := h.query("owner", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(shown) != 1 { |
| 568 | + t.Fatal("members-only room record hidden from the tenant owner") |
| 569 | + } |
| 570 | + // Browse calls apply the same visibility. |
| 571 | + list, err := h.browse("bob", "browserooms", nil) |
| 572 | + if err != nil { |
| 573 | + t.Fatal(err) |
| 574 | + } |
| 575 | + ids := map[string]map[string]any{} |
| 576 | + for _, item := range list["items"].([]any) { |
| 577 | + row := item.(map[string]any) |
| 578 | + ids[row["id"].(string)] = row |
| 579 | + } |
| 580 | + if _, ok := ids["secret"]; ok || ids["general"] == nil || ids["main"] == nil { |
| 581 | + t.Fatalf("rooms for bob = %v", ids) |
| 582 | + } |
| 583 | + if ids["general"]["members"].(float64) != 2 || ids["general"]["role"] != "member" || ids["general"]["last_message_at"].(float64) < float64(hello.CreatedAt) { |
| 584 | + t.Fatalf("general summary = %v", ids["general"]) |
| 585 | + } |
| 586 | + ownerList, _ := h.browse("owner", "browserooms", nil) |
| 587 | + if len(ownerList["items"].([]any)) != 3 { |
| 588 | + t.Fatalf("owner sees %d rooms", len(ownerList["items"].([]any))) |
| 589 | + } |
| 590 | + if _, err := h.browse("bob", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { |
| 591 | + t.Fatalf("members-only room browsed by an outsider: %v", err) |
| 592 | + } |
| 593 | + if _, err := h.browse("", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") { |
| 594 | + t.Fatalf("members-only room browsed anonymously: %v", err) |
| 595 | + } |
| 596 | + room, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2}) |
| 597 | + if err != nil { |
| 598 | + t.Fatal(err) |
| 599 | + } |
| 600 | + if len(room["members"].([]any)) != 2 || room["room"].(map[string]any)["name"] != "General" { |
| 601 | + t.Fatalf("room detail = %v", room) |
| 602 | + } |
| 603 | + messages := room["messages"].([]any) |
| 604 | + if len(messages) != 2 || room["next_cursor"] == "" { |
| 605 | + t.Fatalf("messages = %d cursor=%v", len(messages), room["next_cursor"]) |
| 606 | + } |
| 607 | + more, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2, "cursor": room["next_cursor"]}) |
| 608 | + if err != nil || len(more["messages"].([]any)) == 0 { |
| 609 | + t.Fatalf("second page = %v %v", more, err) |
| 610 | + } |
| 611 | + // Threads: a kind 11 root and its kind 12 replies. |
| 612 | + root := h.must("alice", event.KIND_THREAD, [][]string{{"h", "general"}, {"subject", "Plans"}}, "What next?") |
| 613 | + reply := h.must("bob", event.KIND_THREAD_REPLY, [][]string{{"h", "general"}, {"e", root.ID}, {"p", h.keys["alice"]}}, "Ship it") |
| 614 | + thread, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID}) |
| 615 | + if err != nil { |
| 616 | + t.Fatal(err) |
| 617 | + } |
| 618 | + if thread["root"].(map[string]any)["id"] != root.ID || len(thread["replies"].([]any)) != 1 || thread["replies"].([]any)[0].(map[string]any)["id"] != reply.ID { |
| 619 | + t.Fatalf("thread = %v", thread) |
| 620 | + } |
| 621 | + if _, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": hello.ID}); err != nil { |
| 622 | + t.Fatalf("chat roots are threadable: %v", err) |
| 623 | + } |
| 624 | + // Device notices name the room and link to it. |
| 625 | + mention := h.sign("bob", event.KIND_CHAT, [][]string{{"h", "general"}, {"p", h.keys["alice"]}}, "ping alice") |
| 626 | + pushes := h.tenant.pushNotices(h.ctx, mention) |
| 627 | + 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") { |
| 628 | + t.Fatalf("push notices = %+v", pushes) |
| 629 | + } |
| 630 | + 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:") { |
| 631 | + t.Fatalf("reply notices = %+v", pushes) |
| 632 | + } |
| 633 | + // Deleting the room retires its records and history. |
| 634 | + if _, err := h.publish("bob", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, ""); err == nil { |
| 635 | + t.Fatal("member deleted the room") |
| 636 | + } |
| 637 | + h.must("alice", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, "") |
| 638 | + h.projectRooms() |
| 639 | + 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 { |
| 640 | + t.Fatalf("deleted room records remain: %d", len(rows)) |
| 641 | + } |
| 642 | + if _, err := h.browse("alice", "browseroom", map[string]any{"id": "general"}); err == nil { |
| 643 | + t.Fatal("deleted room still browsable") |
| 644 | + } |
| 645 | +} |
| 646 | + |
| 647 | +func TestRoomsPolicyCapAndSlugMirror(t *testing.T) { |
| 648 | + h := newRoomHarness(t) |
| 649 | + if h.tenant.Policy().Rooms != 64 { |
| 650 | + t.Fatalf("default room allowance = %d", h.tenant.Policy().Rooms) |
| 651 | + } |
| 652 | + if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": 1})}); err != nil { |
| 653 | + t.Fatal(err) |
| 654 | + } |
| 655 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "one"}}, "") |
| 656 | + if _, err := h.publish("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "two"}}, ""); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") { |
| 657 | + t.Fatalf("cap not enforced: %v", err) |
| 658 | + } |
| 659 | + if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": -1})}); err == nil { |
| 660 | + t.Fatal("negative room allowance accepted") |
| 661 | + } |
| 662 | + slug, err := h.tenant.community.Room(h.ctx, "main") |
| 663 | + if err != nil || !slug.Live() || slug.Access != community.RoomOpen { |
| 664 | + t.Fatalf("slug room = %+v %v", slug, err) |
| 665 | + } |
| 666 | + if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != community.RoomRoleMember { |
| 667 | + t.Fatalf("alice slug role = %q", role) |
| 668 | + } |
| 669 | + p := h.tenant.Policy() |
| 670 | + p.Reads = "members" |
| 671 | + if err := h.tenant.applyPolicy(h.ctx, p); err != nil { |
| 672 | + t.Fatal(err) |
| 673 | + } |
| 674 | + if slug, _ := h.tenant.community.Room(h.ctx, "main"); slug.Access != community.RoomMembers { |
| 675 | + t.Fatalf("slug access after policy change = %q", slug.Access) |
| 676 | + } |
| 677 | + if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "removemember", []json.RawMessage{rawJSON(h.keys["alice"])}); err != nil { |
| 678 | + t.Fatal(err) |
| 679 | + } |
| 680 | + if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != "" { |
| 681 | + t.Fatalf("removed member keeps slug role %q", role) |
| 682 | + } |
| 683 | + if role, _ := h.tenant.community.RoomRole(h.ctx, "one", h.keys["alice"]); role != "" { |
| 684 | + t.Fatalf("removed member keeps room role %q", role) |
| 685 | + } |
| 686 | +} |
| 687 | + |
| 688 | +func TestRoomStreamDeliversLiveMessages(t *testing.T) { |
| 689 | + h := newRoomHarness(t) |
| 690 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "") |
| 691 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "") |
| 692 | + server := httptest.NewServer(h.app) |
| 693 | + defer server.Close() |
| 694 | + |
| 695 | + anonymous, err := http.Get(server.URL + "/rooms/general/stream") |
| 696 | + if err != nil { |
| 697 | + t.Fatal(err) |
| 698 | + } |
| 699 | + anonymous.Body.Close() |
| 700 | + if anonymous.StatusCode != http.StatusOK { |
| 701 | + t.Fatalf("open room stream without a session: %d", anonymous.StatusCode) |
| 702 | + } |
| 703 | + closed, err := http.Get(server.URL + "/rooms/secret/stream") |
| 704 | + if err != nil { |
| 705 | + t.Fatal(err) |
| 706 | + } |
| 707 | + closed.Body.Close() |
| 708 | + if closed.StatusCode != http.StatusUnauthorized { |
| 709 | + t.Fatalf("members-only room stream without a session: %d", closed.StatusCode) |
| 710 | + } |
| 711 | + missing, err := http.Get(server.URL + "/rooms/missing/stream") |
| 712 | + if err != nil { |
| 713 | + t.Fatal(err) |
| 714 | + } |
| 715 | + missing.Body.Close() |
| 716 | + if missing.StatusCode != http.StatusNotFound { |
| 717 | + t.Fatalf("unknown room stream: %d", missing.StatusCode) |
| 718 | + } |
| 719 | + |
| 720 | + ctx, cancel := context.WithCancel(context.Background()) |
| 721 | + defer cancel() |
| 722 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/rooms/general/stream", nil) |
| 723 | + if err != nil { |
| 724 | + t.Fatal(err) |
| 725 | + } |
| 726 | + signRequest(t, req, "") |
| 727 | + res, err := http.DefaultClient.Do(req) |
| 728 | + if err != nil { |
| 729 | + t.Fatal(err) |
| 730 | + } |
| 731 | + defer res.Body.Close() |
| 732 | + if res.StatusCode != http.StatusOK || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/event-stream") { |
| 733 | + t.Fatalf("stream %d %s", res.StatusCode, res.Header.Get("Content-Type")) |
| 734 | + } |
| 735 | + reader := bufio.NewReader(res.Body) |
| 736 | + line, err := reader.ReadString('\n') |
| 737 | + if err != nil || line != ": room general\n" { |
| 738 | + t.Fatalf("opening comment %q %v", line, err) |
| 739 | + } |
| 740 | + sent := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "live") |
| 741 | + h.must("alice", event.KIND_CHAT, [][]string{{"h", "secret"}}, "elsewhere") |
| 742 | + deadline := time.After(5 * time.Second) |
| 743 | + lines := make(chan string, 8) |
| 744 | + go func() { |
| 745 | + for { |
| 746 | + line, err := reader.ReadString('\n') |
| 747 | + if err != nil { |
| 748 | + close(lines) |
| 749 | + return |
| 750 | + } |
| 751 | + lines <- line |
| 752 | + } |
| 753 | + }() |
| 754 | + var got event.Event |
| 755 | + for got.ID == "" { |
| 756 | + select { |
| 757 | + case line, ok := <-lines: |
| 758 | + if !ok { |
| 759 | + t.Fatal("stream ended early") |
| 760 | + } |
| 761 | + if line == "event: message\n" { |
| 762 | + data := <-lines |
| 763 | + if err := json.Unmarshal([]byte(strings.TrimPrefix(data, "data: ")), &got); err != nil { |
| 764 | + t.Fatalf("frame %q: %v", data, err) |
| 765 | + } |
| 766 | + } |
| 767 | + case <-deadline: |
| 768 | + t.Fatal("no message within 5s") |
| 769 | + } |
| 770 | + } |
| 771 | + if got.ID != sent.ID || got.Content != "live" { |
| 772 | + t.Fatalf("streamed %+v, want %s", got, sent.ID) |
| 773 | + } |
| 774 | + cancel() |
| 775 | + select { |
| 776 | + case _, ok := <-lines: |
| 777 | + if ok { |
| 778 | + // Drain until the server closes the body. |
| 779 | + for range lines { |
| 780 | + } |
| 781 | + } |
| 782 | + case <-time.After(5 * time.Second): |
| 783 | + t.Fatal("stream did not close after cancel") |
| 784 | + } |
| 785 | +} |
| 786 | diff --git a/internal/gates/rooms_test.go b/internal/gates/rooms_test.go |
| 787 | new file mode 100644 |
| 788 | index 0000000..c3b07b2 |
| 789 | --- /dev/null |
| 790 | +++ b/internal/gates/rooms_test.go |
| 791 | @@ -0,0 +1,163 @@ |
| 792 | +package gates |
| 793 | + |
| 794 | +import ( |
| 795 | + "context" |
| 796 | + "database/sql" |
| 797 | + "encoding/json" |
| 798 | + "strings" |
| 799 | + "testing" |
| 800 | + "time" |
| 801 | + |
| 802 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 803 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 804 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 805 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 806 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 807 | +) |
| 808 | + |
| 809 | +func roomGate(t *testing.T) (*Gate, *community.Service, map[string]string, policy.Policy) { |
| 810 | + t.Helper() |
| 811 | + ctx := context.Background() |
| 812 | + st, err := storage.Open(ctx, t.TempDir()+"/relay.db") |
| 813 | + if err != nil { |
| 814 | + t.Fatal(err) |
| 815 | + } |
| 816 | + t.Cleanup(func() { _ = st.Close() }) |
| 817 | + secrets := map[string]string{"owner": strings.Repeat("0", 63) + "1", "alice": strings.Repeat("0", 63) + "2", "bob": strings.Repeat("0", 63) + "3", "stranger": strings.Repeat("0", 63) + "4"} |
| 818 | + keys := map[string]string{} |
| 819 | + for name, secret := range secrets { |
| 820 | + keys[name], _ = event.PublicKey(secret) |
| 821 | + } |
| 822 | + p := policy.Defaults(keys["owner"]) |
| 823 | + svc, err := community.New(ctx, st, keys["owner"]) |
| 824 | + if err != nil { |
| 825 | + t.Fatal(err) |
| 826 | + } |
| 827 | + svc.ConfigurePolicy(func() policy.Policy { return p }) |
| 828 | + if err := svc.EnsureSlugRoom(ctx, "main", community.RoomOpen); err != nil { |
| 829 | + t.Fatal(err) |
| 830 | + } |
| 831 | + for _, name := range []string{"alice", "bob"} { |
| 832 | + if _, err := svc.Execute(ctx, keys["owner"], "setmember", []json.RawMessage{mustRaw(keys[name]), mustRaw(map[string]any{"role": "member"})}); err != nil { |
| 833 | + t.Fatal(err) |
| 834 | + } |
| 835 | + } |
| 836 | + sign := func(who string, kind int, tags [][]string) event.Event { |
| 837 | + e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "x"} |
| 838 | + if err := event.Sign(&e, secrets[who]); err != nil { |
| 839 | + t.Fatal(err) |
| 840 | + } |
| 841 | + return e |
| 842 | + } |
| 843 | + persist := func(e event.Event) func(*sql.Tx) error { |
| 844 | + return func(tx *sql.Tx) error { |
| 845 | + _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()}) |
| 846 | + return err |
| 847 | + } |
| 848 | + } |
| 849 | + for _, room := range []struct{ id, access string }{{"pub", "open"}, {"priv", "members"}} { |
| 850 | + e := sign("alice", event.KIND_CREATE_GROUP, [][]string{{"h", room.id}, {"visibility", room.access}}) |
| 851 | + if _, err := svc.HandleRoomEventTx(ctx, e, persist(e)); err != nil { |
| 852 | + t.Fatal(err) |
| 853 | + } |
| 854 | + } |
| 855 | + g, err := New(Config{Store: st, Community: svc, Policy: func() policy.Policy { return p }, Slug: "main"}) |
| 856 | + if err != nil { |
| 857 | + t.Fatal(err) |
| 858 | + } |
| 859 | + keys["secret:alice"], keys["secret:bob"], keys["secret:stranger"], keys["secret:owner"] = secrets["alice"], secrets["bob"], secrets["stranger"], secrets["owner"] |
| 860 | + return g, svc, keys, p |
| 861 | +} |
| 862 | + |
| 863 | +func TestRoomWritesNeedMembership(t *testing.T) { |
| 864 | + g, _, keys, _ := roomGate(t) |
| 865 | + ctx := context.Background() |
| 866 | + now := time.Now().Unix() |
| 867 | + sign := func(who string, kind int, tags [][]string) event.Event { |
| 868 | + e := event.Event{Kind: kind, CreatedAt: now, Tags: tags, Content: "x"} |
| 869 | + if err := event.Sign(&e, keys["secret:"+who]); err != nil { |
| 870 | + t.Fatal(err) |
| 871 | + } |
| 872 | + return e |
| 873 | + } |
| 874 | + for _, tc := range []struct { |
| 875 | + name string |
| 876 | + e event.Event |
| 877 | + reject string |
| 878 | + }{ |
| 879 | + {"unknown room", sign("alice", 9, [][]string{{"h", "nope"}}), "invalid: unknown room"}, |
| 880 | + {"room owner posts", sign("alice", 9, [][]string{{"h", "priv"}}), ""}, |
| 881 | + {"tenant member outside a members-only room", sign("bob", 9, [][]string{{"h", "priv"}}), "restricted:"}, |
| 882 | + {"tenant owner moderates any room", sign("owner", 9, [][]string{{"h", "priv"}}), ""}, |
| 883 | + {"tenant member in an open room", sign("bob", 9, [][]string{{"h", "pub"}}), ""}, |
| 884 | + {"stranger in an open room", sign("stranger", 9, [][]string{{"h", "pub"}}), "restricted:"}, |
| 885 | + {"reaction in an open room", sign("bob", 7, [][]string{{"h", "pub"}, {"e", strings.Repeat("a", 64)}}), ""}, |
| 886 | + {"rich content in an open room", sign("bob", event.KIND_RICH_CONTENT, [][]string{{"h", "pub"}}), ""}, |
| 887 | + {"presence from a member", sign("bob", event.KIND_ROOM_PRESENCE, [][]string{{"h", "pub"}}), ""}, |
| 888 | + {"presence from a stranger", sign("stranger", event.KIND_ROOM_TYPING, [][]string{{"h", "pub"}}), "restricted:"}, |
| 889 | + {"member notices are relay signed", sign("alice", event.KIND_ROOM_MEMBER_ADDED, [][]string{{"h", "pub"}, {"p", keys["bob"]}}), "blocked:"}, |
| 890 | + {"room creation names a new room", sign("bob", event.KIND_CREATE_GROUP, [][]string{{"h", "fresh"}}), ""}, |
| 891 | + {"join request reaches the room handler", sign("stranger", event.KIND_JOIN, [][]string{{"h", "pub"}}), ""}, |
| 892 | + {"slug room keeps the open write rule", sign("stranger", 9, [][]string{{"h", "main"}}), ""}, |
| 893 | + {"no h tag is outside rooms", sign("stranger", 9, nil), ""}, |
| 894 | + } { |
| 895 | + t.Run(tc.name, func(t *testing.T) { |
| 896 | + err := g.Write(ctx, tc.e, relay.Session{PubKeys: []string{tc.e.PubKey}}, now) |
| 897 | + if tc.reject == "" && err != nil { |
| 898 | + t.Fatalf("rejected: %v", err) |
| 899 | + } |
| 900 | + if tc.reject != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.reject)) { |
| 901 | + t.Fatalf("want %q, got %v", tc.reject, err) |
| 902 | + } |
| 903 | + }) |
| 904 | + } |
| 905 | + if err := g.Import(ctx, sign("alice", 9, [][]string{{"h", "gone"}}), now); err == nil { |
| 906 | + t.Fatal("import accepted an unknown room") |
| 907 | + } |
| 908 | + if err := g.Import(ctx, sign("stranger", 9, [][]string{{"h", "priv"}}), now); err != nil { |
| 909 | + t.Fatalf("import applies room existence only: %v", err) |
| 910 | + } |
| 911 | +} |
| 912 | + |
| 913 | +func TestRoomReadsFollowRoomAccess(t *testing.T) { |
| 914 | + g, _, keys, _ := roomGate(t) |
| 915 | + ctx := context.Background() |
| 916 | + private := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "priv"}}, Content: "secret"} |
| 917 | + public := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "pub"}}, Content: "open"} |
| 918 | + members := event.Event{Kind: event.KIND_GROUP_MEMBERS, PubKey: strings.Repeat("f", 64), CreatedAt: 1, Tags: [][]string{{"-"}, {"d", "priv"}, {"p", keys["alice"]}}} |
| 919 | + main := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "main"}}, Content: "main"} |
| 920 | + for _, tc := range []struct { |
| 921 | + name string |
| 922 | + e event.Event |
| 923 | + who []string |
| 924 | + want bool |
| 925 | + }{ |
| 926 | + {"member sees a members-only room", private, []string{keys["alice"]}, true}, |
| 927 | + {"tenant owner sees every room", private, []string{keys["owner"]}, true}, |
| 928 | + {"tenant member outside the room does not", private, []string{keys["bob"]}, false}, |
| 929 | + {"anonymous does not", private, nil, false}, |
| 930 | + {"member list of a members-only room is hidden", members, []string{keys["bob"]}, false}, |
| 931 | + {"member list is served to members", members, []string{keys["alice"]}, true}, |
| 932 | + {"open rooms follow the tenant read rule", public, nil, true}, |
| 933 | + {"slug room is unchanged", main, nil, true}, |
| 934 | + } { |
| 935 | + t.Run(tc.name, func(t *testing.T) { |
| 936 | + if got := g.CanSee(ctx, tc.e, relay.Session{PubKeys: tc.who}, nil); got != tc.want { |
| 937 | + t.Fatalf("visible = %v, want %v", got, tc.want) |
| 938 | + } |
| 939 | + }) |
| 940 | + } |
| 941 | + hint, err := g.Read(ctx, []event.Filter{{Kinds: []int{9}, Tags: map[string][]string{"h": {"priv"}}}}, relay.Session{}) |
| 942 | + if err != nil || !hint { |
| 943 | + t.Fatalf("members-only room read hint = %v %v", hint, err) |
| 944 | + } |
| 945 | + hint, err = g.Read(ctx, []event.Filter{{Kinds: []int{9}, Tags: map[string][]string{"h": {"pub"}}}}, relay.Session{}) |
| 946 | + if err != nil || hint { |
| 947 | + t.Fatalf("open room read hint = %v %v", hint, err) |
| 948 | + } |
| 949 | +} |
| 950 | + |
| 951 | +func mustRaw(v any) json.RawMessage { |
| 952 | + b, _ := json.Marshal(v) |
| 953 | + return b |
| 954 | +} |
| 955 | diff --git a/internal/records/rooms_test.go b/internal/records/rooms_test.go |
| 956 | new file mode 100644 |
| 957 | index 0000000..1923050 |
| 958 | --- /dev/null |
| 959 | +++ b/internal/records/rooms_test.go |
| 960 | @@ -0,0 +1,132 @@ |
| 961 | +package records |
| 962 | + |
| 963 | +import ( |
| 964 | + "context" |
| 965 | + "database/sql" |
| 966 | + "encoding/json" |
| 967 | + "strings" |
| 968 | + "testing" |
| 969 | + "time" |
| 970 | + |
| 971 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 972 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 973 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 974 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 975 | +) |
| 976 | + |
| 977 | +func TestRoomRecordsAndNotices(t *testing.T) { |
| 978 | + s, ctx := testStore(t) |
| 979 | + secrets := map[string]string{"owner": strings.Repeat("0", 63) + "1", "alice": strings.Repeat("0", 63) + "2", "bob": strings.Repeat("0", 63) + "3", "carol": strings.Repeat("0", 63) + "4"} |
| 980 | + keys := map[string]string{} |
| 981 | + for name, secret := range secrets { |
| 982 | + keys[name], _ = event.PublicKey(secret) |
| 983 | + } |
| 984 | + p := policy.Defaults(keys["owner"]) |
| 985 | + svc, err := community.New(ctx, s, keys["owner"]) |
| 986 | + if err != nil { |
| 987 | + t.Fatal(err) |
| 988 | + } |
| 989 | + svc.ConfigurePolicy(func() policy.Policy { return p }) |
| 990 | + if err := svc.EnsureSlugRoom(ctx, "main", community.RoomOpen); err != nil { |
| 991 | + t.Fatal(err) |
| 992 | + } |
| 993 | + raw := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b } |
| 994 | + for _, name := range []string{"alice", "bob", "carol"} { |
| 995 | + if _, err := svc.Execute(ctx, keys["owner"], "setmember", []json.RawMessage{raw(keys[name]), raw(map[string]any{"role": "member"})}); err != nil { |
| 996 | + t.Fatal(err) |
| 997 | + } |
| 998 | + } |
| 999 | + send := func(who string, kind int, tags [][]string) { |
| 1000 | + t.Helper() |
| 1001 | + e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags} |
| 1002 | + if err := event.Sign(&e, secrets[who]); err != nil { |
| 1003 | + t.Fatal(err) |
| 1004 | + } |
| 1005 | + if _, err := svc.HandleRoomEventTx(ctx, e, func(tx *sql.Tx) error { |
| 1006 | + _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()}) |
| 1007 | + return err |
| 1008 | + }); err != nil { |
| 1009 | + t.Fatal(err) |
| 1010 | + } |
| 1011 | + } |
| 1012 | + send("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"about", "Ours"}, {"picture", "https://img.example/t.png"}, {"closed"}}) |
| 1013 | + send("alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", keys["bob"], "admin"}}) |
| 1014 | + send("alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", keys["carol"]}}) |
| 1015 | + |
| 1016 | + var generated []event.Event |
| 1017 | + r, err := New(ctx, Config{Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example", GroupID: "main", OnGenerated: func(ctx context.Context, e event.Event) error { |
| 1018 | + generated = append(generated, e) |
| 1019 | + _, err := s.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}) |
| 1020 | + return err |
| 1021 | + }}) |
| 1022 | + if err != nil { |
| 1023 | + t.Fatal(err) |
| 1024 | + } |
| 1025 | + records, err := r.PublishRoom(ctx, "team", 100) |
| 1026 | + if err != nil { |
| 1027 | + t.Fatal(err) |
| 1028 | + } |
| 1029 | + if len(records) != 3 || records[0].Kind != event.KIND_GROUP_METADATA || records[1].Kind != event.KIND_GROUP_ADMINS || records[2].Kind != event.KIND_GROUP_MEMBERS { |
| 1030 | + t.Fatalf("records = %+v", records) |
| 1031 | + } |
| 1032 | + meta := records[0] |
| 1033 | + if event.Tag(meta, "d") != "team" || event.Tag(meta, "name") != "Team" || event.Tag(meta, "about") != "Ours" || event.Tag(meta, "picture") != "https://img.example/t.png" { |
| 1034 | + t.Fatalf("metadata tags = %v", meta.Tags) |
| 1035 | + } |
| 1036 | + if !hasBareTag(meta, "closed") || hasBareTag(meta, "private") || !hasBareTag(meta, "public") { |
| 1037 | + t.Fatalf("access tags = %v", meta.Tags) |
| 1038 | + } |
| 1039 | + admins := map[string]string{} |
| 1040 | + for _, tag := range records[1].Tags { |
| 1041 | + if tag[0] == "p" { |
| 1042 | + admins[tag[1]] = tag[2] |
| 1043 | + } |
| 1044 | + } |
| 1045 | + if len(admins) != 2 || admins[keys["alice"]] != "owner" || admins[keys["bob"]] != "admin" { |
| 1046 | + t.Fatalf("admins = %v", admins) |
| 1047 | + } |
| 1048 | + if got := event.TagValues(records[2], "p"); len(got) != 3 { |
| 1049 | + t.Fatalf("members = %v", got) |
| 1050 | + } |
| 1051 | + for _, rec := range records { |
| 1052 | + if rec.PubKey != r.PublicKey() || event.Validate(rec) != nil { |
| 1053 | + t.Fatalf("record not relay-signed: %+v", rec) |
| 1054 | + } |
| 1055 | + } |
| 1056 | + p.Reads = "members" |
| 1057 | + records, err = r.PublishRoom(ctx, "team", 101) |
| 1058 | + if err != nil || !hasBareTag(records[0], "private") { |
| 1059 | + t.Fatalf("private marker missing: %v %v", records[0].Tags, err) |
| 1060 | + } |
| 1061 | + notice, err := r.RoomNotice(ctx, "team", keys["carol"], true, 102) |
| 1062 | + if err != nil || notice.Kind != event.KIND_ROOM_MEMBER_ADDED || event.Tag(notice, "h") != "team" || event.Tag(notice, "p") != keys["carol"] { |
| 1063 | + t.Fatalf("added notice = %+v %v", notice, err) |
| 1064 | + } |
| 1065 | + notice, err = r.RoomNotice(ctx, "team", keys["carol"], false, 103) |
| 1066 | + if err != nil || notice.Kind != event.KIND_ROOM_MEMBER_REMOVED { |
| 1067 | + t.Fatalf("removed notice = %+v %v", notice, err) |
| 1068 | + } |
| 1069 | + stored, err := s.Query(ctx, event.Filter{Kinds: []int{event.KIND_GROUP_METADATA, event.KIND_GROUP_ADMINS, event.KIND_GROUP_MEMBERS}, Authors: []string{r.PublicKey()}, Tags: map[string][]string{"d": {"team"}}}, storage.QueryOptions{Access: storage.Access{All: true}}) |
| 1070 | + if err != nil || len(stored.Events) != 3 { |
| 1071 | + t.Fatalf("stored records = %d %v", len(stored.Events), err) |
| 1072 | + } |
| 1073 | + if err := r.RetireRoom(ctx, "team"); err != nil { |
| 1074 | + t.Fatal(err) |
| 1075 | + } |
| 1076 | + stored, err = s.Query(ctx, event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Authors: []string{r.PublicKey()}, Tags: map[string][]string{"d": {"team"}}}, storage.QueryOptions{Access: storage.Access{All: true}}) |
| 1077 | + if err != nil || len(stored.Events) != 0 { |
| 1078 | + t.Fatalf("retired records remain: %d %v", len(stored.Events), err) |
| 1079 | + } |
| 1080 | + if len(generated) < 8 { |
| 1081 | + t.Fatalf("generated %d events", len(generated)) |
| 1082 | + } |
| 1083 | +} |
| 1084 | + |
| 1085 | +func hasBareTag(e event.Event, name string) bool { |
| 1086 | + for _, tag := range e.Tags { |
| 1087 | + if len(tag) == 1 && tag[0] == name { |
| 1088 | + return true |
| 1089 | + } |
| 1090 | + } |
| 1091 | + return false |
| 1092 | +} |