tinyrelaycommit

Commit 81afdbd30fe4

browse files at this commit | history

1diff --git a/internal/community/rooms_test.go b/internal/community/rooms_test.go
2new file mode 100644
3index 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(&notices); 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(&notices); err != nil || notices != 1 {
382+ t.Fatalf("later join queued %d notices (%v)", notices, err)
383+ }
384+}
385diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go
386new file mode 100644
387index 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+}
786diff --git a/internal/gates/rooms_test.go b/internal/gates/rooms_test.go
787new file mode 100644
788index 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+}
955diff --git a/internal/records/rooms_test.go b/internal/records/rooms_test.go
956new file mode 100644
957index 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+}

About

ownerc01503e8035f
visibilitypublic
head81afdbd30fe4

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs