diff --git a/tests/handlers/test_room_summary.py b/tests/handlers/test_room_summary.py
index e5a6a6c747..e85d112ecc 100644
--- a/tests/handlers/test_room_summary.py
+++ b/tests/handlers/test_room_summary.py
@@ -28,7 +28,7 @@ from synapse.api.constants import (
from synapse.api.errors import AuthError, NotFoundError, SynapseError
from synapse.api.room_versions import RoomVersions
from synapse.events import make_event_from_dict
-from synapse.handlers.room_summary import _child_events_comparison_key, _RoomEntry
+from synapse.handlers.room_summary import _RoomEntry, child_events_comparison_key
from synapse.rest import admin
from synapse.rest.client import login, room
from synapse.server import HomeServer
@@ -48,7 +48,7 @@ def _create_event(room_id: str, order: Optional[Any] = None, origin_server_ts: i
def _order(*events):
- return sorted(events, key=_child_events_comparison_key)
+ return sorted(events, key=child_events_comparison_key)
class TestSpaceSummarySort(unittest.TestCase):
diff --git a/tests/handlers/test_space_hierarchy.py b/tests/handlers/test_space_hierarchy.py
new file mode 100644
index 0000000000..63bc93d558
--- /dev/null
+++ b/tests/handlers/test_space_hierarchy.py
@@ -0,0 +1,239 @@
+# Copyright 2021 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Dict, Iterable, Mapping, NoReturn, Optional, Sequence, Tuple
+from unittest import mock
+
+from twisted.test.proto_helpers import MemoryReactor
+
+from synapse.api.constants import EventContentFields, EventTypes, RoomTypes
+from synapse.handlers.space_hierarchy import SpaceHierarchyHandler
+from synapse.rest import admin
+from synapse.rest.client import login, room
+from synapse.server import HomeServer
+from synapse.types import JsonDict
+from synapse.util import Clock
+
+from tests import unittest
+
+
+class SpaceDescendantsTestCase(unittest.HomeserverTestCase):
+ """Tests iteration over the descendants of a space."""
+
+ servlets = [
+ admin.register_servlets_for_client_rest_resource,
+ login.register_servlets,
+ room.register_servlets,
+ ]
+
+ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer):
+ self.hs = hs
+ self.handler = self.hs.get_space_hierarchy_handler()
+
+ # Create a user.
+ self.user = self.register_user("user", "pass")
+ self.token = self.login("user", "pass")
+
+ # Create a space and a child room.
+ self.space = self.helper.create_room_as(
+ self.user,
+ tok=self.token,
+ extra_content={
+ "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
+ },
+ )
+ self.room = self.helper.create_room_as(self.user, tok=self.token)
+ self._add_child(self.space, self.room)
+
+ def _add_child(
+ self, space_id: str, room_id: str, order: Optional[str] = None
+ ) -> None:
+ """Adds a room to a space."""
+ content: JsonDict = {"via": [self.hs.hostname]}
+ if order is not None:
+ content["order"] = order
+ self.helper.send_state(
+ space_id,
+ event_type=EventTypes.SpaceChild,
+ body=content,
+ tok=self.token,
+ state_key=room_id,
+ )
+
+ def _create_space(self) -> str:
+ """Creates a space."""
+ return self._create_room(
+ extra_content={
+ "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
+ },
+ )
+
+ def _create_room(self, extra_content: Optional[Dict] = None) -> str:
+ """Creates a room."""
+ return self.helper.create_room_as(
+ self.user,
+ tok=self.token,
+ extra_content=extra_content,
+ )
+
+ def test_empty_space(self):
+ """Tests iteration over an empty space."""
+ space_id = self._create_space()
+
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(space_id)
+ )
+
+ self.assertEqual(descendants, [(space_id, [])])
+ self.assertEqual(inaccessible_room_ids, [])
+
+ def test_invalid_space(self):
+ """Tests iteration over an inaccessible space."""
+ space_id = f"!invalid:{self.hs.hostname}"
+
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(space_id)
+ )
+
+ self.assertEqual(descendants, [(space_id, [])])
+ self.assertEqual(inaccessible_room_ids, [space_id])
+
+ def test_invalid_room(self):
+ """Tests iteration over a space containing an inaccessible room."""
+ space_id = self._create_space()
+ room_id = f"!invalid:{self.hs.hostname}"
+ self._add_child(space_id, room_id)
+
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(space_id)
+ )
+
+ self.assertEqual(descendants, [(space_id, []), (room_id, [self.hs.hostname])])
+ self.assertEqual(inaccessible_room_ids, [room_id])
+
+ def test_remote_space_with_federation_enabled(self):
+ """Tests iteration over a remote space with federation enabled."""
+ space_id = "!space:remote"
+ room_id = "!room:remote"
+
+ async def _get_space_children_remote(
+ _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str]
+ ) -> Tuple[
+ Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]
+ ]:
+ if space_id == "!space:remote":
+ self.assertEqual(via, ["remote"])
+ return [("!room:remote", ["remote"])], {}
+ elif space_id == "!room:remote":
+ self.assertEqual(via, ["remote"])
+ return [], {}
+ else:
+ self.fail(
+ f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call"
+ )
+ raise # `fail` is missing type hints
+
+ with mock.patch(
+ "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote",
+ new=_get_space_children_remote,
+ ):
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(
+ space_id, via=["remote"], enable_federation=True
+ )
+ )
+
+ self.assertEqual(descendants, [(space_id, ["remote"]), (room_id, ["remote"])])
+ self.assertEqual(inaccessible_room_ids, [space_id, room_id])
+
+ def test_remote_space_with_federation_disabled(self):
+ """Tests iteration over a remote space with federation disabled."""
+ space_id = "!space:remote"
+
+ async def _get_space_children_remote(
+ _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str]
+ ) -> NoReturn:
+ self.fail(
+ f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call"
+ )
+ raise # `fail` is missing type hints
+
+ with mock.patch(
+ "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote",
+ new=_get_space_children_remote,
+ ):
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(
+ space_id, via=["remote"], enable_federation=False
+ )
+ )
+
+ self.assertEqual(descendants, [(space_id, ["remote"])])
+ self.assertEqual(inaccessible_room_ids, [space_id])
+
+ def test_cycle(self):
+ """Tests iteration over a cyclic space."""
+ # space_id
+ # - subspace_id
+ # - space_id
+ space_id = self._create_space()
+ subspace_id = self._create_space()
+ self._add_child(space_id, subspace_id)
+ self._add_child(subspace_id, space_id)
+
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(space_id)
+ )
+
+ self.assertEqual(
+ descendants, [(space_id, []), (subspace_id, [self.hs.hostname])]
+ )
+ self.assertEqual(inaccessible_room_ids, [])
+
+ def test_duplicates(self):
+ """Tests iteration over a space with repeated rooms."""
+ # space_id
+ # - subspace_id
+ # - duplicate_room_1_id
+ # - duplicate_room_2_id
+ # - room_id
+ # - duplicate_room_1_id
+ # - duplicate_room_2_id
+ space_id = self._create_space()
+ subspace_id = self._create_space()
+ room_id = self._create_room()
+ duplicate_room_1_id = self._create_room()
+ duplicate_room_2_id = self._create_room()
+ self._add_child(space_id, subspace_id, order="1")
+ self._add_child(space_id, duplicate_room_1_id, order="2")
+ self._add_child(space_id, duplicate_room_2_id, order="3")
+ self._add_child(subspace_id, duplicate_room_1_id, order="1")
+ self._add_child(subspace_id, duplicate_room_2_id, order="2")
+ self._add_child(subspace_id, room_id, order="3")
+
+ descendants, inaccessible_room_ids = self.get_success(
+ self.handler.get_space_descendants(space_id)
+ )
+
+ self.assertEqual(
+ descendants,
+ [
+ (space_id, []),
+ (subspace_id, [self.hs.hostname]),
+ (room_id, [self.hs.hostname]),
+ (duplicate_room_1_id, [self.hs.hostname]),
+ (duplicate_room_2_id, [self.hs.hostname]),
+ ],
+ )
+ self.assertEqual(inaccessible_room_ids, [])
diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py
new file mode 100644
index 0000000000..2aa5a26142
--- /dev/null
+++ b/tests/rest/admin/test_space.py
@@ -0,0 +1,399 @@
+# Copyright 2021 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import (
+ Dict,
+ Iterable,
+ List,
+ Mapping,
+ NoReturn,
+ Optional,
+ Sequence,
+ Tuple,
+ Union,
+)
+from unittest import mock
+
+from typing_extensions import Literal
+
+from twisted.test.proto_helpers import MemoryReactor
+
+import synapse.rest.admin
+from synapse.api.constants import (
+ EventContentFields,
+ EventTypes,
+ JoinRules,
+ Membership,
+ RestrictedJoinRuleTypes,
+ RoomTypes,
+)
+from synapse.api.room_versions import RoomVersions
+from synapse.handlers.space_hierarchy import SpaceHierarchyHandler
+from synapse.rest.client import login, room
+from synapse.server import HomeServer
+from synapse.types import JsonDict
+from synapse.util import Clock
+
+from tests import unittest
+
+
+class RemoveSpaceMemberTestCase(unittest.HomeserverTestCase):
+ """Tests removal of a user from a space."""
+
+ servlets = [
+ synapse.rest.admin.register_servlets,
+ login.register_servlets,
+ room.register_servlets,
+ ]
+
+ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer):
+ self.store = hs.get_datastore()
+
+ # Create users
+ self.admin_user = self.register_user("admin", "pass", admin=True)
+ self.admin_user_tok = self.login("admin", "pass")
+ self.space_owner_user = self.register_user("space_owner", "pass")
+ self.space_owner_user_tok = self.login("space_owner", "pass")
+ self.target_user = self.register_user("user", "pass")
+ self.target_user_tok = self.login("user", "pass")
+
+ # Create a space hierarchy for testing:
+ # space, invite-only
+ # * subspace, restricted
+ self.space_id = self._create_space(JoinRules.INVITE)
+
+ # Make the target user a member of the space
+ self.helper.invite(
+ self.space_id,
+ src=self.space_owner_user,
+ targ=self.target_user,
+ tok=self.space_owner_user_tok,
+ )
+ self.helper.join(self.space_id, self.target_user, tok=self.target_user_tok)
+
+ self.subspace_id = self._create_space((JoinRules.RESTRICTED, self.space_id))
+ self._add_child(self.space_id, self.subspace_id)
+
+ def _add_child(
+ self, space_id: str, room_id: str, via: Optional[List[str]] = None
+ ) -> None:
+ """Adds a room to a space."""
+ if via is None:
+ via = [self.hs.hostname]
+
+ self.helper.send_state(
+ space_id,
+ event_type=EventTypes.SpaceChild,
+ body={"via": via},
+ tok=self.space_owner_user_tok,
+ state_key=room_id,
+ )
+
+ def _create_space(
+ self,
+ join_rules: Union[
+ Literal["public", "invite", "knock"],
+ Tuple[Literal["restricted"], str],
+ ],
+ ) -> str:
+ """Creates a space."""
+ return self._create_room(
+ join_rules,
+ extra_content={
+ "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
+ },
+ )
+
+ def _create_room(
+ self,
+ join_rules: Union[
+ Literal["public", "invite", "knock"],
+ Tuple[Literal["restricted"], str],
+ ],
+ extra_content: Optional[Dict] = None,
+ ) -> str:
+ """Creates a room."""
+ room_id = self.helper.create_room_as(
+ self.space_owner_user,
+ room_version=RoomVersions.V8.identifier,
+ tok=self.space_owner_user_tok,
+ extra_content=extra_content,
+ )
+
+ if isinstance(join_rules, str):
+ self.helper.send_state(
+ room_id,
+ event_type=EventTypes.JoinRules,
+ body={"join_rule": join_rules},
+ tok=self.space_owner_user_tok,
+ )
+ else:
+ _, space_id = join_rules
+ self.helper.send_state(
+ room_id,
+ event_type=EventTypes.JoinRules,
+ body={
+ "join_rule": JoinRules.RESTRICTED,
+ "allow": [
+ {
+ "type": RestrictedJoinRuleTypes.ROOM_MEMBERSHIP,
+ "room_id": space_id,
+ "via": [self.hs.hostname],
+ }
+ ],
+ },
+ tok=self.space_owner_user_tok,
+ )
+
+ return room_id
+
+ def _remove_from_space(
+ self,
+ user_id: str,
+ space_id: Optional[str] = None,
+ include_remote_spaces: Optional[bool] = None,
+ ) -> JsonDict:
+ """Removes the given user from the test space."""
+ if space_id is None:
+ space_id = self.space_id
+
+ content: Union[bytes, JsonDict] = b""
+ if include_remote_spaces is not None:
+ content = {"include_remote_spaces": include_remote_spaces}
+
+ url = f"/_synapse/admin/v1/rooms/{self.space_id}/hierarchy/members/{user_id}"
+ channel = self.make_request(
+ "DELETE",
+ url.encode("ascii"),
+ access_token=self.admin_user_tok,
+ content=content,
+ )
+
+ self.assertEqual(200, channel.code, channel.json_body)
+
+ return channel.json_body
+
+ def test_public_space(self) -> None:
+ """Tests that the user is removed from the space, even if public."""
+ self.helper.send_state(
+ self.space_id,
+ event_type=EventTypes.JoinRules,
+ body={"join_rule": JoinRules.PUBLIC},
+ tok=self.space_owner_user_tok,
+ )
+
+ response = self._remove_from_space(self.target_user)
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id],
+ "inaccessible_rooms": [],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, self.space_id
+ )
+ )
+ self.assertEqual(membership, Membership.LEAVE)
+
+ def test_public_room(self) -> None:
+ """Tests that the user is not removed from public rooms."""
+ public_room_id = self._create_room(JoinRules.PUBLIC)
+ self._add_child(self.subspace_id, public_room_id)
+
+ self.helper.join(public_room_id, self.target_user, tok=self.target_user_tok)
+
+ response = self._remove_from_space(self.target_user)
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id],
+ "inaccessible_rooms": [],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, public_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.JOIN)
+
+ def test_invited(self) -> None:
+ """Tests that the user is made to decline invites to rooms in the space."""
+ invite_only_room_id = self._create_room(JoinRules.INVITE)
+ self._add_child(self.subspace_id, invite_only_room_id)
+
+ self.helper.invite(
+ invite_only_room_id,
+ src=self.space_owner_user,
+ targ=self.target_user,
+ tok=self.space_owner_user_tok,
+ )
+
+ response = self._remove_from_space(self.target_user)
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id, invite_only_room_id],
+ "inaccessible_rooms": [],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, invite_only_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.LEAVE)
+
+ def test_invite_only_room(self) -> None:
+ """Tests that the user is made to leave invite-only rooms."""
+ invite_only_room_id = self._create_room(JoinRules.INVITE)
+ self._add_child(self.subspace_id, invite_only_room_id)
+
+ self.helper.invite(
+ invite_only_room_id,
+ src=self.space_owner_user,
+ targ=self.target_user,
+ tok=self.space_owner_user_tok,
+ )
+ self.helper.join(
+ invite_only_room_id, self.target_user, tok=self.target_user_tok
+ )
+
+ response = self._remove_from_space(self.target_user)
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id, invite_only_room_id],
+ "inaccessible_rooms": [],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, invite_only_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.LEAVE)
+
+ def test_restricted_room(self) -> None:
+ """Tests that the user is made to leave restricted rooms."""
+ restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id))
+ self._add_child(self.subspace_id, restricted_room_id)
+ self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok)
+
+ response = self._remove_from_space(self.target_user)
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id, restricted_room_id],
+ "inaccessible_rooms": [],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, restricted_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.LEAVE)
+
+ def test_remote_space(self) -> None:
+ """Tests that the user is made to leave rooms in a remote space."""
+ remote_space_id = "!space:remote"
+ self._add_child(self.subspace_id, remote_space_id, via=["remote"])
+
+ restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id))
+ self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok)
+
+ async def _get_space_children_remote(
+ _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str]
+ ) -> Tuple[
+ Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]
+ ]:
+ self.assertEqual(space_id, remote_space_id)
+ self.assertEqual(via, ["remote"])
+
+ return [(restricted_room_id, [self.hs.hostname])], {}
+
+ with mock.patch(
+ "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote",
+ new=_get_space_children_remote,
+ ):
+ response = self._remove_from_space(
+ self.target_user, space_id="!space:remote", include_remote_spaces=True
+ )
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id, restricted_room_id],
+ "inaccessible_rooms": [remote_space_id],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, restricted_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.LEAVE)
+
+ def test_remote_spaces_excluded(self) -> None:
+ """Tests the exclusion of remote spaces."""
+ remote_space_id = "!space:remote"
+ self._add_child(self.subspace_id, remote_space_id, via=["remote"])
+
+ restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id))
+ self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok)
+
+ async def _get_space_children_remote(
+ _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str]
+ ) -> NoReturn:
+ self.fail(
+ f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call"
+ )
+ raise # `fail` is missing type hints
+
+ with mock.patch(
+ "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote",
+ new=_get_space_children_remote,
+ ):
+ response = self._remove_from_space(
+ self.target_user, space_id="!space:remote", include_remote_spaces=False
+ )
+ self.assertEqual(
+ response,
+ {
+ "left_rooms": [self.space_id],
+ "inaccessible_rooms": [remote_space_id],
+ "failed_rooms": {},
+ },
+ )
+
+ membership, _ = self.get_success(
+ self.store.get_local_current_membership_for_user_in_room(
+ self.target_user, restricted_room_id
+ )
+ )
+ self.assertEqual(membership, Membership.JOIN)
|