package clientapi_test import ( "net/http" "net/url" "testing" ) type hierarchyResult struct { Rooms []struct { RoomID string `json:"room_id"` Name string `json:"name"` RoomType string `json:"room_type"` JoinRule string `json:"join_rule"` NumJoinedMembers int `json:"num_joined_members"` ChildrenState []map[string]any `json:"children_state"` } `json:"rooms"` NextBatch string `json:"next_batch"` } func (h hierarchyResult) ids() []string { out := make([]string, 0, len(h.Rooms)) for _, r := range h.Rooms { out = append(out, r.RoomID) } return out } func (ts *testServer) hierarchy(acc account, roomID, query string) hierarchyResult { ts.t.Helper() rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(roomID)+"/hierarchy"+query, acc.AccessToken) expectStatus(ts.t, rec, http.StatusOK) return decode[hierarchyResult](ts.t, rec) } // addChild puts a room into a space. func (ts *testServer) addChild(acc account, spaceID, childID string, content map[string]any) { ts.t.Helper() if content == nil { content = map[string]any{} } if _, ok := content["via"]; !ok { content["via"] = []string{"warp.example"} } rec := ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(spaceID)+"/state/m.space.child/"+url.PathEscape(childID), acc.AccessToken, content) expectStatus(ts.t, rec, http.StatusOK) } func (ts *testServer) createSpace(acc account, name string, public bool) string { ts.t.Helper() body := map[string]any{"name": name, "creation_content": map[string]any{"type": "m.space"}} if public { body["preset"] = "public_chat" } return ts.createRoom(acc, body) } func TestSpaceHierarchy(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") space := ts.createSpace(alice, "Space", true) sub := ts.createSpace(alice, "Subspace", true) general := ts.createRoom(alice, map[string]any{"name": "General", "preset": "public_chat"}) random := ts.createRoom(alice, map[string]any{"name": "Random", "preset": "public_chat"}) secret := ts.createRoom(alice, map[string]any{"name": "Secret"}) deep := ts.createRoom(alice, map[string]any{"name": "Deep", "preset": "public_chat"}) ts.addChild(alice, space, random, map[string]any{"order": "b"}) ts.addChild(alice, space, general, map[string]any{"order": "a", "suggested": true}) ts.addChild(alice, space, sub, nil) ts.addChild(alice, space, secret, nil) ts.addChild(alice, sub, deep, nil) // A child event with no via is a removed child. removed := ts.createRoom(alice, map[string]any{"name": "Gone", "preset": "public_chat"}) ts.addChild(alice, space, removed, map[string]any{"via": []string{}}) // Alice is in every room, so she sees all of them: the root, its // children by order and then by timestamp, then the subspace's child. all := ts.hierarchy(alice, space, "") want := []string{space, general, random, sub, secret, deep} if got := all.ids(); !equalStrings(got, want) { t.Fatalf("alice's hierarchy = %v, want %v", got, want) } if all.Rooms[0].RoomType != "m.space" || len(all.Rooms[0].ChildrenState) != 4 { t.Errorf("root = %+v, want a space with four live children", all.Rooms[0]) } // Bob is in nothing: the invite-only room is hidden from him. if got := ts.hierarchy(bob, space, "").ids(); !equalStrings(got, []string{space, general, random, sub, deep}) { t.Errorf("bob's hierarchy = %v", got) } if got := ts.hierarchy(alice, space, "?max_depth=1").ids(); !equalStrings(got, []string{space, general, random, sub, secret}) { t.Errorf("max_depth=1 = %v", got) } if got := ts.hierarchy(alice, space, "?suggested_only=true").ids(); !equalStrings(got, []string{space, general}) { t.Errorf("suggested_only = %v", got) } // Paging two at a time walks the same order. var paged []string from := "" for range 10 { query := "?limit=2" if from != "" { query += "&from=" + url.QueryEscape(from) } page := ts.hierarchy(alice, space, query) paged = append(paged, page.ids()...) if page.NextBatch == "" { break } from = page.NextBatch } if !equalStrings(paged, want) { t.Errorf("paged hierarchy = %v, want %v", paged, want) } } func TestSpaceHierarchyAccess(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") carol := ts.register("carol", "correct-horse-battery-staple") private := ts.createSpace(alice, "Private", false) rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(private)+"/hierarchy", bob.AccessToken) expectStatus(t, rec, http.StatusForbidden) // A restricted room is visible to the members of the room it lets in. space := ts.createSpace(alice, "Space", true) restricted := ts.createRoom(alice, map[string]any{ "name": "Members only", "initial_state": []map[string]any{{ "type": "m.room.join_rules", "state_key": "", "content": map[string]any{ "join_rule": "restricted", "allow": []map[string]any{{"type": "m.room_membership", "room_id": space}}, }, }}, }) ts.addChild(alice, space, restricted, nil) expectStatus(t, ts.post("/_matrix/client/v3/join/"+url.PathEscape(space), bob.AccessToken, map[string]any{}), http.StatusOK) if got := ts.hierarchy(bob, space, "").ids(); !equalStrings(got, []string{space, restricted}) { t.Errorf("a member of the space sees %v, want the restricted room too", got) } if got := ts.hierarchy(carol, space, "").ids(); !equalStrings(got, []string{space}) { t.Errorf("a stranger sees %v, want the space alone", got) } } func TestRoomSummary(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") public := ts.createRoom(alice, map[string]any{ "name": "Lobby", "topic": "Say hello", "preset": "public_chat", "room_alias_name": "lobby", }) private := ts.createRoom(alice, map[string]any{"name": "Private"}) for _, target := range []string{public, "#lobby:warp.example"} { rec := ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(target), bob.AccessToken) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if body["room_id"] != public || body["name"] != "Lobby" || body["topic"] != "Say hello" || body["membership"] != "leave" || body["join_rule"] != "public" || body["room_version"] == nil { t.Errorf("summary of %s = %v", target, body) } } rec := ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(private), bob.AccessToken) expectStatus(t, rec, http.StatusForbidden) rec = ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(private), alice.AccessToken) expectStatus(t, rec, http.StatusOK) if jsonBody(t, rec)["membership"] != "join" { t.Errorf("alice's summary of her own room says membership %v", jsonBody(t, rec)["membership"]) } } // children_state is required on every room /hierarchy returns, an empty // list for one with no children: clients that parse the response strictly // (the famedly Dart SDK) fail on a room without it. func TestSpaceHierarchyEmptyChildrenState(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") space := ts.createSpace(alice, "Empty space", false) room := ts.createRoom(alice, map[string]any{"name": "Leaf"}) ts.addChild(alice, space, room, nil) rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(space)+"/hierarchy", alice.AccessToken) expectStatus(t, rec, http.StatusOK) raw := decode[struct { Rooms []map[string]any `json:"rooms"` }](t, rec) if len(raw.Rooms) != 2 { t.Fatalf("got %d rooms, want the space and its child", len(raw.Rooms)) } for _, r := range raw.Rooms { children, ok := r["children_state"].([]any) if !ok { t.Errorf("%v: children_state = %#v, want a list", r["room_id"], r["children_state"]) } if r["room_id"] == room && len(children) != 0 { t.Errorf("the leaf room has children_state %v", children) } } }