- commit
- 81afdbd30fe4859cd8e03084064ba645c8aa764f
- parent
- da03ad396f629daaeff2332a94db7d4923f54ca8
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 378 | +++++++++++++++++++++++++++++ |
| A | internal/ | 395 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 163 | ++++++++++++ |
| A | internal/ | 132 | ++++++++++ |
@@ -0,0 +1,378 @@
1+package community2+3+import (4+ "context"5+ "database/sql"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/policy"12+ "github.com/FelineStateMachine/tinyrelay/internal/storage"13+)14+15+type roomFixture struct {16+ ctx context.Context17+ store *storage.Store18+ svc *Service19+ rooms int20+ keys map[string]string21+}22+23+func newRoomFixture(t *testing.T) *roomFixture {24+ t.Helper()25+ ctx := context.Background()26+ st, err := storage.Open(ctx, t.TempDir()+"/relay.db")27+ if err != nil {28+ t.Fatal(err)29+ }30+ t.Cleanup(func() { _ = st.Close() })31+ f := &roomFixture{ctx: ctx, store: st, rooms: 64, keys: map[string]string{}}32+ for i, name := range []string{"owner", "alice", "bob", "carol", "dave", "stranger"} {33+ f.keys[name] = strings.Repeat("0", 63) + string(rune('1'+i))34+ }35+ f.svc, err = New(ctx, st, f.pub("owner"))36+ if err != nil {37+ t.Fatal(err)38+ }39+ f.svc.ConfigurePolicy(func() policy.Policy {40+ p := policy.Defaults(f.pub("owner"))41+ p.Rooms = f.rooms42+ return p43+ })44+ if err := f.svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil {45+ t.Fatal(err)46+ }47+ for _, name := range []string{"alice", "bob", "carol", "dave"} {48+ if _, err := f.svc.Execute(ctx, f.pub("owner"), "setmember", raws(f.pub(name), map[string]any{"role": "member"})); err != nil {49+ t.Fatal(err)50+ }51+ }52+ return f53+}54+55+func (f *roomFixture) pub(name string) string {56+ pk, _ := event.PublicKey(f.keys[name])57+ return pk58+}59+60+func (f *roomFixture) event(t *testing.T, who string, kind int, tags [][]string, content string) event.Event {61+ t.Helper()62+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}63+ if err := event.Sign(&e, f.keys[who]); err != nil {64+ t.Fatal(err)65+ }66+ return e67+}68+69+func (f *roomFixture) persist(e event.Event) func(*sql.Tx) error {70+ return func(tx *sql.Tx) error {71+ _, err := storage.SaveTx(f.ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()})72+ return err73+ }74+}75+76+// send applies a room management event and returns the handler error.77+func (f *roomFixture) send(t *testing.T, who string, kind int, tags [][]string) (MembershipResult, error) {78+ t.Helper()79+ e := f.event(t, who, kind, tags, "")80+ return f.svc.HandleRoomEventTx(f.ctx, e, f.persist(e))81+}82+83+func (f *roomFixture) message(t *testing.T, who, room, text string) event.Event {84+ t.Helper()85+ e := f.event(t, who, event.KIND_CHAT, [][]string{{"h", room}}, text)86+ if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err != nil {87+ t.Fatalf("message from %s in %s: %v", who, room, err)88+ }89+ return e90+}91+92+func (f *roomFixture) role(t *testing.T, room, who string) string {93+ t.Helper()94+ role, err := f.svc.RoomRole(f.ctx, room, f.pub(who))95+ if err != nil {96+ t.Fatal(err)97+ }98+ return role99+}100+101+func TestRoomCreationHonoursMembershipAndCap(t *testing.T) {102+ f := newRoomFixture(t)103+ f.rooms = 1104+ if _, err := f.send(t, "stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {105+ t.Fatalf("stranger created a room: %v", err)106+ }107+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "Bad Room"}, {"name", "x"}}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {108+ t.Fatalf("invalid id accepted: %v", err)109+ }110+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "main"}}); err == nil {111+ t.Fatal("slug room recreated")112+ }113+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}, {"about", "Talk"}, {"channel_type", "public"}}); err != nil {114+ t.Fatal(err)115+ }116+ room, err := f.svc.Room(f.ctx, "general")117+ if err != nil || !room.Live() || room.Name != "General" || room.About != "Talk" || room.Access != RoomOpen || room.CreatedBy != f.pub("alice") || room.EventID == "" {118+ t.Fatalf("room = %+v err=%v", room, err)119+ }120+ if f.role(t, "general", "alice") != RoomRoleOwner {121+ t.Fatal("creator is not the room owner")122+ }123+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}); err == nil || !strings.HasPrefix(err.Error(), "duplicate:") {124+ t.Fatalf("duplicate room: %v", err)125+ }126+ if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "second"}}); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") {127+ t.Fatalf("cap not applied: %v", err)128+ }129+ f.rooms = 0130+ if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "third"}}); err == nil || !strings.Contains(err.Error(), "switched off") {131+ t.Fatalf("rooms off not applied: %v", err)132+ }133+ f.rooms = 64134+ if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"visibility", "members"}}); err != nil {135+ t.Fatal(err)136+ }137+ if room, _ := f.svc.Room(f.ctx, "team"); room.Access != RoomMembers {138+ t.Fatalf("visibility tag ignored: %+v", room)139+ }140+ rooms, err := f.svc.Rooms(f.ctx)141+ if err != nil || len(rooms) != 3 {142+ t.Fatalf("rooms = %v err=%v", rooms, err)143+ }144+}145+146+func TestRoomAdminEventsFollowRoomRoles(t *testing.T) {147+ f := newRoomFixture(t)148+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"closed"}}); err != nil {149+ t.Fatal(err)150+ }151+ // Metadata edits belong to room admins; the tenant owner may always moderate.152+ if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Hijacked"}}); err == nil {153+ t.Fatal("outsider edited the room")154+ }155+ if _, err := f.send(t, "owner", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"about", "Moderated"}}); err != nil {156+ t.Fatal(err)157+ }158+ if _, err := f.send(t, "alice", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"name", "Team Chat"}}); err != nil {159+ t.Fatal(err)160+ }161+ if room, _ := f.svc.Room(f.ctx, "team"); room.Name != "Team Chat" || room.About != "Moderated" || room.Access != RoomMembers {162+ t.Fatalf("edit lost: %+v", room)163+ }164+ // Adding people: tenant membership is required and owner appointments are reserved.165+ if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("stranger")}}); err == nil {166+ t.Fatal("non-member added to a room")167+ }168+ if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "admin"}}); err != nil {169+ t.Fatal(err)170+ }171+ if f.role(t, "team", "bob") != RoomRoleAdmin {172+ t.Fatal("admin role not applied")173+ }174+ if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol"), "owner"}}); err == nil {175+ t.Fatal("admin appointed an owner")176+ }177+ if _, err := f.send(t, "bob", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil {178+ t.Fatal(err)179+ }180+ if _, err := f.send(t, "carol", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("dave")}}); err == nil {181+ t.Fatal("member added someone")182+ }183+ // Removal: admins cannot remove owners and the last owner stays.184+ if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("alice")}}); err == nil {185+ t.Fatal("admin removed the owner")186+ }187+ if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "at least one owner") {188+ t.Fatalf("last owner left: %v", err)189+ }190+ if _, err := f.send(t, "alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", f.pub("bob"), "owner"}}); err != nil {191+ t.Fatal(err)192+ }193+ if _, err := f.send(t, "alice", event.KIND_LEAVE, [][]string{{"h", "team"}}); err != nil {194+ t.Fatal(err)195+ }196+ if f.role(t, "team", "alice") != "" || f.role(t, "team", "bob") != RoomRoleOwner {197+ t.Fatal("ownership hand-over failed")198+ }199+ if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("bob")}}); err == nil {200+ t.Fatal("last owner removed themselves")201+ }202+ if _, err := f.send(t, "bob", event.KIND_REMOVE_USER, [][]string{{"h", "team"}, {"p", f.pub("carol")}}); err != nil {203+ t.Fatal(err)204+ }205+ // Join requests: members-only rooms refuse them, open rooms take tenant members.206+ if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil || !strings.Contains(err.Error(), "members-only") {207+ t.Fatalf("joined a members-only room: %v", err)208+ }209+ if _, err := f.send(t, "bob", event.KIND_EDIT_METADATA, [][]string{{"h", "team"}, {"open"}}); err != nil {210+ t.Fatal(err)211+ }212+ if _, err := f.send(t, "stranger", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil {213+ t.Fatal("stranger joined")214+ }215+ if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil {216+ t.Fatal(err)217+ }218+ if res, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err != nil || res.Stored || !strings.HasPrefix(res.Message, "duplicate:") {219+ t.Fatalf("second join = %+v %v", res, err)220+ }221+ // Deleting events: authors and admins only, and only inside this room.222+ own := f.message(t, "carol", "team", "mine")223+ other := f.message(t, "bob", "team", "theirs")224+ if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err == nil {225+ t.Fatal("member deleted another member's message")226+ }227+ if _, err := f.send(t, "carol", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", own.ID}}); err != nil {228+ t.Fatal(err)229+ }230+ if _, err := f.send(t, "bob", event.KIND_DELETE_EVENT, [][]string{{"h", "team"}, {"e", other.ID}}); err != nil {231+ t.Fatal(err)232+ }233+ var stored int234+ if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id IN (?,?)`, own.ID, other.ID).Scan(&stored); err != nil || stored != 0 {235+ t.Fatalf("messages remain: %d %v", stored, err)236+ }237+ // Deleting the room: owners only, never the slug room, and the history goes.238+ left := f.message(t, "carol", "team", "still here")239+ if _, err := f.send(t, "carol", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err == nil {240+ t.Fatal("member deleted the room")241+ }242+ if _, err := f.send(t, "owner", event.KIND_DELETE_GROUP, [][]string{{"h", "main"}}); err == nil {243+ t.Fatal("slug room deleted")244+ }245+ if _, err := f.send(t, "bob", event.KIND_DELETE_GROUP, [][]string{{"h", "team"}}); err != nil {246+ t.Fatal(err)247+ }248+ if room, _ := f.svc.Room(f.ctx, "team"); room.Live() {249+ t.Fatal("room still live")250+ }251+ if n, _ := f.svc.RoomMemberCount(f.ctx, "team"); n != 0 {252+ t.Fatalf("members remain: %d", n)253+ }254+ if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM events WHERE id=?`, left.ID).Scan(&stored); err != nil || stored != 0 {255+ t.Fatalf("history remains: %d %v", stored, err)256+ }257+ if _, err := f.send(t, "carol", event.KIND_JOIN, [][]string{{"h", "team"}}); err == nil {258+ t.Fatal("joined a deleted room")259+ }260+ if _, err := f.send(t, "dave", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Reborn"}}); err != nil {261+ t.Fatalf("deleted id not reusable: %v", err)262+ }263+ if f.role(t, "team", "dave") != RoomRoleOwner || f.role(t, "team", "bob") != "" {264+ t.Fatal("recreated room kept old members")265+ }266+}267+268+func TestRoomMessagesJoinOpenRoomsAndRespectMembersOnly(t *testing.T) {269+ f := newRoomFixture(t)270+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "open"}}); err != nil {271+ t.Fatal(err)272+ }273+ if _, err := f.send(t, "alice", event.KIND_CREATE_GROUP, [][]string{{"h", "closed"}, {"closed"}}); err != nil {274+ t.Fatal(err)275+ }276+ f.message(t, "bob", "open", "hello")277+ if f.role(t, "open", "bob") != RoomRoleMember {278+ t.Fatal("first message did not join the open room")279+ }280+ e := f.event(t, "bob", event.KIND_CHAT, [][]string{{"h", "closed"}}, "psst")281+ if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {282+ t.Fatalf("outsider wrote into a members-only room: %v", err)283+ }284+ e = f.event(t, "stranger", event.KIND_CHAT, [][]string{{"h", "open"}}, "hi")285+ if err := f.svc.HandleRoomMessageTx(f.ctx, e, f.persist(e)); err == nil {286+ t.Fatal("stranger wrote into an open room")287+ }288+ allowed, joins, err := f.svc.RoomWriteAllowed(f.ctx, "open", f.pub("carol"))289+ if err != nil || !allowed || !joins {290+ t.Fatalf("open room write = %v %v %v", allowed, joins, err)291+ }292+ if allowed, _, _ := f.svc.RoomWriteAllowed(f.ctx, "closed", f.pub("carol")); allowed {293+ t.Fatal("members-only room open to tenant members")294+ }295+ if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub("carol")}); visible {296+ t.Fatal("members-only room visible to outsiders")297+ }298+ for _, who := range []string{"alice", "owner"} {299+ if visible, _ := f.svc.RoomVisible(f.ctx, "closed", []string{f.pub(who)}); !visible {300+ t.Fatalf("members-only room hidden from %s", who)301+ }302+ }303+ if visible, _ := f.svc.RoomVisible(f.ctx, "open", nil); !visible {304+ t.Fatal("open room hidden")305+ }306+}307+308+func TestSlugRoomMirrorsTenantRoster(t *testing.T) {309+ f := newRoomFixture(t)310+ room, err := f.svc.Room(f.ctx, "main")311+ if err != nil || !room.Live() || room.Access != RoomOpen {312+ t.Fatalf("slug room = %+v %v", room, err)313+ }314+ if f.svc.SlugRoom() != "main" || f.role(t, "main", "owner") != RoomRoleOwner || f.role(t, "main", "alice") != RoomRoleMember {315+ t.Fatal("roster not mirrored")316+ }317+ if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "setmember", raws(f.pub("alice"), map[string]any{"role": "moderator"})); err != nil {318+ t.Fatal(err)319+ }320+ if f.role(t, "main", "alice") != RoomRoleAdmin {321+ t.Fatal("moderator is not a room admin")322+ }323+ if _, err := f.send(t, "bob", event.KIND_CREATE_GROUP, [][]string{{"h", "side"}}); err != nil {324+ t.Fatal(err)325+ }326+ if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "removemember", raws(f.pub("bob"))); err != nil {327+ t.Fatal(err)328+ }329+ var rows int330+ if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM room_members WHERE pubkey=?`, f.pub("bob")).Scan(&rows); err != nil || rows != 0 {331+ t.Fatalf("removed member still in rooms: %d %v", rows, err)332+ }333+ if err := f.svc.EnsureSlugRoom(f.ctx, "main", RoomMembers); err != nil {334+ t.Fatal(err)335+ }336+ if room, _ := f.svc.Room(f.ctx, "main"); room.Access != RoomMembers {337+ t.Fatal("slug access did not follow the read rule")338+ }339+ if _, err := f.svc.Execute(f.ctx, f.pub("owner"), "transferowner", raws(f.pub("alice"))); err != nil {340+ t.Fatal(err)341+ }342+ if err := f.svc.SetOwner(f.pub("alice")); err != nil {343+ t.Fatal(err)344+ }345+ if f.role(t, "main", "alice") != RoomRoleOwner || f.role(t, "main", "owner") != RoomRoleAdmin {346+ t.Fatalf("transfer not mirrored: alice=%s owner=%s", f.role(t, "main", "alice"), f.role(t, "main", "owner"))347+ }348+}349+350+func TestSlugRoomFirstMirrorIsSilent(t *testing.T) {351+ ctx := context.Background()352+ st, err := storage.Open(ctx, t.TempDir()+"/relay.db")353+ if err != nil {354+ t.Fatal(err)355+ }356+ defer st.Close()357+ owner := strings.Repeat("a", 64)358+ svc, err := New(ctx, st, owner)359+ if err != nil {360+ t.Fatal(err)361+ }362+ if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("b", 64), map[string]any{"role": "member"})); err != nil {363+ t.Fatal(err)364+ }365+ if err := svc.EnsureSlugRoom(ctx, "main", RoomOpen); err != nil {366+ t.Fatal(err)367+ }368+ var notices int369+ 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 {370+ t.Fatalf("initial mirror queued %d member notices (%v)", notices, err)371+ }372+ if _, err := svc.Execute(ctx, owner, "setmember", raws(strings.Repeat("c", 64), map[string]any{"role": "member"})); err != nil {373+ t.Fatal(err)374+ }375+ 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 {376+ t.Fatalf("later join queued %d notices (%v)", notices, err)377+ }378+}@@ -0,0 +1,395 @@
1+package daemon2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/community"14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+)17+18+type roomHarness struct {19+ t *testing.T20+ app *App21+ tenant *Tenant22+ ctx context.Context23+ secrets map[string]string24+ keys map[string]string25+}26+27+func newRoomHarness(t *testing.T) *roomHarness {28+ t.Helper()29+ app, tenant := testTenant(t)30+ h := &roomHarness{t: t, app: app, tenant: tenant, ctx: context.Background(), secrets: map[string]string{}, keys: map[string]string{}}31+ for i, name := range []string{"owner", "alice", "bob", "stranger"} {32+ h.secrets[name] = strings.Repeat("0", 63) + string(rune('1'+i))33+ h.keys[name], _ = event.PublicKey(h.secrets[name])34+ }35+ for _, name := range []string{"alice", "bob"} {36+ if _, err := tenant.Execute(h.ctx, h.keys["owner"], "setmember", []json.RawMessage{rawJSON(h.keys[name]), rawJSON(map[string]any{"role": "member"})}); err != nil {37+ t.Fatal(err)38+ }39+ }40+ return h41+}42+43+func rawJSON(v any) json.RawMessage {44+ b, _ := json.Marshal(v)45+ return b46+}47+48+func (h *roomHarness) sign(who string, kind int, tags [][]string, content string) event.Event {49+ h.t.Helper()50+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}51+ if err := event.Sign(&e, h.secrets[who]); err != nil {52+ h.t.Fatal(err)53+ }54+ return e55+}56+57+func (h *roomHarness) publish(who string, kind int, tags [][]string, content string) (event.Event, error) {58+ h.t.Helper()59+ e := h.sign(who, kind, tags, content)60+ _, err := h.tenant.router.Publish(h.ctx, e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: h.tenant.RelayURL()})61+ return e, err62+}63+64+func (h *roomHarness) must(who string, kind int, tags [][]string, content string) event.Event {65+ h.t.Helper()66+ e, err := h.publish(who, kind, tags, content)67+ if err != nil {68+ h.t.Fatalf("publish kind %d from %s: %v", kind, who, err)69+ }70+ return e71+}72+73+// projectRooms waits for the tenant worker to finish the queued room74+// projections, so records and notices are observed the way clients see them.75+func (h *roomHarness) projectRooms() {76+ h.t.Helper()77+ deadline := time.Now().Add(15 * time.Second)78+ for {79+ var pending int80+ 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 {81+ h.t.Fatal(err)82+ }83+ if pending == 0 {84+ return85+ }86+ if time.Now().After(deadline) {87+ h.t.Fatalf("%d room projections still queued", pending)88+ }89+ time.Sleep(50 * time.Millisecond)90+ }91+}92+93+func (h *roomHarness) query(who string, f event.Filter) []event.Event {94+ h.t.Helper()95+ s := relay.Session{RelayURL: h.tenant.RelayURL()}96+ if who != "" {97+ s.PubKeys = []string{h.keys[who]}98+ }99+ rows, err := h.tenant.Query(h.ctx, []event.Filter{f}, s)100+ if err != nil {101+ h.t.Fatal(err)102+ }103+ return rows104+}105+106+func (h *roomHarness) browse(who, method string, params map[string]any) (map[string]any, error) {107+ h.t.Helper()108+ actor := ""109+ if who != "" {110+ actor = h.keys[who]111+ }112+ result, err := h.tenant.Execute(h.ctx, actor, method, []json.RawMessage{rawJSON(params)})113+ if err != nil {114+ return nil, err115+ }116+ raw, _ := json.Marshal(result)117+ var out map[string]any118+ _ = json.Unmarshal(raw, &out)119+ return out, nil120+}121+122+func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) {123+ h := newRoomHarness(t)124+ if _, err := h.publish("stranger", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, ""); err == nil {125+ t.Fatal("stranger created a room")126+ }127+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "")128+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"name", "Secret"}, {"closed"}}, "")129+ // A tenant member joins an open room on their first message; a members-only room refuses them.130+ hello := h.must("bob", event.KIND_CHAT, [][]string{{"h", "general"}}, "hello")131+ if role, _ := h.tenant.community.RoomRole(h.ctx, "general", h.keys["bob"]); role != community.RoomRoleMember {132+ t.Fatalf("bob role = %q", role)133+ }134+ if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "secret"}}, "psst"); err == nil {135+ t.Fatal("outsider wrote into a members-only room")136+ }137+ if _, err := h.publish("bob", event.KIND_CHAT, [][]string{{"h", "missing"}}, "?"); err == nil {138+ t.Fatal("unknown room accepted")139+ }140+ if _, err := h.publish("alice", event.KIND_ROOM_MEMBER_ADDED, [][]string{{"h", "general"}, {"p", h.keys["bob"]}}, ""); err == nil {141+ t.Fatal("client published a member notice")142+ }143+ // Presence is accepted from members and never stored.144+ h.must("bob", event.KIND_ROOM_PRESENCE, [][]string{{"h", "general"}}, "")145+ if rows := h.query("bob", event.Filter{Kinds: []int{event.KIND_ROOM_PRESENCE}}); len(rows) != 0 {146+ t.Fatalf("presence stored: %d", len(rows))147+ }148+ // Room-scoped metadata edits use room roles, not tenant moderation.149+ h.must("alice", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"about", "Everyone"}}, "")150+ if room, _ := h.tenant.community.Room(h.ctx, "general"); room.About != "Everyone" {151+ t.Fatalf("room edit lost: %+v", room)152+ }153+ if _, err := h.publish("bob", event.KIND_EDIT_METADATA, [][]string{{"h", "general"}, {"name", "Nope"}}, ""); err == nil {154+ t.Fatal("member edited the room")155+ }156+ // Relay records and member notices follow the durable projection.157+ h.projectRooms()158+ meta := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"general"}}})159+ if len(meta) != 1 || meta[0].PubKey != h.tenant.records.PublicKey() || event.Tag(meta[0], "name") != "General" || event.Tag(meta[0], "about") != "Everyone" {160+ t.Fatalf("39000 = %+v", meta)161+ }162+ admins := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_ADMINS}, Tags: map[string][]string{"d": {"general"}}})163+ if len(admins) != 1 || len(admins[0].Tags) < 3 || admins[0].Tags[2][1] != h.keys["alice"] || admins[0].Tags[2][2] != "owner" {164+ t.Fatalf("39001 = %+v", admins)165+ }166+ members := h.query("", event.Filter{Kinds: []int{event.KIND_GROUP_MEMBERS}, Tags: map[string][]string{"d": {"general"}}})167+ if len(members) != 1 || len(event.TagValues(members[0], "p")) != 2 {168+ t.Fatalf("39002 = %+v", members)169+ }170+ notices := h.query("", event.Filter{Kinds: []int{event.KIND_ROOM_MEMBER_ADDED}, Tags: map[string][]string{"h": {"general"}}})171+ if len(notices) != 2 || notices[0].PubKey != h.tenant.records.PublicKey() {172+ t.Fatalf("44100 notices = %+v", notices)173+ }174+ if hidden := h.query("bob", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(hidden) != 0 {175+ t.Fatal("members-only room record served to an outsider")176+ }177+ if shown := h.query("owner", event.Filter{Kinds: []int{event.KIND_GROUP_METADATA}, Tags: map[string][]string{"d": {"secret"}}}); len(shown) != 1 {178+ t.Fatal("members-only room record hidden from the tenant owner")179+ }180+ // Browse calls apply the same visibility.181+ list, err := h.browse("bob", "browserooms", nil)182+ if err != nil {183+ t.Fatal(err)184+ }185+ ids := map[string]map[string]any{}186+ for _, item := range list["items"].([]any) {187+ row := item.(map[string]any)188+ ids[row["id"].(string)] = row189+ }190+ if _, ok := ids["secret"]; ok || ids["general"] == nil || ids["main"] == nil {191+ t.Fatalf("rooms for bob = %v", ids)192+ }193+ if ids["general"]["members"].(float64) != 2 || ids["general"]["role"] != "member" || ids["general"]["last_message_at"].(float64) < float64(hello.CreatedAt) {194+ t.Fatalf("general summary = %v", ids["general"])195+ }196+ ownerList, _ := h.browse("owner", "browserooms", nil)197+ if len(ownerList["items"].([]any)) != 3 {198+ t.Fatalf("owner sees %d rooms", len(ownerList["items"].([]any)))199+ }200+ if _, err := h.browse("bob", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {201+ t.Fatalf("members-only room browsed by an outsider: %v", err)202+ }203+ if _, err := h.browse("", "browseroom", map[string]any{"id": "secret"}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {204+ t.Fatalf("members-only room browsed anonymously: %v", err)205+ }206+ room, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2})207+ if err != nil {208+ t.Fatal(err)209+ }210+ if len(room["members"].([]any)) != 2 || room["room"].(map[string]any)["name"] != "General" {211+ t.Fatalf("room detail = %v", room)212+ }213+ messages := room["messages"].([]any)214+ if len(messages) != 2 || room["next_cursor"] == "" {215+ t.Fatalf("messages = %d cursor=%v", len(messages), room["next_cursor"])216+ }217+ more, err := h.browse("bob", "browseroom", map[string]any{"id": "general", "limit": 2, "cursor": room["next_cursor"]})218+ if err != nil || len(more["messages"].([]any)) == 0 {219+ t.Fatalf("second page = %v %v", more, err)220+ }221+ // Threads: a kind 11 root and its kind 12 replies.222+ root := h.must("alice", event.KIND_THREAD, [][]string{{"h", "general"}, {"subject", "Plans"}}, "What next?")223+ reply := h.must("bob", event.KIND_THREAD_REPLY, [][]string{{"h", "general"}, {"e", root.ID}, {"p", h.keys["alice"]}}, "Ship it")224+ thread, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID})225+ if err != nil {226+ t.Fatal(err)227+ }228+ if thread["root"].(map[string]any)["id"] != root.ID || len(thread["replies"].([]any)) != 1 || thread["replies"].([]any)[0].(map[string]any)["id"] != reply.ID {229+ t.Fatalf("thread = %v", thread)230+ }231+ if _, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": hello.ID}); err != nil {232+ t.Fatalf("chat roots are threadable: %v", err)233+ }234+ // Device notices name the room and link to it.235+ mention := h.sign("bob", event.KIND_CHAT, [][]string{{"h", "general"}, {"p", h.keys["alice"]}}, "ping alice")236+ pushes := h.tenant.pushNotices(h.ctx, mention)237+ 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") {238+ t.Fatalf("push notices = %+v", pushes)239+ }240+ 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:") {241+ t.Fatalf("reply notices = %+v", pushes)242+ }243+ // Deleting the room retires its records and history.244+ if _, err := h.publish("bob", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, ""); err == nil {245+ t.Fatal("member deleted the room")246+ }247+ h.must("alice", event.KIND_DELETE_GROUP, [][]string{{"h", "general"}}, "")248+ h.projectRooms()249+ 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 {250+ t.Fatalf("deleted room records remain: %d", len(rows))251+ }252+ if _, err := h.browse("alice", "browseroom", map[string]any{"id": "general"}); err == nil {253+ t.Fatal("deleted room still browsable")254+ }255+}256+257+func TestRoomsPolicyCapAndSlugMirror(t *testing.T) {258+ h := newRoomHarness(t)259+ if h.tenant.Policy().Rooms != 64 {260+ t.Fatalf("default room allowance = %d", h.tenant.Policy().Rooms)261+ }262+ if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": 1})}); err != nil {263+ t.Fatal(err)264+ }265+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "one"}}, "")266+ if _, err := h.publish("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "two"}}, ""); err == nil || !strings.Contains(err.Error(), "allows 1 rooms") {267+ t.Fatalf("cap not enforced: %v", err)268+ }269+ if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "setpolicy", []json.RawMessage{rawJSON(map[string]any{"rooms": -1})}); err == nil {270+ t.Fatal("negative room allowance accepted")271+ }272+ slug, err := h.tenant.community.Room(h.ctx, "main")273+ if err != nil || !slug.Live() || slug.Access != community.RoomOpen {274+ t.Fatalf("slug room = %+v %v", slug, err)275+ }276+ if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != community.RoomRoleMember {277+ t.Fatalf("alice slug role = %q", role)278+ }279+ p := h.tenant.Policy()280+ p.Reads = "members"281+ if err := h.tenant.applyPolicy(h.ctx, p); err != nil {282+ t.Fatal(err)283+ }284+ if slug, _ := h.tenant.community.Room(h.ctx, "main"); slug.Access != community.RoomMembers {285+ t.Fatalf("slug access after policy change = %q", slug.Access)286+ }287+ if _, err := h.tenant.Execute(h.ctx, h.keys["owner"], "removemember", []json.RawMessage{rawJSON(h.keys["alice"])}); err != nil {288+ t.Fatal(err)289+ }290+ if role, _ := h.tenant.community.RoomRole(h.ctx, "main", h.keys["alice"]); role != "" {291+ t.Fatalf("removed member keeps slug role %q", role)292+ }293+ if role, _ := h.tenant.community.RoomRole(h.ctx, "one", h.keys["alice"]); role != "" {294+ t.Fatalf("removed member keeps room role %q", role)295+ }296+}297+298+func TestRoomStreamDeliversLiveMessages(t *testing.T) {299+ h := newRoomHarness(t)300+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}, {"name", "General"}}, "")301+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")302+ server := httptest.NewServer(h.app)303+ defer server.Close()304+305+ anonymous, err := http.Get(server.URL + "/rooms/general/stream")306+ if err != nil {307+ t.Fatal(err)308+ }309+ anonymous.Body.Close()310+ if anonymous.StatusCode != http.StatusOK {311+ t.Fatalf("open room stream without a session: %d", anonymous.StatusCode)312+ }313+ closed, err := http.Get(server.URL + "/rooms/secret/stream")314+ if err != nil {315+ t.Fatal(err)316+ }317+ closed.Body.Close()318+ if closed.StatusCode != http.StatusUnauthorized {319+ t.Fatalf("members-only room stream without a session: %d", closed.StatusCode)320+ }321+ missing, err := http.Get(server.URL + "/rooms/missing/stream")322+ if err != nil {323+ t.Fatal(err)324+ }325+ missing.Body.Close()326+ if missing.StatusCode != http.StatusNotFound {327+ t.Fatalf("unknown room stream: %d", missing.StatusCode)328+ }329+330+ ctx, cancel := context.WithCancel(context.Background())331+ defer cancel()332+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/rooms/general/stream", nil)333+ if err != nil {334+ t.Fatal(err)335+ }336+ signRequest(t, req, "")337+ res, err := http.DefaultClient.Do(req)338+ if err != nil {339+ t.Fatal(err)340+ }341+ defer res.Body.Close()342+ if res.StatusCode != http.StatusOK || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/event-stream") {343+ t.Fatalf("stream %d %s", res.StatusCode, res.Header.Get("Content-Type"))344+ }345+ reader := bufio.NewReader(res.Body)346+ line, err := reader.ReadString('\n')347+ if err != nil || line != ": room general\n" {348+ t.Fatalf("opening comment %q %v", line, err)349+ }350+ sent := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "live")351+ h.must("alice", event.KIND_CHAT, [][]string{{"h", "secret"}}, "elsewhere")352+ deadline := time.After(5 * time.Second)353+ lines := make(chan string, 8)354+ go func() {355+ for {356+ line, err := reader.ReadString('\n')357+ if err != nil {358+ close(lines)359+ return360+ }361+ lines <- line362+ }363+ }()364+ var got event.Event365+ for got.ID == "" {366+ select {367+ case line, ok := <-lines:368+ if !ok {369+ t.Fatal("stream ended early")370+ }371+ if line == "event: message\n" {372+ data := <-lines373+ if err := json.Unmarshal([]byte(strings.TrimPrefix(data, "data: ")), &got); err != nil {374+ t.Fatalf("frame %q: %v", data, err)375+ }376+ }377+ case <-deadline:378+ t.Fatal("no message within 5s")379+ }380+ }381+ if got.ID != sent.ID || got.Content != "live" {382+ t.Fatalf("streamed %+v, want %s", got, sent.ID)383+ }384+ cancel()385+ select {386+ case _, ok := <-lines:387+ if ok {388+ // Drain until the server closes the body.389+ for range lines {390+ }391+ }392+ case <-time.After(5 * time.Second):393+ t.Fatal("stream did not close after cancel")394+ }395+}@@ -0,0 +1,163 @@
1+package gates2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/policy"14+ "github.com/FelineStateMachine/tinyrelay/internal/relay"15+ "github.com/FelineStateMachine/tinyrelay/internal/storage"16+)17+18+func roomGate(t *testing.T) (*Gate, *community.Service, map[string]string, policy.Policy) {19+ t.Helper()20+ ctx := context.Background()21+ st, err := storage.Open(ctx, t.TempDir()+"/relay.db")22+ if err != nil {23+ t.Fatal(err)24+ }25+ t.Cleanup(func() { _ = st.Close() })26+ 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"}27+ keys := map[string]string{}28+ for name, secret := range secrets {29+ keys[name], _ = event.PublicKey(secret)30+ }31+ p := policy.Defaults(keys["owner"])32+ svc, err := community.New(ctx, st, keys["owner"])33+ if err != nil {34+ t.Fatal(err)35+ }36+ svc.ConfigurePolicy(func() policy.Policy { return p })37+ if err := svc.EnsureSlugRoom(ctx, "main", community.RoomOpen); err != nil {38+ t.Fatal(err)39+ }40+ for _, name := range []string{"alice", "bob"} {41+ if _, err := svc.Execute(ctx, keys["owner"], "setmember", []json.RawMessage{mustRaw(keys[name]), mustRaw(map[string]any{"role": "member"})}); err != nil {42+ t.Fatal(err)43+ }44+ }45+ sign := func(who string, kind int, tags [][]string) event.Event {46+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "x"}47+ if err := event.Sign(&e, secrets[who]); err != nil {48+ t.Fatal(err)49+ }50+ return e51+ }52+ persist := func(e event.Event) func(*sql.Tx) error {53+ return func(tx *sql.Tx) error {54+ _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()})55+ return err56+ }57+ }58+ for _, room := range []struct{ id, access string }{{"pub", "open"}, {"priv", "members"}} {59+ e := sign("alice", event.KIND_CREATE_GROUP, [][]string{{"h", room.id}, {"visibility", room.access}})60+ if _, err := svc.HandleRoomEventTx(ctx, e, persist(e)); err != nil {61+ t.Fatal(err)62+ }63+ }64+ g, err := New(Config{Store: st, Community: svc, Policy: func() policy.Policy { return p }, Slug: "main"})65+ if err != nil {66+ t.Fatal(err)67+ }68+ keys["secret:alice"], keys["secret:bob"], keys["secret:stranger"], keys["secret:owner"] = secrets["alice"], secrets["bob"], secrets["stranger"], secrets["owner"]69+ return g, svc, keys, p70+}71+72+func TestRoomWritesNeedMembership(t *testing.T) {73+ g, _, keys, _ := roomGate(t)74+ ctx := context.Background()75+ now := time.Now().Unix()76+ sign := func(who string, kind int, tags [][]string) event.Event {77+ e := event.Event{Kind: kind, CreatedAt: now, Tags: tags, Content: "x"}78+ if err := event.Sign(&e, keys["secret:"+who]); err != nil {79+ t.Fatal(err)80+ }81+ return e82+ }83+ for _, tc := range []struct {84+ name string85+ e event.Event86+ reject string87+ }{88+ {"unknown room", sign("alice", 9, [][]string{{"h", "nope"}}), "invalid: unknown room"},89+ {"room owner posts", sign("alice", 9, [][]string{{"h", "priv"}}), ""},90+ {"tenant member outside a members-only room", sign("bob", 9, [][]string{{"h", "priv"}}), "restricted:"},91+ {"tenant owner moderates any room", sign("owner", 9, [][]string{{"h", "priv"}}), ""},92+ {"tenant member in an open room", sign("bob", 9, [][]string{{"h", "pub"}}), ""},93+ {"stranger in an open room", sign("stranger", 9, [][]string{{"h", "pub"}}), "restricted:"},94+ {"reaction in an open room", sign("bob", 7, [][]string{{"h", "pub"}, {"e", strings.Repeat("a", 64)}}), ""},95+ {"rich content in an open room", sign("bob", event.KIND_RICH_CONTENT, [][]string{{"h", "pub"}}), ""},96+ {"presence from a member", sign("bob", event.KIND_ROOM_PRESENCE, [][]string{{"h", "pub"}}), ""},97+ {"presence from a stranger", sign("stranger", event.KIND_ROOM_TYPING, [][]string{{"h", "pub"}}), "restricted:"},98+ {"member notices are relay signed", sign("alice", event.KIND_ROOM_MEMBER_ADDED, [][]string{{"h", "pub"}, {"p", keys["bob"]}}), "blocked:"},99+ {"room creation names a new room", sign("bob", event.KIND_CREATE_GROUP, [][]string{{"h", "fresh"}}), ""},100+ {"join request reaches the room handler", sign("stranger", event.KIND_JOIN, [][]string{{"h", "pub"}}), ""},101+ {"slug room keeps the open write rule", sign("stranger", 9, [][]string{{"h", "main"}}), ""},102+ {"no h tag is outside rooms", sign("stranger", 9, nil), ""},103+ } {104+ t.Run(tc.name, func(t *testing.T) {105+ err := g.Write(ctx, tc.e, relay.Session{PubKeys: []string{tc.e.PubKey}}, now)106+ if tc.reject == "" && err != nil {107+ t.Fatalf("rejected: %v", err)108+ }109+ if tc.reject != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.reject)) {110+ t.Fatalf("want %q, got %v", tc.reject, err)111+ }112+ })113+ }114+ if err := g.Import(ctx, sign("alice", 9, [][]string{{"h", "gone"}}), now); err == nil {115+ t.Fatal("import accepted an unknown room")116+ }117+ if err := g.Import(ctx, sign("stranger", 9, [][]string{{"h", "priv"}}), now); err != nil {118+ t.Fatalf("import applies room existence only: %v", err)119+ }120+}121+122+func TestRoomReadsFollowRoomAccess(t *testing.T) {123+ g, _, keys, _ := roomGate(t)124+ ctx := context.Background()125+ private := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "priv"}}, Content: "secret"}126+ public := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "pub"}}, Content: "open"}127+ members := event.Event{Kind: event.KIND_GROUP_MEMBERS, PubKey: strings.Repeat("f", 64), CreatedAt: 1, Tags: [][]string{{"-"}, {"d", "priv"}, {"p", keys["alice"]}}}128+ main := event.Event{Kind: 9, PubKey: keys["alice"], CreatedAt: 1, Tags: [][]string{{"h", "main"}}, Content: "main"}129+ for _, tc := range []struct {130+ name string131+ e event.Event132+ who []string133+ want bool134+ }{135+ {"member sees a members-only room", private, []string{keys["alice"]}, true},136+ {"tenant owner sees every room", private, []string{keys["owner"]}, true},137+ {"tenant member outside the room does not", private, []string{keys["bob"]}, false},138+ {"anonymous does not", private, nil, false},139+ {"member list of a members-only room is hidden", members, []string{keys["bob"]}, false},140+ {"member list is served to members", members, []string{keys["alice"]}, true},141+ {"open rooms follow the tenant read rule", public, nil, true},142+ {"slug room is unchanged", main, nil, true},143+ } {144+ t.Run(tc.name, func(t *testing.T) {145+ if got := g.CanSee(ctx, tc.e, relay.Session{PubKeys: tc.who}, nil); got != tc.want {146+ t.Fatalf("visible = %v, want %v", got, tc.want)147+ }148+ })149+ }150+ hint, err := g.Read(ctx, []event.Filter{{Kinds: []int{9}, Tags: map[string][]string{"h": {"priv"}}}}, relay.Session{})151+ if err != nil || !hint {152+ t.Fatalf("members-only room read hint = %v %v", hint, err)153+ }154+ hint, err = g.Read(ctx, []event.Filter{{Kinds: []int{9}, Tags: map[string][]string{"h": {"pub"}}}}, relay.Session{})155+ if err != nil || hint {156+ t.Fatalf("open room read hint = %v %v", hint, err)157+ }158+}159+160+func mustRaw(v any) json.RawMessage {161+ b, _ := json.Marshal(v)162+ return b163+}@@ -0,0 +1,132 @@
1+package records2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/policy"14+ "github.com/FelineStateMachine/tinyrelay/internal/storage"15+)16+17+func TestRoomRecordsAndNotices(t *testing.T) {18+ s, ctx := testStore(t)19+ 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"}20+ keys := map[string]string{}21+ for name, secret := range secrets {22+ keys[name], _ = event.PublicKey(secret)23+ }24+ p := policy.Defaults(keys["owner"])25+ svc, err := community.New(ctx, s, keys["owner"])26+ if err != nil {27+ t.Fatal(err)28+ }29+ svc.ConfigurePolicy(func() policy.Policy { return p })30+ if err := svc.EnsureSlugRoom(ctx, "main", community.RoomOpen); err != nil {31+ t.Fatal(err)32+ }33+ raw := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b }34+ for _, name := range []string{"alice", "bob", "carol"} {35+ if _, err := svc.Execute(ctx, keys["owner"], "setmember", []json.RawMessage{raw(keys[name]), raw(map[string]any{"role": "member"})}); err != nil {36+ t.Fatal(err)37+ }38+ }39+ send := func(who string, kind int, tags [][]string) {40+ t.Helper()41+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags}42+ if err := event.Sign(&e, secrets[who]); err != nil {43+ t.Fatal(err)44+ }45+ if _, err := svc.HandleRoomEventTx(ctx, e, func(tx *sql.Tx) error {46+ _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: time.Now().Unix()})47+ return err48+ }); err != nil {49+ t.Fatal(err)50+ }51+ }52+ send("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "team"}, {"name", "Team"}, {"about", "Ours"}, {"picture", "https://img.example/t.png"}, {"closed"}})53+ send("alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", keys["bob"], "admin"}})54+ send("alice", event.KIND_PUT_USER, [][]string{{"h", "team"}, {"p", keys["carol"]}})55+56+ var generated []event.Event57+ 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 {58+ generated = append(generated, e)59+ _, err := s.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt})60+ return err61+ }})62+ if err != nil {63+ t.Fatal(err)64+ }65+ records, err := r.PublishRoom(ctx, "team", 100)66+ if err != nil {67+ t.Fatal(err)68+ }69+ 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 {70+ t.Fatalf("records = %+v", records)71+ }72+ meta := records[0]73+ 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" {74+ t.Fatalf("metadata tags = %v", meta.Tags)75+ }76+ if !hasBareTag(meta, "closed") || hasBareTag(meta, "private") || !hasBareTag(meta, "public") {77+ t.Fatalf("access tags = %v", meta.Tags)78+ }79+ admins := map[string]string{}80+ for _, tag := range records[1].Tags {81+ if tag[0] == "p" {82+ admins[tag[1]] = tag[2]83+ }84+ }85+ if len(admins) != 2 || admins[keys["alice"]] != "owner" || admins[keys["bob"]] != "admin" {86+ t.Fatalf("admins = %v", admins)87+ }88+ if got := event.TagValues(records[2], "p"); len(got) != 3 {89+ t.Fatalf("members = %v", got)90+ }91+ for _, rec := range records {92+ if rec.PubKey != r.PublicKey() || event.Validate(rec) != nil {93+ t.Fatalf("record not relay-signed: %+v", rec)94+ }95+ }96+ p.Reads = "members"97+ records, err = r.PublishRoom(ctx, "team", 101)98+ if err != nil || !hasBareTag(records[0], "private") {99+ t.Fatalf("private marker missing: %v %v", records[0].Tags, err)100+ }101+ notice, err := r.RoomNotice(ctx, "team", keys["carol"], true, 102)102+ if err != nil || notice.Kind != event.KIND_ROOM_MEMBER_ADDED || event.Tag(notice, "h") != "team" || event.Tag(notice, "p") != keys["carol"] {103+ t.Fatalf("added notice = %+v %v", notice, err)104+ }105+ notice, err = r.RoomNotice(ctx, "team", keys["carol"], false, 103)106+ if err != nil || notice.Kind != event.KIND_ROOM_MEMBER_REMOVED {107+ t.Fatalf("removed notice = %+v %v", notice, err)108+ }109+ 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}})110+ if err != nil || len(stored.Events) != 3 {111+ t.Fatalf("stored records = %d %v", len(stored.Events), err)112+ }113+ if err := r.RetireRoom(ctx, "team"); err != nil {114+ t.Fatal(err)115+ }116+ 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}})117+ if err != nil || len(stored.Events) != 0 {118+ t.Fatalf("retired records remain: %d %v", len(stored.Events), err)119+ }120+ if len(generated) < 8 {121+ t.Fatalf("generated %d events", len(generated))122+ }123+}124+125+func hasBareTag(e event.Event, name string) bool {126+ for _, tag := range e.Tags {127+ if len(tag) == 1 && tag[0] == name {128+ return true129+ }130+ }131+ return false132+}