Add an admin API to get the space hierarchy (#19021)

It is often useful when investigating a space to get information about
that space and it's children. This PR adds an Admin API to return
information about a space and it's children, regardless of room
membership. Will not fetch information over federation about remote
rooms that the server is not participating in.
This commit is contained in:
Shay
2025-10-24 13:32:16 -07:00
committed by GitHub
parent 9d81bb703c
commit f1695ac20e
6 changed files with 475 additions and 21 deletions

View File

@@ -31,7 +31,7 @@ from twisted.internet.task import deferLater
from twisted.internet.testing import MemoryReactor
import synapse.rest.admin
from synapse.api.constants import EventTypes, Membership, RoomTypes
from synapse.api.constants import EventContentFields, EventTypes, Membership, RoomTypes
from synapse.api.errors import Codes
from synapse.api.room_versions import RoomVersions
from synapse.handlers.pagination import (
@@ -56,6 +56,308 @@ from tests import unittest
ONE_HOUR_IN_S = 3600
class AdminHierarchyTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
# create some users
self.admin_user = self.register_user("admin", "pass", admin=True)
self.admin_user_tok = self.login("admin", "pass")
self.other_user = self.register_user("user", "pass")
self.other_user_tok = self.login("user", "pass")
self.third_user = self.register_user("third_user", "pass")
self.third_user_tok = self.login("third_user", "pass")
# mock out the function which pulls room information in over federation.
self._room_summary_handler = hs.get_room_summary_handler()
self._room_summary_handler._summarize_remote_room_hierarchy = Mock() # type: ignore[method-assign]
# create some rooms with different options
self.room_id1 = self.helper.create_room_as(
self.other_user,
is_public=False,
tok=self.other_user_tok,
extra_content={"name": "nefarious", "topic": "being bad"},
)
self.room_id2 = self.helper.create_room_as(
self.third_user,
tok=self.third_user_tok,
extra_content={"name": "also nefarious"},
)
self.room_id3 = self.helper.create_room_as(
self.admin_user,
is_public=False,
tok=self.admin_user_tok,
extra_content={
"name": "not nefarious",
"topic": "happy things",
"creation_content": {
"additional_creators": [self.other_user, self.third_user]
},
},
room_version="12",
)
self.not_in_space_room_id = self.helper.create_room_as(
self.other_user,
tok=self.other_user_tok,
extra_content={"name": "not related to other rooms"},
)
# create a space room
self.space_room_id = self.helper.create_room_as(
self.other_user,
is_public=True,
extra_content={
"visibility": "public",
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE},
"name": "space_room",
},
tok=self.other_user_tok,
)
# and an unjoined remote room
self.remote_room_id = "!remote_room"
self.room_id_to_human_name_map = {
self.room_id1: "room1",
self.room_id2: "room2",
self.room_id3: "room3",
self.not_in_space_room_id: "room4",
self.space_room_id: "space_room",
self.remote_room_id: "remote_room",
}
# add three of the rooms to space
for state_key in [self.room_id1, self.room_id2, self.room_id3]:
self.helper.send_state(
self.space_room_id,
EventTypes.SpaceChild,
body={"via": ["local_test_server"]},
tok=self.other_user_tok,
state_key=state_key,
)
# and add remote room to space - ideally we'd add an actual remote
# space with rooms in it but the test framework doesn't currently
# support that. Instead we add a room which the server would have to
# reach out over federation to get details about and assert that the
# federation call was not made
self.helper.send_state(
self.space_room_id,
EventTypes.SpaceChild,
body={"via": ["remote_test_server"]},
tok=self.other_user_tok,
state_key=self.remote_room_id,
)
def test_no_auth(self) -> None:
"""
If the requester does not provide authentication, a 401 is returned
"""
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy",
)
self.assertEqual(401, channel.code, msg=channel.json_body)
self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"])
def test_requester_is_no_admin(self) -> None:
"""
If the requester is not a server admin, an error 403 is returned.
"""
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy",
access_token=self.other_user_tok,
)
self.assertEqual(403, channel.code, msg=channel.json_body)
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
def test_bad_request(self) -> None:
"""
Test that invalid param values raise an error
"""
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy?limit=ten",
access_token=self.admin_user_tok,
)
self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"])
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy?max_depth=four",
access_token=self.admin_user_tok,
)
self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"])
def test_room_summary(self) -> None:
"""
Test that details of room and details of children of room are
provided correctly
"""
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy",
access_token=self.admin_user_tok,
)
self.assertEqual(channel.code, 200, msg=channel.json_body)
rooms = channel.json_body["rooms"]
self.assertCountEqual(
{
self.room_id_to_human_name_map.get(
room["room_id"], f"Unknown room: {room['room_id']}"
)
for room in rooms
},
{"space_room", "room1", "room2", "room3"},
)
for room_result in rooms:
room_id = room_result["room_id"]
if room_id == self.room_id1:
self.assertEqual(room_result["name"], "nefarious")
self.assertEqual(room_result["topic"], "being bad")
self.assertEqual(room_result["join_rule"], "invite")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], True)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.room_id2:
self.assertEqual(room_result["name"], "also nefarious")
self.assertEqual(room_result["join_rule"], "public")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], False)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.room_id3:
self.assertEqual(room_result["name"], "not nefarious")
self.assertEqual(room_result["join_rule"], "invite")
self.assertEqual(room_result["topic"], "happy things")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], True)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.not_in_space_room_id:
self.fail("this room should not have been returned")
elif room_id == self.space_room_id:
self.assertEqual(room_result["join_rule"], "public")
self.assertEqual(len(room_result["children_state"]), 4)
self.assertEqual(room_result["room_type"], "m.space")
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], False)
self.assertEqual(room_result["num_joined_members"], 1)
self.assertEqual(room_result["name"], "space_room")
else:
self.fail("unknown room returned")
# Assert that a federation function to look up details about
# this room has not been called. We never expect the admin
# hierarchy endpoint to reach out over federation.
self._room_summary_handler._summarize_remote_room_hierarchy.assert_not_called() # type: ignore[attr-defined]
def test_room_summary_pagination(self) -> None:
"""
Test that details of room and details of children of room are provided
correctly when paginating
"""
channel = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy?limit=2",
access_token=self.admin_user_tok,
)
self.assertEqual(channel.code, 200, msg=channel.json_body)
rooms = channel.json_body["rooms"]
self.assertCountEqual(
{
self.room_id_to_human_name_map.get(
room["room_id"], f"Unknown room: {room['room_id']}"
)
for room in rooms
},
{"space_room", "room1"},
)
next_batch = channel.json_body["next_batch"]
channel2 = self.make_request(
"GET",
f"/_synapse/admin/v1/rooms/{self.space_room_id}/hierarchy?from={next_batch}",
access_token=self.admin_user_tok,
)
self.assertEqual(channel2.code, 200, msg=channel2.json_body)
new_rooms = channel2.json_body["rooms"]
self.assertCountEqual(
{
self.room_id_to_human_name_map.get(
room["room_id"], f"Unknown room: {room['room_id']}"
)
for room in new_rooms
},
{"room2", "room3"},
)
rooms_to_check = rooms + new_rooms
for room_result in rooms_to_check:
room_id = room_result["room_id"]
if room_id == self.room_id1:
self.assertEqual(room_result["name"], "nefarious")
self.assertEqual(room_result["topic"], "being bad")
self.assertEqual(room_result["join_rule"], "invite")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], True)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.room_id2:
self.assertEqual(room_result["name"], "also nefarious")
self.assertEqual(room_result["join_rule"], "public")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], False)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.room_id3:
self.assertEqual(room_result["name"], "not nefarious")
self.assertEqual(room_result["join_rule"], "invite")
self.assertEqual(room_result["topic"], "happy things")
self.assertEqual(len(room_result["children_state"]), 0)
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], True)
self.assertEqual(room_result["num_joined_members"], 1)
elif room_id == self.not_in_space_room_id:
self.fail("this room should not have been returned")
elif room_id == self.space_room_id:
self.assertEqual(room_result["join_rule"], "public")
self.assertEqual(len(room_result["children_state"]), 4)
self.assertEqual(room_result["room_type"], "m.space")
self.assertEqual(room_result["world_readable"], False)
self.assertEqual(room_result["guest_can_join"], False)
self.assertEqual(room_result["num_joined_members"], 1)
self.assertEqual(room_result["name"], "space_room")
else:
self.fail("unknown room returned")
# Assert that a federation function to look up details about
# this room has not been called. We never expect the admin
# hierarchy endpoint to reach out over federation.
self._room_summary_handler._summarize_remote_room_hierarchy.assert_not_called() # type: ignore[attr-defined]
class DeleteRoomTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,