From 8627a456e314d4777d8dcbeb7da1fb3624ebfdf2 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 15:40:29 +0000 Subject: Refer to "spaces" instead of "rooms" --- synapse/handlers/room_hierarchy.py | 274 ------------------------------- synapse/handlers/space_hierarchy.py | 277 ++++++++++++++++++++++++++++++++ synapse/rest/admin/__init__.py | 4 +- synapse/rest/admin/room_hierarchy.py | 146 ----------------- synapse/rest/admin/space.py | 146 +++++++++++++++++ synapse/server.py | 6 +- tests/handlers/test_room_hierarchy.py | 174 -------------------- tests/handlers/test_space_hierarchy.py | 174 ++++++++++++++++++++ tests/rest/admin/test_room_hierarchy.py | 260 ------------------------------ tests/rest/admin/test_space.py | 260 ++++++++++++++++++++++++++++++ 10 files changed, 862 insertions(+), 859 deletions(-) delete mode 100644 synapse/handlers/room_hierarchy.py create mode 100644 synapse/handlers/space_hierarchy.py delete mode 100644 synapse/rest/admin/room_hierarchy.py create mode 100644 synapse/rest/admin/space.py delete mode 100644 tests/handlers/test_room_hierarchy.py create mode 100644 tests/handlers/test_space_hierarchy.py delete mode 100644 tests/rest/admin/test_room_hierarchy.py create mode 100644 tests/rest/admin/test_space.py diff --git a/synapse/handlers/room_hierarchy.py b/synapse/handlers/room_hierarchy.py deleted file mode 100644 index 54cdc130bd..0000000000 --- a/synapse/handlers/room_hierarchy.py +++ /dev/null @@ -1,274 +0,0 @@ -# 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. - -import logging -from typing import ( - TYPE_CHECKING, - Dict, - Iterable, - List, - Mapping, - Optional, - Sequence, - Tuple, -) - -from synapse.api.constants import EventContentFields, EventTypes, RoomTypes -from synapse.api.errors import SynapseError -from synapse.handlers.room_summary import child_events_comparison_key, has_valid_via -from synapse.storage.state import StateFilter -from synapse.types import JsonDict - -if TYPE_CHECKING: - from synapse.server import HomeServer - - -logger = logging.getLogger(__name__) - - -class RoomHierarchyHandler: - """Provides methods for walking over space hierarchies. - - Also see `RoomSummaryHandler`, which has similar functionality. - """ - - def __init__(self, hs: "HomeServer"): - self._store = hs.get_datastore() - self._federation_client = hs.get_federation_client() - - self._server_name = hs.hostname - - async def get_room_descendants( - self, space_id: str, via: Optional[Iterable[str]] = None - ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]: - """Gets the children of a space, recursively. - - Args: - space_id: The room ID of the space. - - Returns: - A tuple containing: - * A list of (room ID, via) tuples, representing the descendants of the - space. `space_id` is included in the list. - * A list of room IDs whose children could not be fully listed. - Rooms in this list are either spaces not known locally, and thus require - listing over federation, or are unknown rooms or subspaces completely - inaccessible to the local homeserver which may contain further rooms. - - This list is a subset of the previous list, except it may include - `space_id`. - """ - via = via or [] - - # (room ID, via, federation room chunks) - todo: List[Tuple[str, Iterable[str], Mapping[str, Optional[JsonDict]]]] = [ - (space_id, via, {}) - ] - descendants: List[Tuple[str, Iterable[str]]] = [] - - seen = {space_id} - - inaccessible_room_ids: List[str] = [] - - while todo: - space_id, via, federation_room_chunks = todo.pop() - descendants.append((space_id, via)) - try: - ( - is_in_room, - children, - federation_room_chunks, - ) = await self._get_room_children(space_id, via, federation_room_chunks) - except SynapseError: - # Could not list children over federation - inaccessible_room_ids.append(space_id) - continue - - for child_room_id, child_via in reversed(children): - if child_room_id in seen: - continue - - seen.add(child_room_id) - - # Queue up the child for processing. - # The child may not actually be a space, but that's checked by - # `_get_room_children`. - todo.append((child_room_id, child_via, federation_room_chunks)) - - # Children were retrieved over federation, which is not guaranteed to be - # the full list. - if not is_in_room: - inaccessible_room_ids.append(space_id) - - return descendants, inaccessible_room_ids - - async def _get_room_children( - self, - space_id: str, - via: Optional[Iterable[str]] = None, - federation_room_chunks: Optional[Mapping[str, Optional[JsonDict]]] = None, - ) -> Tuple[ - bool, Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] - ]: - """Gets the direct children of a space. - - Args: - space_id: The room ID of the space. - via: A list of servers which may know about the space. - federation_room_chunks: A cache of room chunks previously returned by - `_get_room_children` that may be used to skip federation requests for - inaccessible or non-space rooms. - - Returns: - A tuple containing: - * A boolean indicating whether `space_id` is known to the local homeserver. - * A list of (room ID, via) tuples, representing the children of the space, - if `space_id` refers to a space; an empty list otherwise. - * A dictionary of child room ID: `PublicRoomsChunk`s returned over - federation: - https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms - These are supposed to include extra `room_type` and `allowed_room_ids` - fields, as described in MSC2946. - - Contains `None` for rooms to which the remote homeserver thinks we do not - have access. - - Local information about rooms should be trusted over data in this - dictionary. - - Raises: - SynapseError: if `space_id` is not known locally and its children could not - be retrieved over federation. - """ - via = via or [] - federation_room_chunks = federation_room_chunks or {} - - is_in_room = await self._store.is_host_joined(space_id, self._server_name) - if is_in_room: - children = await self._get_room_children_local(space_id) - return True, children, {} - else: - # Check the room chunks previously returned over federation to see if we - # should really make a request. - # `federation_room_chunks` is intentionally not used earlier since we want - # to trust local data over data from federation. - if space_id in federation_room_chunks: - room_chunk = federation_room_chunks[space_id] - if room_chunk is None: - # `space_id` is inaccessible to the local homeserver according to - # federation. - raise SynapseError( - 502, f"{space_id} is not accessible to the local homeserver" - ) - elif room_chunk.get("room_type") != RoomTypes.SPACE: - # `space_id` is not a space according to federation. - return False, [], {} - - children, room_chunks = await self._get_room_children_remote(space_id, via) - return False, children, room_chunks - - async def _get_room_children_local( - self, space_id: str - ) -> Sequence[Tuple[str, Iterable[str]]]: - """Gets the direct children of a space that the local homeserver is in. - - Args: - space_id: The room ID of the space. - - Returns: - A list of (room ID, via) tuples, representing the children of the space, - if `space_id` refers to a space; an empty list otherwise. - - Raises: - ValueError: if `space_id` is not known locally. - """ - # Fetch the `m.room.create` and `m.space.child` events for `space_id` - state_filter = StateFilter.from_types( - [(EventTypes.Create, ""), (EventTypes.SpaceChild, None)] - ) - current_state_ids = await self._store.get_filtered_current_state_ids( - space_id, state_filter - ) - state_events = await self._store.get_events_as_list(current_state_ids.values()) - assert len(state_events) == len(current_state_ids) - - create_event_id = current_state_ids.get((EventTypes.Create, "")) - if create_event_id is None: - # The local homeserver is not in this room - raise ValueError(f"{space_id} is not a room known locally.") - - create_event = next( - event for event in state_events if event.event_id == create_event_id - ) - if create_event.content.get(EventContentFields.ROOM_TYPE) != RoomTypes.SPACE: - # `space_id` is a regular room and not a space. - # Ignore any `m.space.child` events. - return [] - - child_events = [ - event - for event in state_events - # Ignore events with a missing or non-array `via`, as per MSC1772 - if event.event_id != create_event_id and has_valid_via(event) - ] - child_events.sort(key=child_events_comparison_key) - return [(event.state_key, event.content["via"]) for event in child_events] - - async def _get_room_children_remote( - self, space_id: str, via: Iterable[str] - ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]]: - """Gets the direct children of a space over federation. - - Args: - space_id: The room ID of the space. - via: A list of servers which may know about the space. - - Returns: - A tuple containing: - * A list of (room ID, via) tuples, representing the children of the space, - if `space_id` refers to a space; an empty list otherwise. - * A dictionary of child room ID: `PublicRoomsChunk`s returned over - federation: - https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms - These are supposed to include extra `room_type` and `allowed_room_ids` - fields, as described in MSC2946. - - Contains `None` for rooms to which the remote homeserver thinks we do not - have access. - - Raises: - SynapseError: if none of the remote servers provided us with the space's - children. - """ - ( - room, - children_chunks, - inaccessible_children, - ) = await self._federation_client.get_room_hierarchy( - via, space_id, suggested_only=False - ) - - child_events: List[JsonDict] = room["children_state"] - children = [ - (child_event["room_id"], child_event["content"]["via"]) - for child_event in child_events - ] - - room_chunks: Dict[str, Optional[JsonDict]] = {} - room_chunks.update((room_id, None) for room_id in inaccessible_children) - room_chunks.update( - (room_chunk["room_id"], room_chunk) for room_chunk in children_chunks - ) - - return children, room_chunks diff --git a/synapse/handlers/space_hierarchy.py b/synapse/handlers/space_hierarchy.py new file mode 100644 index 0000000000..619059b55e --- /dev/null +++ b/synapse/handlers/space_hierarchy.py @@ -0,0 +1,277 @@ +# 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. + +import logging +from typing import ( + TYPE_CHECKING, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, +) + +from synapse.api.constants import EventContentFields, EventTypes, RoomTypes +from synapse.api.errors import SynapseError +from synapse.handlers.room_summary import child_events_comparison_key, has_valid_via +from synapse.storage.state import StateFilter +from synapse.types import JsonDict + +if TYPE_CHECKING: + from synapse.server import HomeServer + + +logger = logging.getLogger(__name__) + + +class SpaceHierarchyHandler: + """Provides methods for walking over space hierarchies. + + Also see `RoomSummaryHandler`, which has similar functionality. + """ + + def __init__(self, hs: "HomeServer"): + self._store = hs.get_datastore() + self._federation_client = hs.get_federation_client() + + self._server_name = hs.hostname + + async def get_space_descendants( + self, space_id: str, via: Optional[Iterable[str]] = None + ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]: + """Gets the children of a space, recursively. + + Args: + space_id: The room ID of the space. + + Returns: + A tuple containing: + * A list of (room ID, via) tuples, representing the descendants of the + space. `space_id` is included in the list. + * A list of room IDs whose children could not be fully listed. + Rooms in this list are either spaces not known locally, and thus require + listing over federation, or are unknown rooms or subspaces completely + inaccessible to the local homeserver which may contain further rooms. + + This list is a subset of the previous list, except it may include + `space_id`. + """ + via = via or [] + + # (room ID, via, federation room chunks) + todo: List[Tuple[str, Iterable[str], Mapping[str, Optional[JsonDict]]]] = [ + (space_id, via, {}) + ] + # [(room ID, via)] + descendants: List[Tuple[str, Iterable[str]]] = [] + + seen = {space_id} + + inaccessible_room_ids: List[str] = [] + + while todo: + space_id, via, federation_room_chunks = todo.pop() + descendants.append((space_id, via)) + try: + ( + is_in_room, + children, + federation_room_chunks, + ) = await self._get_space_children( + space_id, via, federation_room_chunks + ) + except SynapseError: + # Could not list children over federation + inaccessible_room_ids.append(space_id) + continue + + for child_room_id, child_via in reversed(children): + if child_room_id in seen: + continue + + seen.add(child_room_id) + + # Queue up the child for processing. + # The child may not actually be a space, but that's checked by + # `_get_space_children`. + todo.append((child_room_id, child_via, federation_room_chunks)) + + # Children were retrieved over federation, which is not guaranteed to be + # the full list. + if not is_in_room: + inaccessible_room_ids.append(space_id) + + return descendants, inaccessible_room_ids + + async def _get_space_children( + self, + space_id: str, + via: Optional[Iterable[str]] = None, + federation_room_chunks: Optional[Mapping[str, Optional[JsonDict]]] = None, + ) -> Tuple[ + bool, Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] + ]: + """Gets the direct children of a space. + + Args: + space_id: The room ID of the space. + via: A list of servers which may know about the space. + federation_room_chunks: A cache of room chunks previously returned by + `_get_space_children` that may be used to skip federation requests for + inaccessible or non-space rooms. + + Returns: + A tuple containing: + * A boolean indicating whether `space_id` is known to the local homeserver. + * A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + * A dictionary of child room ID: `PublicRoomsChunk`s returned over + federation: + https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms + These are supposed to include extra `room_type` and `allowed_room_ids` + fields, as described in MSC2946. + + Contains `None` for rooms to which the remote homeserver thinks we do not + have access. + + Local information about rooms should be trusted over data in this + dictionary. + + Raises: + SynapseError: if `space_id` is not known locally and its children could not + be retrieved over federation. + """ + via = via or [] + federation_room_chunks = federation_room_chunks or {} + + is_in_room = await self._store.is_host_joined(space_id, self._server_name) + if is_in_room: + children = await self._get_space_children_local(space_id) + return True, children, {} + else: + # Check the room chunks previously returned over federation to see if we + # should really make a request. + # `federation_room_chunks` is intentionally not used earlier since we want + # to trust local data over data from federation. + if space_id in federation_room_chunks: + room_chunk = federation_room_chunks[space_id] + if room_chunk is None: + # `space_id` is inaccessible to the local homeserver according to + # federation. + raise SynapseError( + 502, f"{space_id} is not accessible to the local homeserver" + ) + elif room_chunk.get("room_type") != RoomTypes.SPACE: + # `space_id` is not a space according to federation. + return False, [], {} + + children, room_chunks = await self._get_space_children_remote(space_id, via) + return False, children, room_chunks + + async def _get_space_children_local( + self, space_id: str + ) -> Sequence[Tuple[str, Iterable[str]]]: + """Gets the direct children of a space that the local homeserver is in. + + Args: + space_id: The room ID of the space. + + Returns: + A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + + Raises: + ValueError: if `space_id` is not known locally. + """ + # Fetch the `m.room.create` and `m.space.child` events for `space_id` + state_filter = StateFilter.from_types( + [(EventTypes.Create, ""), (EventTypes.SpaceChild, None)] + ) + current_state_ids = await self._store.get_filtered_current_state_ids( + space_id, state_filter + ) + state_events = await self._store.get_events_as_list(current_state_ids.values()) + assert len(state_events) == len(current_state_ids) + + create_event_id = current_state_ids.get((EventTypes.Create, "")) + if create_event_id is None: + # The local homeserver is not in this room + raise ValueError(f"{space_id} is not a room known locally.") + + create_event = next( + event for event in state_events if event.event_id == create_event_id + ) + if create_event.content.get(EventContentFields.ROOM_TYPE) != RoomTypes.SPACE: + # `space_id` is a regular room and not a space. + # Ignore any `m.space.child` events. + return [] + + child_events = [ + event + for event in state_events + # Ignore events with a missing or non-array `via`, as per MSC1772 + if event.event_id != create_event_id and has_valid_via(event) + ] + child_events.sort(key=child_events_comparison_key) + return [(event.state_key, event.content["via"]) for event in child_events] + + async def _get_space_children_remote( + self, space_id: str, via: Iterable[str] + ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]]: + """Gets the direct children of a space over federation. + + Args: + space_id: The room ID of the space. + via: A list of servers which may know about the space. + + Returns: + A tuple containing: + * A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + * A dictionary of child room ID: `PublicRoomsChunk`s returned over + federation: + https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms + These are supposed to include extra `room_type` and `allowed_room_ids` + fields, as described in MSC2946. + + Contains `None` for rooms to which the remote homeserver thinks we do not + have access. + + Raises: + SynapseError: if none of the remote servers provided us with the space's + children. + """ + ( + room, + children_chunks, + inaccessible_children, + ) = await self._federation_client.get_room_hierarchy( + via, space_id, suggested_only=False + ) + + child_events: List[JsonDict] = room["children_state"] + children = [ + (child_event["room_id"], child_event["content"]["via"]) + for child_event in child_events + ] + + room_chunks: Dict[str, Optional[JsonDict]] = {} + room_chunks.update((room_id, None) for room_id in inaccessible_children) + room_chunks.update( + (room_chunk["room_id"], room_chunk) for room_chunk in children_chunks + ) + + return children, room_chunks diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index b7bc36c17a..36cfd1e4e2 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -45,7 +45,6 @@ from synapse.rest.admin.registration_tokens import ( NewRegistrationTokenRestServlet, RegistrationTokenRestServlet, ) -from synapse.rest.admin.room_hierarchy import RemoveHierarchyMemberRestServlet from synapse.rest.admin.rooms import ( DeleteRoomStatusByDeleteIdRestServlet, DeleteRoomStatusByRoomIdRestServlet, @@ -60,6 +59,7 @@ from synapse.rest.admin.rooms import ( RoomStateRestServlet, ) from synapse.rest.admin.server_notice_servlet import SendServerNoticeServlet +from synapse.rest.admin.space import RemoveSpaceMemberRestServlet from synapse.rest.admin.statistics import UserMediaStatisticsRestServlet from synapse.rest.admin.username_available import UsernameAvailableRestServlet from synapse.rest.admin.users import ( @@ -254,7 +254,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ListRegistrationTokensRestServlet(hs).register(http_server) NewRegistrationTokenRestServlet(hs).register(http_server) RegistrationTokenRestServlet(hs).register(http_server) - RemoveHierarchyMemberRestServlet(hs).register(http_server) + RemoveSpaceMemberRestServlet(hs).register(http_server) # Some servlets only get registered for the main process. if hs.config.worker.worker_app is None: diff --git a/synapse/rest/admin/room_hierarchy.py b/synapse/rest/admin/room_hierarchy.py deleted file mode 100644 index 16bd902b5a..0000000000 --- a/synapse/rest/admin/room_hierarchy.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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. -import logging -from typing import TYPE_CHECKING, Dict, List, Tuple - -from synapse.api.constants import EventTypes, JoinRules, Membership -from synapse.api.errors import SynapseError -from synapse.http.servlet import ResolveRoomIdMixin, RestServlet -from synapse.http.site import SynapseRequest -from synapse.rest.admin._base import admin_patterns, assert_user_is_admin -from synapse.storage.state import StateFilter -from synapse.types import JsonDict, UserID, create_requester - -if TYPE_CHECKING: - from synapse.server import HomeServer - -logger = logging.getLogger(__name__) - - -class RemoveHierarchyMemberRestServlet(ResolveRoomIdMixin, RestServlet): - """ - Puppets a local user to remove them from all rooms in a space. - """ - - PATTERNS = admin_patterns( - "/rooms/(?P[^/]+)/hierarchy/members/(?P[^/]+)$" - ) - - def __init__(self, hs: "HomeServer"): - super().__init__(hs) - self._hs = hs - self._auth = hs.get_auth() - self._store = hs.get_datastore() - self._room_member_handler = hs.get_room_member_handler() - self._room_hierarchy_handler = hs.get_room_hierarchy_handler() - - async def on_DELETE( - self, request: SynapseRequest, space_id: str, user_id: str - ) -> Tuple[int, JsonDict]: - """Forces a local user to leave all non-public rooms in a space. - - The space itself is always left, regardless of whether it is public. - - May succeed partially if the user fails to leave some rooms. - - Returns: - A tuple containing the HTTP status code and a JSON dictionary containing: - * `left`: A list of rooms that the user has been made to leave. - * `failed`: A with entries for rooms that could not be fully processed. - The values of the dictionary are lists of failure reasons. - Rooms may appear here if: - * The user failed to leave them for any reason. - * The room is a space that the local homeserver is not in, and so its - full list of child rooms could not be determined. - * The room is inaccessible to the local homeserver, and it is not known - whether the room is a subspace containing further rooms. - * Some combination of the above. - """ - requester = await self._auth.get_user_by_req(request) - await assert_user_is_admin(self._auth, requester.user) - - space_id, _ = await self.resolve_room_id(space_id) - - target_user = UserID.from_string(user_id) - - if not self._hs.is_mine(target_user): - raise SynapseError(400, "This endpoint can only be used with local users") - - # Fetch the list of rooms the target user is currently in - user_rooms = await self._store.get_rooms_for_local_user_where_membership_is( - user_id, [Membership.INVITE, Membership.JOIN, Membership.KNOCK] - ) - user_room_ids = {room.room_id for room in user_rooms} - - # Fetch the list of rooms in the space hierarchy - ( - descendants, - inaccessible_room_ids, - ) = await self._room_hierarchy_handler.get_room_descendants(space_id) - space_room_ids = {space_id} - space_room_ids.update(room_id for room_id, _ in descendants) - - # Determine which rooms to leave by checking join rules. - rooms_to_check = space_room_ids.intersection(user_room_ids) - rooms_to_leave = {space_id} # Always leave the space, even if it is public - state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) - for room_id in rooms_to_check: - current_state_ids = await self._store.get_filtered_current_state_ids( - room_id, state_filter - ) - join_rules_event_id = current_state_ids.get((EventTypes.JoinRules, "")) - if join_rules_event_id is not None: - join_rules_event = await self._store.get_event(join_rules_event_id) - join_rules = join_rules_event.content.get("join_rule") - else: - # The user is invited to or has knocked on a room that is not known - # locally. Assume that such rooms are not public and should be left. - # If it turns out that the room is actually public, then we've not - # actually prevented the user from joining it. - join_rules = None - if join_rules != JoinRules.PUBLIC: - rooms_to_leave.add(room_id) - - # Now start leaving rooms - failures: Dict[str, List[str]] = { - room_id: ["Could not fully explore space or room."] - for room_id in inaccessible_room_ids - } - left_rooms: List[str] = [] - - fake_requester = create_requester( - target_user, authenticated_entity=requester.user.to_string() - ) - - for room_id in rooms_to_leave: - # There is a race condition here where the user may have left or been kicked - # from a room since their list of memberships was fetched. - # `update_membership` will raise if the user is no longer in the room, - # but it's tricky to distinguish from other failure modes. - - try: - await self._room_member_handler.update_membership( - requester=fake_requester, - target=target_user, - room_id=room_id, - action=Membership.LEAVE, - content={}, - ratelimit=False, - require_consent=False, - ) - left_rooms.append(room_id) - except Exception as e: - failures.get(room_id, []).append(str(e)) - - return 200, {"left": left_rooms, "failed": failures} diff --git a/synapse/rest/admin/space.py b/synapse/rest/admin/space.py new file mode 100644 index 0000000000..952240d01f --- /dev/null +++ b/synapse/rest/admin/space.py @@ -0,0 +1,146 @@ +# 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. +import logging +from typing import TYPE_CHECKING, Dict, List, Tuple + +from synapse.api.constants import EventTypes, JoinRules, Membership +from synapse.api.errors import SynapseError +from synapse.http.servlet import ResolveRoomIdMixin, RestServlet +from synapse.http.site import SynapseRequest +from synapse.rest.admin._base import admin_patterns, assert_user_is_admin +from synapse.storage.state import StateFilter +from synapse.types import JsonDict, UserID, create_requester + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class RemoveSpaceMemberRestServlet(ResolveRoomIdMixin, RestServlet): + """ + Puppets a local user to remove them from all rooms in a space. + """ + + PATTERNS = admin_patterns( + "/rooms/(?P[^/]+)/hierarchy/members/(?P[^/]+)$" + ) + + def __init__(self, hs: "HomeServer"): + super().__init__(hs) + self._hs = hs + self._auth = hs.get_auth() + self._store = hs.get_datastore() + self._room_member_handler = hs.get_room_member_handler() + self._space_hierarchy_handler = hs.get_space_hierarchy_handler() + + async def on_DELETE( + self, request: SynapseRequest, space_id: str, user_id: str + ) -> Tuple[int, JsonDict]: + """Forces a local user to leave all non-public rooms in a space. + + The space itself is always left, regardless of whether it is public. + + May succeed partially if the user fails to leave some rooms. + + Returns: + A tuple containing the HTTP status code and a JSON dictionary containing: + * `left`: A list of rooms that the user has been made to leave. + * `failed`: A with entries for rooms that could not be fully processed. + The values of the dictionary are lists of failure reasons. + Rooms may appear here if: + * The user failed to leave them for any reason. + * The room is a space that the local homeserver is not in, and so its + full list of child rooms could not be determined. + * The room is inaccessible to the local homeserver, and it is not known + whether the room is a subspace containing further rooms. + * Some combination of the above. + """ + requester = await self._auth.get_user_by_req(request) + await assert_user_is_admin(self._auth, requester.user) + + space_id, _ = await self.resolve_room_id(space_id) + + target_user = UserID.from_string(user_id) + + if not self._hs.is_mine(target_user): + raise SynapseError(400, "This endpoint can only be used with local users") + + # Fetch the list of rooms the target user is currently in + user_rooms = await self._store.get_rooms_for_local_user_where_membership_is( + user_id, [Membership.INVITE, Membership.JOIN, Membership.KNOCK] + ) + user_room_ids = {room.room_id for room in user_rooms} + + # Fetch the list of rooms in the space hierarchy + ( + descendants, + inaccessible_room_ids, + ) = await self._space_hierarchy_handler.get_space_descendants(space_id) + space_room_ids = {space_id} + space_room_ids.update(room_id for room_id, _ in descendants) + + # Determine which rooms to leave by checking join rules. + rooms_to_check = space_room_ids.intersection(user_room_ids) + rooms_to_leave = {space_id} # Always leave the space, even if it is public + state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) + for room_id in rooms_to_check: + current_state_ids = await self._store.get_filtered_current_state_ids( + room_id, state_filter + ) + join_rules_event_id = current_state_ids.get((EventTypes.JoinRules, "")) + if join_rules_event_id is not None: + join_rules_event = await self._store.get_event(join_rules_event_id) + join_rules = join_rules_event.content.get("join_rule") + else: + # The user is invited to or has knocked on a room that is not known + # locally. Assume that such rooms are not public and should be left. + # If it turns out that the room is actually public, then we've not + # actually prevented the user from joining it. + join_rules = None + if join_rules != JoinRules.PUBLIC: + rooms_to_leave.add(room_id) + + # Now start leaving rooms + failures: Dict[str, List[str]] = { + room_id: ["Could not fully explore space or room."] + for room_id in inaccessible_room_ids + } + left_rooms: List[str] = [] + + fake_requester = create_requester( + target_user, authenticated_entity=requester.user.to_string() + ) + + for room_id in rooms_to_leave: + # There is a race condition here where the user may have left or been kicked + # from a room since their list of memberships was fetched. + # `update_membership` will raise if the user is no longer in the room, + # but it's tricky to distinguish from other failure modes. + + try: + await self._room_member_handler.update_membership( + requester=fake_requester, + target=target_user, + room_id=room_id, + action=Membership.LEAVE, + content={}, + ratelimit=False, + require_consent=False, + ) + left_rooms.append(room_id) + except Exception as e: + failures.get(room_id, []).append(str(e)) + + return 200, {"left": left_rooms, "failed": failures} diff --git a/synapse/server.py b/synapse/server.py index cd8f260c70..9b7dba013c 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -99,7 +99,6 @@ from synapse.handlers.room import ( RoomShutdownHandler, ) from synapse.handlers.room_batch import RoomBatchHandler -from synapse.handlers.room_hierarchy import RoomHierarchyHandler from synapse.handlers.room_list import RoomListHandler from synapse.handlers.room_member import RoomMemberHandler, RoomMemberMasterHandler from synapse.handlers.room_member_worker import RoomMemberWorkerHandler @@ -107,6 +106,7 @@ from synapse.handlers.room_summary import RoomSummaryHandler from synapse.handlers.search import SearchHandler from synapse.handlers.send_email import SendEmailHandler from synapse.handlers.set_password import SetPasswordHandler +from synapse.handlers.space_hierarchy import SpaceHierarchyHandler from synapse.handlers.sso import SsoHandler from synapse.handlers.stats import StatsHandler from synapse.handlers.sync import SyncHandler @@ -792,8 +792,8 @@ class HomeServer(metaclass=abc.ABCMeta): return AccountDataHandler(self) @cache_in_self - def get_room_hierarchy_handler(self) -> RoomHierarchyHandler: - return RoomHierarchyHandler(self) + def get_space_hierarchy_handler(self) -> SpaceHierarchyHandler: + return SpaceHierarchyHandler(self) @cache_in_self def get_room_summary_handler(self) -> RoomSummaryHandler: diff --git a/tests/handlers/test_room_hierarchy.py b/tests/handlers/test_room_hierarchy.py deleted file mode 100644 index 31adcd60ff..0000000000 --- a/tests/handlers/test_room_hierarchy.py +++ /dev/null @@ -1,174 +0,0 @@ -# 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, Optional - -from synapse.api.constants import EventContentFields, EventTypes, RoomTypes -from synapse.rest import admin -from synapse.rest.client import login, room -from synapse.server import HomeServer -from synapse.types import JsonDict - -from tests import unittest - - -class RoomDescendantsTestCase(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, clock, hs: HomeServer): - self.hs = hs - self.handler = self.hs.get_room_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_room_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_room_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_room_descendants(space_id) - ) - - self.assertEqual(descendants, [(space_id, []), (room_id, [self.hs.hostname])]) - self.assertEqual(inaccessible_room_ids, [room_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_room_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_room_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/handlers/test_space_hierarchy.py b/tests/handlers/test_space_hierarchy.py new file mode 100644 index 0000000000..8e46b2c93e --- /dev/null +++ b/tests/handlers/test_space_hierarchy.py @@ -0,0 +1,174 @@ +# 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, Optional + +from synapse.api.constants import EventContentFields, EventTypes, RoomTypes +from synapse.rest import admin +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.types import JsonDict + +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, 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_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_room_hierarchy.py b/tests/rest/admin/test_room_hierarchy.py deleted file mode 100644 index 3d2ac01370..0000000000 --- a/tests/rest/admin/test_room_hierarchy.py +++ /dev/null @@ -1,260 +0,0 @@ -# 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, Optional, Tuple, Union - -from typing_extensions import Literal - -import synapse.rest.admin -from synapse.api.constants import ( - EventContentFields, - EventTypes, - JoinRules, - Membership, - RestrictedJoinRuleTypes, - RoomTypes, -) -from synapse.api.room_versions import RoomVersions -from synapse.rest.client import login, room -from synapse.types import JsonDict - -from tests import unittest - - -class RemoveHierarchyMemberTestCase(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, clock, hs): - 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) -> None: - """Adds a room to a space.""" - self.helper.send_state( - space_id, - event_type=EventTypes.SpaceChild, - body={"via": [self.hs.hostname]}, - 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) -> JsonDict: - """Removes the given user from the test space.""" - 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, - ) - - 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.assertCountEqual(response["left"], [self.space_id]) - self.assertEqual(response["failed"], {}) - - 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.assertCountEqual(response["left"], [self.space_id]) - self.assertEqual(response["failed"], {}) - - 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.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) - self.assertEqual(response["failed"], {}) - - 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.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) - self.assertEqual(response["failed"], {}) - - 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.assertCountEqual(response["left"], [self.space_id, restricted_room_id]) - self.assertEqual(response["failed"], {}) - - 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) diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py new file mode 100644 index 0000000000..59c6c3b284 --- /dev/null +++ b/tests/rest/admin/test_space.py @@ -0,0 +1,260 @@ +# 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, Optional, Tuple, Union + +from typing_extensions import Literal + +import synapse.rest.admin +from synapse.api.constants import ( + EventContentFields, + EventTypes, + JoinRules, + Membership, + RestrictedJoinRuleTypes, + RoomTypes, +) +from synapse.api.room_versions import RoomVersions +from synapse.rest.client import login, room +from synapse.types import JsonDict + +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, clock, hs): + 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) -> None: + """Adds a room to a space.""" + self.helper.send_state( + space_id, + event_type=EventTypes.SpaceChild, + body={"via": [self.hs.hostname]}, + 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) -> JsonDict: + """Removes the given user from the test space.""" + 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, + ) + + 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.assertCountEqual(response["left"], [self.space_id]) + self.assertEqual(response["failed"], {}) + + 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.assertCountEqual(response["left"], [self.space_id]) + self.assertEqual(response["failed"], {}) + + 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.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) + self.assertEqual(response["failed"], {}) + + 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.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) + self.assertEqual(response["failed"], {}) + + 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.assertCountEqual(response["left"], [self.space_id, restricted_room_id]) + self.assertEqual(response["failed"], {}) + + 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) -- cgit 1.5.1