diff --git a/changelog.d/11223.feature b/changelog.d/11223.feature
new file mode 100644
index 0000000000..55ea693dcd
--- /dev/null
+++ b/changelog.d/11223.feature
@@ -0,0 +1 @@
+Add a new version of delete room admin API `DELETE /_synapse/admin/v2/rooms/<room_id>` to run it in background. Contributed by @dklimpel.
\ No newline at end of file
diff --git a/docs/admin_api/purge_history_api.md b/docs/admin_api/purge_history_api.md
index bd29e29ab8..277e28d9cb 100644
--- a/docs/admin_api/purge_history_api.md
+++ b/docs/admin_api/purge_history_api.md
@@ -70,6 +70,8 @@ This API returns a JSON body like the following:
The status will be one of `active`, `complete`, or `failed`.
+If `status` is `failed` there will be a string `error` with the error message.
+
## Reclaim disk space (Postgres)
To reclaim the disk space and return it to the operating system, you need to run
diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md
index 41a4961d00..6a6ae92d66 100644
--- a/docs/admin_api/rooms.md
+++ b/docs/admin_api/rooms.md
@@ -4,6 +4,9 @@
- [Room Members API](#room-members-api)
- [Room State API](#room-state-api)
- [Delete Room API](#delete-room-api)
+ * [Version 1 (old version)](#version-1-old-version)
+ * [Version 2 (new version)](#version-2-new-version)
+ * [Status of deleting rooms](#status-of-deleting-rooms)
* [Undoing room shutdowns](#undoing-room-shutdowns)
- [Make Room Admin API](#make-room-admin-api)
- [Forward Extremities Admin API](#forward-extremities-admin-api)
@@ -397,10 +400,10 @@ as room administrator and will contain a message explaining what happened. Users
to the new room will have power level `-10` by default, and thus be unable to speak.
If `block` is `true`, users will be prevented from joining the old room.
-This option can also be used to pre-emptively block a room, even if it's unknown
-to this homeserver. In this case, the room will be blocked, and no further action
-will be taken. If `block` is `false`, attempting to delete an unknown room is
-invalid and will be rejected as a bad request.
+This option can in [Version 1](#version-1-old-version) also be used to pre-emptively
+block a room, even if it's unknown to this homeserver. In this case, the room will be
+blocked, and no further action will be taken. If `block` is `false`, attempting to
+delete an unknown room is invalid and will be rejected as a bad request.
This API will remove all trace of the old room from your database after removing
all local users. If `purge` is `true` (the default), all traces of the old room will
@@ -412,6 +415,17 @@ several minutes or longer.
The local server will only have the power to move local user and room aliases to
the new room. Users on other servers will be unaffected.
+To use it, you will need to authenticate by providing an ``access_token`` for a
+server admin: see [Admin API](../usage/administration/admin_api).
+
+## Version 1 (old version)
+
+This version works synchronously. That means you only get the response once the server has
+finished the action, which may take a long time. If you request the same action
+a second time, and the server has not finished the first one, the second request will block.
+This is fixed in version 2 of this API. The parameters are the same in both APIs.
+This API will become deprecated in the future.
+
The API is:
```
@@ -430,9 +444,6 @@ with a body of:
}
```
-To use it, you will need to authenticate by providing an ``access_token`` for a
-server admin: see [Admin API](../usage/administration/admin_api).
-
A response body like the following is returned:
```json
@@ -449,6 +460,44 @@ A response body like the following is returned:
}
```
+The parameters and response values have the same format as
+[version 2](#version-2-new-version) of the API.
+
+## Version 2 (new version)
+
+**Note**: This API is new, experimental and "subject to change".
+
+This version works asynchronously, meaning you get the response from server immediately
+while the server works on that task in background. You can then request the status of the action
+to check if it has completed.
+
+The API is:
+
+```
+DELETE /_synapse/admin/v2/rooms/<room_id>
+```
+
+with a body of:
+
+```json
+{
+ "new_room_user_id": "@someuser:example.com",
+ "room_name": "Content Violation Notification",
+ "message": "Bad Room has been shutdown due to content violations on this server. Please review our Terms of Service.",
+ "block": true,
+ "purge": true
+}
+```
+
+The API starts the shut down and purge running, and returns immediately with a JSON body with
+a purge id:
+
+```json
+{
+ "delete_id": "<opaque id>"
+}
+```
+
**Parameters**
The following parameters should be set in the URL:
@@ -470,7 +519,8 @@ The following JSON body parameters are available:
is not permitted and rooms in violation will be blocked.`
* `block` - Optional. If set to `true`, this room will be added to a blocking list,
preventing future attempts to join the room. Rooms can be blocked
- even if they're not yet known to the homeserver. Defaults to `false`.
+ even if they're not yet known to the homeserver (only with
+ [Version 1](#version-1-old-version) of the API). Defaults to `false`.
* `purge` - Optional. If set to `true`, it will remove all traces of the room from your database.
Defaults to `true`.
* `force_purge` - Optional, and ignored unless `purge` is `true`. If set to `true`, it
@@ -480,17 +530,124 @@ The following JSON body parameters are available:
The JSON body must not be empty. The body must be at least `{}`.
-**Response**
+## Status of deleting rooms
-The following fields are returned in the JSON response body:
+**Note**: This API is new, experimental and "subject to change".
+
+It is possible to query the status of the background task for deleting rooms.
+The status can be queried up to 24 hours after completion of the task,
+or until Synapse is restarted (whichever happens first).
+
+### Query by `room_id`
-* `kicked_users` - An array of users (`user_id`) that were kicked.
-* `failed_to_kick_users` - An array of users (`user_id`) that that were not kicked.
-* `local_aliases` - An array of strings representing the local aliases that were migrated from
- the old room to the new.
-* `new_room_id` - A string representing the room ID of the new room, or `null` if
- no such room was created.
+With this API you can get the status of all active deletion tasks, and all those completed in the last 24h,
+for the given `room_id`.
+
+The API is:
+
+```
+GET /_synapse/admin/v2/rooms/<room_id>/delete_status
+```
+
+A response body like the following is returned:
+
+```json
+{
+ "results": [
+ {
+ "delete_id": "delete_id1",
+ "status": "failed",
+ "error": "error message",
+ "shutdown_room": {
+ "kicked_users": [],
+ "failed_to_kick_users": [],
+ "local_aliases": [],
+ "new_room_id": null
+ }
+ }, {
+ "delete_id": "delete_id2",
+ "status": "purging",
+ "shutdown_room": {
+ "kicked_users": [
+ "@foobar:example.com"
+ ],
+ "failed_to_kick_users": [],
+ "local_aliases": [
+ "#badroom:example.com",
+ "#evilsaloon:example.com"
+ ],
+ "new_room_id": "!newroomid:example.com"
+ }
+ }
+ ]
+}
+```
+
+**Parameters**
+
+The following parameters should be set in the URL:
+
+* `room_id` - The ID of the room.
+
+### Query by `delete_id`
+
+With this API you can get the status of one specific task by `delete_id`.
+
+The API is:
+
+```
+GET /_synapse/admin/v2/rooms/delete_status/<delete_id>
+```
+
+A response body like the following is returned:
+
+```json
+{
+ "status": "purging",
+ "shutdown_room": {
+ "kicked_users": [
+ "@foobar:example.com"
+ ],
+ "failed_to_kick_users": [],
+ "local_aliases": [
+ "#badroom:example.com",
+ "#evilsaloon:example.com"
+ ],
+ "new_room_id": "!newroomid:example.com"
+ }
+}
+```
+
+**Parameters**
+
+The following parameters should be set in the URL:
+
+* `delete_id` - The ID for this delete.
+
+### Response
+
+The following fields are returned in the JSON response body:
+- `results` - An array of objects, each containing information about one task.
+ This field is omitted from the result when you query by `delete_id`.
+ Task objects contain the following fields:
+ - `delete_id` - The ID for this purge if you query by `room_id`.
+ - `status` - The status will be one of:
+ - `shutting_down` - The process is removing users from the room.
+ - `purging` - The process is purging the room and event data from database.
+ - `complete` - The process has completed successfully.
+ - `failed` - The process is aborted, an error has occurred.
+ - `error` - A string that shows an error message if `status` is `failed`.
+ Otherwise this field is hidden.
+ - `shutdown_room` - An object containing information about the result of shutting down the room.
+ *Note:* The result is shown after removing the room members.
+ The delete process can still be running. Please pay attention to the `status`.
+ - `kicked_users` - An array of users (`user_id`) that were kicked.
+ - `failed_to_kick_users` - An array of users (`user_id`) that that were not kicked.
+ - `local_aliases` - An array of strings representing the local aliases that were
+ migrated from the old room to the new.
+ - `new_room_id` - A string representing the room ID of the new room, or `null` if
+ no such room was created.
## Undoing room deletions
diff --git a/synapse/handlers/pagination.py b/synapse/handlers/pagination.py
index aa26911aed..cd64142735 100644
--- a/synapse/handlers/pagination.py
+++ b/synapse/handlers/pagination.py
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
-from typing import TYPE_CHECKING, Any, Dict, Optional, Set
+from typing import TYPE_CHECKING, Any, Collection, Dict, List, Optional, Set
import attr
@@ -22,7 +22,7 @@ from twisted.python.failure import Failure
from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import SynapseError
from synapse.api.filtering import Filter
-from synapse.logging.context import run_in_background
+from synapse.handlers.room import ShutdownRoomResponse
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.storage.state import StateFilter
from synapse.streams.config import PaginationConfig
@@ -56,11 +56,62 @@ class PurgeStatus:
STATUS_FAILED: "failed",
}
+ # Save the error message if an error occurs
+ error: str = ""
+
# Tracks whether this request has completed. One of STATUS_{ACTIVE,COMPLETE,FAILED}.
status: int = STATUS_ACTIVE
def asdict(self) -> JsonDict:
- return {"status": PurgeStatus.STATUS_TEXT[self.status]}
+ ret = {"status": PurgeStatus.STATUS_TEXT[self.status]}
+ if self.error:
+ ret["error"] = self.error
+ return ret
+
+
+@attr.s(slots=True, auto_attribs=True)
+class DeleteStatus:
+ """Object tracking the status of a delete room request
+
+ This class contains information on the progress of a delete room request, for
+ return by get_delete_status.
+ """
+
+ STATUS_PURGING = 0
+ STATUS_COMPLETE = 1
+ STATUS_FAILED = 2
+ STATUS_SHUTTING_DOWN = 3
+
+ STATUS_TEXT = {
+ STATUS_PURGING: "purging",
+ STATUS_COMPLETE: "complete",
+ STATUS_FAILED: "failed",
+ STATUS_SHUTTING_DOWN: "shutting_down",
+ }
+
+ # Tracks whether this request has completed.
+ # One of STATUS_{PURGING,COMPLETE,FAILED,SHUTTING_DOWN}.
+ status: int = STATUS_PURGING
+
+ # Save the error message if an error occurs
+ error: str = ""
+
+ # Saves the result of an action to give it back to REST API
+ shutdown_room: ShutdownRoomResponse = {
+ "kicked_users": [],
+ "failed_to_kick_users": [],
+ "local_aliases": [],
+ "new_room_id": None,
+ }
+
+ def asdict(self) -> JsonDict:
+ ret = {
+ "status": DeleteStatus.STATUS_TEXT[self.status],
+ "shutdown_room": self.shutdown_room,
+ }
+ if self.error:
+ ret["error"] = self.error
+ return ret
class PaginationHandler:
@@ -70,6 +121,9 @@ class PaginationHandler:
paginating during a purge.
"""
+ # when to remove a completed deletion/purge from the results map
+ CLEAR_PURGE_AFTER_MS = 1000 * 3600 * 24 # 24 hours
+
def __init__(self, hs: "HomeServer"):
self.hs = hs
self.auth = hs.get_auth()
@@ -78,11 +132,18 @@ class PaginationHandler:
self.state_store = self.storage.state
self.clock = hs.get_clock()
self._server_name = hs.hostname
+ self._room_shutdown_handler = hs.get_room_shutdown_handler()
self.pagination_lock = ReadWriteLock()
+ # IDs of rooms in which there currently an active purge *or delete* operation.
self._purges_in_progress_by_room: Set[str] = set()
# map from purge id to PurgeStatus
self._purges_by_id: Dict[str, PurgeStatus] = {}
+ # map from purge id to DeleteStatus
+ self._delete_by_id: Dict[str, DeleteStatus] = {}
+ # map from room id to delete ids
+ # Dict[`room_id`, List[`delete_id`]]
+ self._delete_by_room: Dict[str, List[str]] = {}
self._event_serializer = hs.get_event_client_serializer()
self._retention_default_max_lifetime = (
@@ -265,8 +326,13 @@ class PaginationHandler:
logger.info("[purge] starting purge_id %s", purge_id)
self._purges_by_id[purge_id] = PurgeStatus()
- run_in_background(
- self._purge_history, purge_id, room_id, token, delete_local_events
+ run_as_background_process(
+ "purge_history",
+ self._purge_history,
+ purge_id,
+ room_id,
+ token,
+ delete_local_events,
)
return purge_id
@@ -276,7 +342,7 @@ class PaginationHandler:
"""Carry out a history purge on a room.
Args:
- purge_id: The id for this purge
+ purge_id: The ID for this purge.
room_id: The room to purge from
token: topological token to delete events before
delete_local_events: True to delete local events as well as remote ones
@@ -295,6 +361,7 @@ class PaginationHandler:
"[purge] failed", exc_info=(f.type, f.value, f.getTracebackObject()) # type: ignore
)
self._purges_by_id[purge_id].status = PurgeStatus.STATUS_FAILED
+ self._purges_by_id[purge_id].error = f.getErrorMessage()
finally:
self._purges_in_progress_by_room.discard(room_id)
@@ -302,7 +369,9 @@ class PaginationHandler:
def clear_purge() -> None:
del self._purges_by_id[purge_id]
- self.hs.get_reactor().callLater(24 * 3600, clear_purge)
+ self.hs.get_reactor().callLater(
+ PaginationHandler.CLEAR_PURGE_AFTER_MS / 1000, clear_purge
+ )
def get_purge_status(self, purge_id: str) -> Optional[PurgeStatus]:
"""Get the current status of an active purge
@@ -312,8 +381,25 @@ class PaginationHandler:
"""
return self._purges_by_id.get(purge_id)
+ def get_delete_status(self, delete_id: str) -> Optional[DeleteStatus]:
+ """Get the current status of an active deleting
+
+ Args:
+ delete_id: delete_id returned by start_shutdown_and_purge_room
+ """
+ return self._delete_by_id.get(delete_id)
+
+ def get_delete_ids_by_room(self, room_id: str) -> Optional[Collection[str]]:
+ """Get all active delete ids by room
+
+ Args:
+ room_id: room_id that is deleted
+ """
+ return self._delete_by_room.get(room_id)
+
async def purge_room(self, room_id: str, force: bool = False) -> None:
"""Purge the given room from the database.
+ This function is part the delete room v1 API.
Args:
room_id: room to be purged
@@ -472,3 +558,192 @@ class PaginationHandler:
)
return chunk
+
+ async def _shutdown_and_purge_room(
+ self,
+ delete_id: str,
+ room_id: str,
+ requester_user_id: str,
+ new_room_user_id: Optional[str] = None,
+ new_room_name: Optional[str] = None,
+ message: Optional[str] = None,
+ block: bool = False,
+ purge: bool = True,
+ force_purge: bool = False,
+ ) -> None:
+ """
+ Shuts down and purges a room.
+
+ See `RoomShutdownHandler.shutdown_room` for details of creation of the new room
+
+ Args:
+ delete_id: The ID for this delete.
+ room_id: The ID of the room to shut down.
+ requester_user_id:
+ User who requested the action. Will be recorded as putting the room on the
+ blocking list.
+ new_room_user_id:
+ If set, a new room will be created with this user ID
+ as the creator and admin, and all users in the old room will be
+ moved into that room. If not set, no new room will be created
+ and the users will just be removed from the old room.
+ new_room_name:
+ A string representing the name of the room that new users will
+ be invited to. Defaults to `Content Violation Notification`
+ message:
+ A string containing the first message that will be sent as
+ `new_room_user_id` in the new room. Ideally this will clearly
+ convey why the original room was shut down.
+ Defaults to `Sharing illegal content on this server is not
+ permitted and rooms in violation will be blocked.`
+ block:
+ If set to `true`, this room will be added to a blocking list,
+ preventing future attempts to join the room. Defaults to `false`.
+ purge:
+ If set to `true`, purge the given room from the database.
+ force_purge:
+ If set to `true`, the room will be purged from database
+ also if it fails to remove some users from room.
+
+ Saves a `RoomShutdownHandler.ShutdownRoomResponse` in `DeleteStatus`:
+ """
+
+ self._purges_in_progress_by_room.add(room_id)
+ try:
+ with await self.pagination_lock.write(room_id):
+ self._delete_by_id[delete_id].status = DeleteStatus.STATUS_SHUTTING_DOWN
+ self._delete_by_id[
+ delete_id
+ ].shutdown_room = await self._room_shutdown_handler.shutdown_room(
+ room_id=room_id,
+ requester_user_id=requester_user_id,
+ new_room_user_id=new_room_user_id,
+ new_room_name=new_room_name,
+ message=message,
+ block=block,
+ )
+ self._delete_by_id[delete_id].status = DeleteStatus.STATUS_PURGING
+
+ if purge:
+ logger.info("starting purge room_id %s", room_id)
+
+ # first check that we have no users in this room
+ if not force_purge:
+ joined = await self.store.is_host_joined(
+ room_id, self._server_name
+ )
+ if joined:
+ raise SynapseError(
+ 400, "Users are still joined to this room"
+ )
+
+ await self.storage.purge_events.purge_room(room_id)
+
+ logger.info("complete")
+ self._delete_by_id[delete_id].status = DeleteStatus.STATUS_COMPLETE
+ except Exception:
+ f = Failure()
+ logger.error(
+ "failed",
+ exc_info=(f.type, f.value, f.getTracebackObject()), # type: ignore
+ )
+ self._delete_by_id[delete_id].status = DeleteStatus.STATUS_FAILED
+ self._delete_by_id[delete_id].error = f.getErrorMessage()
+ finally:
+ self._purges_in_progress_by_room.discard(room_id)
+
+ # remove the delete from the list 24 hours after it completes
+ def clear_delete() -> None:
+ del self._delete_by_id[delete_id]
+ self._delete_by_room[room_id].remove(delete_id)
+ if not self._delete_by_room[room_id]:
+ del self._delete_by_room[room_id]
+
+ self.hs.get_reactor().callLater(
+ PaginationHandler.CLEAR_PURGE_AFTER_MS / 1000, clear_delete
+ )
+
+ def start_shutdown_and_purge_room(
+ self,
+ room_id: str,
+ requester_user_id: str,
+ new_room_user_id: Optional[str] = None,
+ new_room_name: Optional[str] = None,
+ message: Optional[str] = None,
+ block: bool = False,
+ purge: bool = True,
+ force_purge: bool = False,
+ ) -> str:
+ """Start off shut down and purge on a room.
+
+ Args:
+ room_id: The ID of the room to shut down.
+ requester_user_id:
+ User who requested the action and put the room on the
+ blocking list.
+ new_room_user_id:
+ If set, a new room will be created with this user ID
+ as the creator and admin, and all users in the old room will be
+ moved into that room. If not set, no new room will be created
+ and the users will just be removed from the old room.
+ new_room_name:
+ A string representing the name of the room that new users will
+ be invited to. Defaults to `Content Violation Notification`
+ message:
+ A string containing the first message that will be sent as
+ `new_room_user_id` in the new room. Ideally this will clearly
+ convey why the original room was shut down.
+ Defaults to `Sharing illegal content on this server is not
+ permitted and rooms in violation will be blocked.`
+ block:
+ If set to `true`, this room will be added to a blocking list,
+ preventing future attempts to join the room. Defaults to `false`.
+ purge:
+ If set to `true`, purge the given room from the database.
+ force_purge:
+ If set to `true`, the room will be purged from database
+ also if it fails to remove some users from room.
+
+ Returns:
+ unique ID for this delete transaction.
+ """
+ if room_id in self._purges_in_progress_by_room:
+ raise SynapseError(
+ 400, "History purge already in progress for %s" % (room_id,)
+ )
+
+ # This check is double to `RoomShutdownHandler.shutdown_room`
+ # But here the requester get a direct response / error with HTTP request
+ # and do not have to check the purge status
+ if new_room_user_id is not None:
+ if not self.hs.is_mine_id(new_room_user_id):
+ raise SynapseError(
+ 400, "User must be our own: %s" % (new_room_user_id,)
+ )
+
+ delete_id = random_string(16)
+
+ # we log the delete_id here so that it can be tied back to the
+ # request id in the log lines.
+ logger.info(
+ "starting shutdown room_id %s with delete_id %s",
+ room_id,
+ delete_id,
+ )
+
+ self._delete_by_id[delete_id] = DeleteStatus()
+ self._delete_by_room.setdefault(room_id, []).append(delete_id)
+ run_as_background_process(
+ "shutdown_and_purge_room",
+ self._shutdown_and_purge_room,
+ delete_id,
+ room_id,
+ requester_user_id,
+ new_room_user_id,
+ new_room_name,
+ message,
+ block,
+ purge,
+ force_purge,
+ )
+ return delete_id
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 11af30eee7..f9a099c4f3 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -1279,6 +1279,17 @@ class RoomEventSource(EventSource[RoomStreamToken, EventBase]):
class ShutdownRoomResponse(TypedDict):
+ """
+ Attributes:
+ kicked_users: An array of users (`user_id`) that were kicked.
+ failed_to_kick_users:
+ An array of users (`user_id`) that that were not kicked.
+ local_aliases:
+ An array of strings representing the local aliases that were
+ migrated from the old room to the new.
+ new_room_id: A string representing the room ID of the new room.
+ """
+
kicked_users: List[str]
failed_to_kick_users: List[str]
local_aliases: List[str]
@@ -1286,7 +1297,6 @@ class ShutdownRoomResponse(TypedDict):
class RoomShutdownHandler:
-
DEFAULT_MESSAGE = (
"Sharing illegal content on this server is not permitted and rooms in"
" violation will be blocked."
@@ -1299,7 +1309,6 @@ class RoomShutdownHandler:
self._room_creation_handler = hs.get_room_creation_handler()
self._replication = hs.get_replication_data_handler()
self.event_creation_handler = hs.get_event_creation_handler()
- self.state = hs.get_state_handler()
self.store = hs.get_datastore()
async def shutdown_room(
diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
index 81e98f81d6..d78fe406c4 100644
--- a/synapse/rest/admin/__init__.py
+++ b/synapse/rest/admin/__init__.py
@@ -46,6 +46,8 @@ from synapse.rest.admin.registration_tokens import (
RegistrationTokenRestServlet,
)
from synapse.rest.admin.rooms import (
+ DeleteRoomStatusByDeleteIdRestServlet,
+ DeleteRoomStatusByRoomIdRestServlet,
ForwardExtremitiesRestServlet,
JoinRoomAliasServlet,
ListRoomRestServlet,
@@ -53,6 +55,7 @@ from synapse.rest.admin.rooms import (
RoomEventContextServlet,
RoomMembersRestServlet,
RoomRestServlet,
+ RoomRestV2Servlet,
RoomStateRestServlet,
)
from synapse.rest.admin.server_notice_servlet import SendServerNoticeServlet
@@ -223,7 +226,10 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
ListRoomRestServlet(hs).register(http_server)
RoomStateRestServlet(hs).register(http_server)
RoomRestServlet(hs).register(http_server)
+ RoomRestV2Servlet(hs).register(http_server)
RoomMembersRestServlet(hs).register(http_server)
+ DeleteRoomStatusByDeleteIdRestServlet(hs).register(http_server)
+ DeleteRoomStatusByRoomIdRestServlet(hs).register(http_server)
JoinRoomAliasServlet(hs).register(http_server)
VersionServlet(hs).register(http_server)
UserAdminServlet(hs).register(http_server)
diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py
index a2f4edebb8..37cb4d0796 100644
--- a/synapse/rest/admin/rooms.py
+++ b/synapse/rest/admin/rooms.py
@@ -34,7 +34,7 @@ from synapse.rest.admin._base import (
assert_user_is_admin,
)
from synapse.storage.databases.main.room import RoomSortOrder
-from synapse.types import JsonDict, UserID, create_requester
+from synapse.types import JsonDict, RoomID, UserID, create_requester
from synapse.util import json_decoder
if TYPE_CHECKING:
@@ -46,6 +46,138 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+class RoomRestV2Servlet(RestServlet):
+ """Delete a room from server asynchronously with a background task.
+
+ It is a combination and improvement of shutdown and purge room.
+
+ Shuts down a room by removing all local users from the room.
+ Blocking all future invites and joins to the room is optional.
+
+ If desired any local aliases will be repointed to a new room
+ created by `new_room_user_id` and kicked users will be auto-
+ joined to the new room.
+
+ If 'purge' is true, it will remove all traces of a room from the database.
+ """
+
+ PATTERNS = admin_patterns("/rooms/(?P<room_id>[^/]+)$", "v2")
+
+ def __init__(self, hs: "HomeServer"):
+ self._auth = hs.get_auth()
+ self._store = hs.get_datastore()
+ self._pagination_handler = hs.get_pagination_handler()
+
+ async def on_DELETE(
+ self, request: SynapseRequest, room_id: str
+ ) -> Tuple[int, JsonDict]:
+
+ requester = await self._auth.get_user_by_req(request)
+ await assert_user_is_admin(self._auth, requester.user)
+
+ content = parse_json_object_from_request(request)
+
+ block = content.get("block", False)
+ if not isinstance(block, bool):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Param 'block' must be a boolean, if given",
+ Codes.BAD_JSON,
+ )
+
+ purge = content.get("purge", True)
+ if not isinstance(purge, bool):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Param 'purge' must be a boolean, if given",
+ Codes.BAD_JSON,
+ )
+
+ force_purge = content.get("force_purge", False)
+ if not isinstance(force_purge, bool):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Param 'force_purge' must be a boolean, if given",
+ Codes.BAD_JSON,
+ )
+
+ if not RoomID.is_valid(room_id):
+ raise SynapseError(400, "%s is not a legal room ID" % (room_id,))
+
+ if not await self._store.get_room(room_id):
+ raise NotFoundError("Unknown room id %s" % (room_id,))
+
+ delete_id = self._pagination_handler.start_shutdown_and_purge_room(
+ room_id=room_id,
+ new_room_user_id=content.get("new_room_user_id"),
+ new_room_name=content.get("room_name"),
+ message=content.get("message"),
+ requester_user_id=requester.user.to_string(),
+ block=block,
+ purge=purge,
+ force_purge=force_purge,
+ )
+
+ return 200, {"delete_id": delete_id}
+
+
+class DeleteRoomStatusByRoomIdRestServlet(RestServlet):
+ """Get the status of the delete room background task."""
+
+ PATTERNS = admin_patterns("/rooms/(?P<room_id>[^/]+)/delete_status$", "v2")
+
+ def __init__(self, hs: "HomeServer"):
+ self._auth = hs.get_auth()
+ self._pagination_handler = hs.get_pagination_handler()
+
+ async def on_GET(
+ self, request: SynapseRequest, room_id: str
+ ) -> Tuple[int, JsonDict]:
+
+ await assert_requester_is_admin(self._auth, request)
+
+ if not RoomID.is_valid(room_id):
+ raise SynapseError(400, "%s is not a legal room ID" % (room_id,))
+
+ delete_ids = self._pagination_handler.get_delete_ids_by_room(room_id)
+ if delete_ids is None:
+ raise NotFoundError("No delete task for room_id '%s' found" % room_id)
+
+ response = []
+ for delete_id in delete_ids:
+ delete = self._pagination_handler.get_delete_status(delete_id)
+ if delete:
+ response += [
+ {
+ "delete_id": delete_id,
+ **delete.asdict(),
+ }
+ ]
+ return 200, {"results": cast(JsonDict, response)}
+
+
+class DeleteRoomStatusByDeleteIdRestServlet(RestServlet):
+ """Get the status of the delete room background task."""
+
+ PATTERNS = admin_patterns("/rooms/delete_status/(?P<delete_id>[^/]+)$", "v2")
+
+ def __init__(self, hs: "HomeServer"):
+ self._auth = hs.get_auth()
+ self._pagination_handler = hs.get_pagination_handler()
+
+ async def on_GET(
+ self, request: SynapseRequest, delete_id: str
+ ) -> Tuple[int, JsonDict]:
+
+ await assert_requester_is_admin(self._auth, request)
+
+ delete_status = self._pagination_handler.get_delete_status(delete_id)
+ if delete_status is None:
+ raise NotFoundError("delete id '%s' not found" % delete_id)
+
+ return 200, cast(JsonDict, delete_status.asdict())
+
+
class ListRoomRestServlet(RestServlet):
"""
List all rooms that are known to the homeserver. Results are returned
diff --git a/tests/rest/admin/test_admin.py b/tests/rest/admin/test_admin.py
index 192073c520..af849bd471 100644
--- a/tests/rest/admin/test_admin.py
+++ b/tests/rest/admin/test_admin.py
@@ -474,3 +474,51 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
% server_and_media_id_2
),
)
+
+
+class PurgeHistoryTestCase(unittest.HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets,
+ login.register_servlets,
+ room.register_servlets,
+ ]
+
+ def prepare(self, reactor, clock, hs):
+ 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.room_id = self.helper.create_room_as(
+ self.other_user, tok=self.other_user_tok
+ )
+ self.url = f"/_synapse/admin/v1/purge_history/{self.room_id}"
+ self.url_status = "/_synapse/admin/v1/purge_history_status/"
+
+ def test_purge_history(self):
+ """
+ Simple test of purge history API.
+ Test only that is is possible to call, get status 200 and purge_id.
+ """
+
+ channel = self.make_request(
+ "POST",
+ self.url,
+ content={"delete_local_events": True, "purge_up_to_ts": 0},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertIn("purge_id", channel.json_body)
+ purge_id = channel.json_body["purge_id"]
+
+ # get status
+ channel = self.make_request(
+ "GET",
+ self.url_status + purge_id,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("complete", channel.json_body["status"])
diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py
index 11ec54c82e..b48fc12e5f 100644
--- a/tests/rest/admin/test_room.py
+++ b/tests/rest/admin/test_room.py
@@ -23,6 +23,7 @@ from parameterized import parameterized
import synapse.rest.admin
from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import Codes
+from synapse.handlers.pagination import PaginationHandler
from synapse.rest.client import directory, events, login, room
from tests import unittest
@@ -71,11 +72,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url,
- json.dumps({}),
+ {},
access_token=self.other_user_tok,
)
- self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(403, channel.code, msg=channel.json_body)
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
def test_room_does_not_exist(self):
@@ -87,11 +88,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
url,
- json.dumps({}),
+ {},
access_token=self.admin_user_tok,
)
- self.assertEqual(404, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(404, channel.code, msg=channel.json_body)
self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"])
def test_room_is_not_valid(self):
@@ -103,11 +104,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
url,
- json.dumps({}),
+ {},
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(
"invalidroom is not a legal room ID",
channel.json_body["error"],
@@ -122,11 +123,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertIn("new_room_id", channel.json_body)
self.assertIn("kicked_users", channel.json_body)
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -141,11 +142,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(
"User must be our own: @not:exist.bla",
channel.json_body["error"],
@@ -160,11 +161,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(Codes.BAD_JSON, channel.json_body["errcode"])
def test_purge_is_not_bool(self):
@@ -176,11 +177,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(Codes.BAD_JSON, channel.json_body["errcode"])
def test_purge_room_and_block(self):
@@ -202,11 +203,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url.encode("ascii"),
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(None, channel.json_body["new_room_id"])
self.assertEqual(self.other_user, channel.json_body["kicked_users"][0])
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -235,11 +236,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url.encode("ascii"),
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(None, channel.json_body["new_room_id"])
self.assertEqual(self.other_user, channel.json_body["kicked_users"][0])
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -269,11 +270,11 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"DELETE",
self.url.encode("ascii"),
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(None, channel.json_body["new_room_id"])
self.assertEqual(self.other_user, channel.json_body["kicked_users"][0])
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -344,7 +345,7 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(self.other_user, channel.json_body["kicked_users"][0])
self.assertIn("new_room_id", channel.json_body)
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -373,7 +374,7 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
json.dumps({"history_visibility": "world_readable"}),
access_token=self.other_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Test that room is not purged
with self.assertRaises(AssertionError):
@@ -390,7 +391,7 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(self.other_user, channel.json_body["kicked_users"][0])
self.assertIn("new_room_id", channel.json_body)
self.assertIn("failed_to_kick_users", channel.json_body)
@@ -446,18 +447,617 @@ class DeleteRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"GET", url.encode("ascii"), access_token=self.admin_user_tok
)
+ self.assertEqual(expect_code, channel.code, msg=channel.json_body)
+
+ url = "events?timeout=0&room_id=" + room_id
+ channel = self.make_request(
+ "GET", url.encode("ascii"), access_token=self.admin_user_tok
+ )
+ self.assertEqual(expect_code, channel.code, msg=channel.json_body)
+
+
+class DeleteRoomV2TestCase(unittest.HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets,
+ login.register_servlets,
+ events.register_servlets,
+ room.register_servlets,
+ room.register_deprecated_servlets,
+ ]
+
+ def prepare(self, reactor, clock, hs):
+ self.event_creation_handler = hs.get_event_creation_handler()
+ hs.config.consent.user_consent_version = "1"
+
+ consent_uri_builder = Mock()
+ consent_uri_builder.build_user_consent_uri.return_value = "http://example.com"
+ self.event_creation_handler._consent_uri_builder = consent_uri_builder
+
+ self.store = hs.get_datastore()
+
+ 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")
+
+ # Mark the admin user as having consented
+ self.get_success(self.store.user_set_consent_version(self.admin_user, "1"))
+
+ self.room_id = self.helper.create_room_as(
+ self.other_user, tok=self.other_user_tok
+ )
+ self.url = f"/_synapse/admin/v2/rooms/{self.room_id}"
+ self.url_status_by_room_id = (
+ f"/_synapse/admin/v2/rooms/{self.room_id}/delete_status"
+ )
+ self.url_status_by_delete_id = "/_synapse/admin/v2/rooms/delete_status/"
+
+ @parameterized.expand(
+ [
+ ("DELETE", "/_synapse/admin/v2/rooms/%s"),
+ ("GET", "/_synapse/admin/v2/rooms/%s/delete_status"),
+ ("GET", "/_synapse/admin/v2/rooms/delete_status/%s"),
+ ]
+ )
+ def test_requester_is_no_admin(self, method: str, url: str):
+ """
+ If the user is not a server admin, an error 403 is returned.
+ """
+
+ channel = self.make_request(
+ method,
+ url % self.room_id,
+ content={},
+ access_token=self.other_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.FORBIDDEN, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
+
+ @parameterized.expand(
+ [
+ ("DELETE", "/_synapse/admin/v2/rooms/%s"),
+ ("GET", "/_synapse/admin/v2/rooms/%s/delete_status"),
+ ("GET", "/_synapse/admin/v2/rooms/delete_status/%s"),
+ ]
+ )
+ def test_room_does_not_exist(self, method: str, url: str):
+ """
+ Check that unknown rooms/server return error 404.
+ """
+
+ channel = self.make_request(
+ method,
+ url % "!unknown:test",
+ content={},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.NOT_FOUND, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"])
+
+ @parameterized.expand(
+ [
+ ("DELETE", "/_synapse/admin/v2/rooms/%s"),
+ ("GET", "/_synapse/admin/v2/rooms/%s/delete_status"),
+ ]
+ )
+ def test_room_is_not_valid(self, method: str, url: str):
+ """
+ Check that invalid room names, return an error 400.
+ """
+
+ channel = self.make_request(
+ method,
+ url % "invalidroom",
+ content={},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ "invalidroom is not a legal room ID",
+ channel.json_body["error"],
+ )
+
+ def test_new_room_user_does_not_exist(self):
+ """
+ Tests that the user ID must be from local server but it does not have to exist.
+ """
+
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"new_room_user_id": "@unknown:test"},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user, expect_new_room=True)
+
+ def test_new_room_user_is_not_local(self):
+ """
+ Check that only local users can create new room to move members.
+ """
+
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"new_room_user_id": "@not:exist.bla"},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, msg=channel.json_body)
self.assertEqual(
- expect_code, int(channel.result["code"]), msg=channel.result["body"]
+ "User must be our own: @not:exist.bla",
+ channel.json_body["error"],
)
+ def test_block_is_not_bool(self):
+ """
+ If parameter `block` is not boolean, return an error
+ """
+
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"block": "NotBool"},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.BAD_JSON, channel.json_body["errcode"])
+
+ def test_purge_is_not_bool(self):
+ """
+ If parameter `purge` is not boolean, return an error
+ """
+
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"purge": "NotBool"},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.BAD_JSON, channel.json_body["errcode"])
+
+ def test_delete_expired_status(self):
+ """Test that the task status is removed after expiration."""
+
+ # first task, do not purge, that we can create a second task
+ channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content={"purge": False},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id1 = channel.json_body["delete_id"]
+
+ # go ahead
+ self.reactor.advance(PaginationHandler.CLEAR_PURGE_AFTER_MS / 1000 / 2)
+
+ # second task
+ channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content={"purge": True},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id2 = channel.json_body["delete_id"]
+
+ # get status
+ channel = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertEqual(2, len(channel.json_body["results"]))
+ self.assertEqual("complete", channel.json_body["results"][0]["status"])
+ self.assertEqual("complete", channel.json_body["results"][1]["status"])
+ self.assertEqual(delete_id1, channel.json_body["results"][0]["delete_id"])
+ self.assertEqual(delete_id2, channel.json_body["results"][1]["delete_id"])
+
+ # get status after more than clearing time for first task
+ # second task is not cleared
+ self.reactor.advance(PaginationHandler.CLEAR_PURGE_AFTER_MS / 1000 / 2)
+
+ channel = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertEqual(1, len(channel.json_body["results"]))
+ self.assertEqual("complete", channel.json_body["results"][0]["status"])
+ self.assertEqual(delete_id2, channel.json_body["results"][0]["delete_id"])
+
+ # get status after more than clearing time for all tasks
+ self.reactor.advance(PaginationHandler.CLEAR_PURGE_AFTER_MS / 1000 / 2)
+
+ channel = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.NOT_FOUND, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"])
+
+ def test_delete_same_room_twice(self):
+ """Test that the call for delete a room at second time gives an exception."""
+
+ body = {"new_room_user_id": self.admin_user}
+
+ # first call to delete room
+ # and do not wait for finish the task
+ first_channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content=body,
+ access_token=self.admin_user_tok,
+ await_result=False,
+ )
+
+ # second call to delete room
+ second_channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content=body,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(
+ HTTPStatus.BAD_REQUEST, second_channel.code, msg=second_channel.json_body
+ )
+ self.assertEqual(Codes.UNKNOWN, second_channel.json_body["errcode"])
+ self.assertEqual(
+ f"History purge already in progress for {self.room_id}",
+ second_channel.json_body["error"],
+ )
+
+ # get result of first call
+ first_channel.await_result()
+ self.assertEqual(HTTPStatus.OK, first_channel.code, msg=first_channel.json_body)
+ self.assertIn("delete_id", first_channel.json_body)
+
+ # check status after finish the task
+ self._test_result(
+ first_channel.json_body["delete_id"],
+ self.other_user,
+ expect_new_room=True,
+ )
+
+ def test_purge_room_and_block(self):
+ """Test to purge a room and block it.
+ Members will not be moved to a new room and will not receive a message.
+ """
+ # Test that room is not purged
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+
+ # Test that room is not blocked
+ self._is_blocked(self.room_id, expect=False)
+
+ # Assert one user in room
+ self._is_member(room_id=self.room_id, user_id=self.other_user)
+
+ channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content={"block": True, "purge": True},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user)
+
+ self._is_purged(self.room_id)
+ self._is_blocked(self.room_id, expect=True)
+ self._has_no_members(self.room_id)
+
+ def test_purge_room_and_not_block(self):
+ """Test to purge a room and do not block it.
+ Members will not be moved to a new room and will not receive a message.
+ """
+ # Test that room is not purged
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+
+ # Test that room is not blocked
+ self._is_blocked(self.room_id, expect=False)
+
+ # Assert one user in room
+ self._is_member(room_id=self.room_id, user_id=self.other_user)
+
+ channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content={"block": False, "purge": True},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user)
+
+ self._is_purged(self.room_id)
+ self._is_blocked(self.room_id, expect=False)
+ self._has_no_members(self.room_id)
+
+ def test_block_room_and_not_purge(self):
+ """Test to block a room without purging it.
+ Members will not be moved to a new room and will not receive a message.
+ The room will not be purged.
+ """
+ # Test that room is not purged
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+
+ # Test that room is not blocked
+ self._is_blocked(self.room_id, expect=False)
+
+ # Assert one user in room
+ self._is_member(room_id=self.room_id, user_id=self.other_user)
+
+ channel = self.make_request(
+ "DELETE",
+ self.url.encode("ascii"),
+ content={"block": True, "purge": False},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user)
+
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+ self._is_blocked(self.room_id, expect=True)
+ self._has_no_members(self.room_id)
+
+ def test_shutdown_room_consent(self):
+ """Test that we can shutdown rooms with local users who have not
+ yet accepted the privacy policy. This used to fail when we tried to
+ force part the user from the old room.
+ Members will be moved to a new room and will receive a message.
+ """
+ self.event_creation_handler._block_events_without_consent_error = None
+
+ # Assert one user in room
+ users_in_room = self.get_success(self.store.get_users_in_room(self.room_id))
+ self.assertEqual([self.other_user], users_in_room)
+
+ # Enable require consent to send events
+ self.event_creation_handler._block_events_without_consent_error = "Error"
+
+ # Assert that the user is getting consent error
+ self.helper.send(
+ self.room_id, body="foo", tok=self.other_user_tok, expect_code=403
+ )
+
+ # Test that room is not purged
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+
+ # Assert one user in room
+ self._is_member(room_id=self.room_id, user_id=self.other_user)
+
+ # Test that the admin can still send shutdown
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"new_room_user_id": self.admin_user},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user, expect_new_room=True)
+
+ channel = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertEqual(1, len(channel.json_body["results"]))
+
+ # Test that member has moved to new room
+ self._is_member(
+ room_id=channel.json_body["results"][0]["shutdown_room"]["new_room_id"],
+ user_id=self.other_user,
+ )
+
+ self._is_purged(self.room_id)
+ self._has_no_members(self.room_id)
+
+ def test_shutdown_room_block_peek(self):
+ """Test that a world_readable room can no longer be peeked into after
+ it has been shut down.
+ Members will be moved to a new room and will receive a message.
+ """
+ self.event_creation_handler._block_events_without_consent_error = None
+
+ # Enable world readable
+ url = "rooms/%s/state/m.room.history_visibility" % (self.room_id,)
+ channel = self.make_request(
+ "PUT",
+ url.encode("ascii"),
+ content={"history_visibility": "world_readable"},
+ access_token=self.other_user_tok,
+ )
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+
+ # Test that room is not purged
+ with self.assertRaises(AssertionError):
+ self._is_purged(self.room_id)
+
+ # Assert one user in room
+ self._is_member(room_id=self.room_id, user_id=self.other_user)
+
+ # Test that the admin can still send shutdown
+ channel = self.make_request(
+ "DELETE",
+ self.url,
+ content={"new_room_user_id": self.admin_user},
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertIn("delete_id", channel.json_body)
+ delete_id = channel.json_body["delete_id"]
+
+ self._test_result(delete_id, self.other_user, expect_new_room=True)
+
+ channel = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+ self.assertEqual(1, len(channel.json_body["results"]))
+
+ # Test that member has moved to new room
+ self._is_member(
+ room_id=channel.json_body["results"][0]["shutdown_room"]["new_room_id"],
+ user_id=self.other_user,
+ )
+
+ self._is_purged(self.room_id)
+ self._has_no_members(self.room_id)
+
+ # Assert we can no longer peek into the room
+ self._assert_peek(self.room_id, expect_code=403)
+
+ def _is_blocked(self, room_id: str, expect: bool = True) -> None:
+ """Assert that the room is blocked or not"""
+ d = self.store.is_room_blocked(room_id)
+ if expect:
+ self.assertTrue(self.get_success(d))
+ else:
+ self.assertIsNone(self.get_success(d))
+
+ def _has_no_members(self, room_id: str) -> None:
+ """Assert there is now no longer anyone in the room"""
+ users_in_room = self.get_success(self.store.get_users_in_room(room_id))
+ self.assertEqual([], users_in_room)
+
+ def _is_member(self, room_id: str, user_id: str) -> None:
+ """Test that user is member of the room"""
+ users_in_room = self.get_success(self.store.get_users_in_room(room_id))
+ self.assertIn(user_id, users_in_room)
+
+ def _is_purged(self, room_id: str) -> None:
+ """Test that the following tables have been purged of all rows related to the room."""
+ for table in PURGE_TABLES:
+ count = self.get_success(
+ self.store.db_pool.simple_select_one_onecol(
+ table=table,
+ keyvalues={"room_id": room_id},
+ retcol="COUNT(*)",
+ desc="test_purge_room",
+ )
+ )
+
+ self.assertEqual(count, 0, msg=f"Rows not purged in {table}")
+
+ def _assert_peek(self, room_id: str, expect_code: int) -> None:
+ """Assert that the admin user can (or cannot) peek into the room."""
+
+ url = f"rooms/{room_id}/initialSync"
+ channel = self.make_request(
+ "GET", url.encode("ascii"), access_token=self.admin_user_tok
+ )
+ self.assertEqual(expect_code, channel.code, msg=channel.json_body)
+
url = "events?timeout=0&room_id=" + room_id
channel = self.make_request(
"GET", url.encode("ascii"), access_token=self.admin_user_tok
)
+ self.assertEqual(expect_code, channel.code, msg=channel.json_body)
+
+ def _test_result(
+ self,
+ delete_id: str,
+ kicked_user: str,
+ expect_new_room: bool = False,
+ ) -> None:
+ """
+ Test that the result is the expected.
+ Uses both APIs (status by room_id and delete_id)
+
+ Args:
+ delete_id: id of this purge
+ kicked_user: a user_id which is kicked from the room
+ expect_new_room: if we expect that a new room was created
+ """
+
+ # get information by room_id
+ channel_room_id = self.make_request(
+ "GET",
+ self.url_status_by_room_id,
+ access_token=self.admin_user_tok,
+ )
self.assertEqual(
- expect_code, int(channel.result["code"]), msg=channel.result["body"]
+ HTTPStatus.OK, channel_room_id.code, msg=channel_room_id.json_body
+ )
+ self.assertEqual(1, len(channel_room_id.json_body["results"]))
+ self.assertEqual(
+ delete_id, channel_room_id.json_body["results"][0]["delete_id"]
)
+ # get information by delete_id
+ channel_delete_id = self.make_request(
+ "GET",
+ self.url_status_by_delete_id + delete_id,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(
+ HTTPStatus.OK,
+ channel_delete_id.code,
+ msg=channel_delete_id.json_body,
+ )
+
+ # test values that are the same in both responses
+ for content in [
+ channel_room_id.json_body["results"][0],
+ channel_delete_id.json_body,
+ ]:
+ self.assertEqual("complete", content["status"])
+ self.assertEqual(kicked_user, content["shutdown_room"]["kicked_users"][0])
+ self.assertIn("failed_to_kick_users", content["shutdown_room"])
+ self.assertIn("local_aliases", content["shutdown_room"])
+ self.assertNotIn("error", content)
+
+ if expect_new_room:
+ self.assertIsNotNone(content["shutdown_room"]["new_room_id"])
+ else:
+ self.assertIsNone(content["shutdown_room"]["new_room_id"])
+
class RoomTestCase(unittest.HomeserverTestCase):
"""Test /room admin API."""
@@ -494,7 +1094,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
)
# Check request completed successfully
- self.assertEqual(200, int(channel.code), msg=channel.json_body)
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Check that response json body contains a "rooms" key
self.assertTrue(
@@ -578,9 +1178,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
url.encode("ascii"),
access_token=self.admin_user_tok,
)
- self.assertEqual(
- 200, int(channel.result["code"]), msg=channel.result["body"]
- )
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertTrue("rooms" in channel.json_body)
for r in channel.json_body["rooms"]:
@@ -620,7 +1218,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
url.encode("ascii"),
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
def test_correct_room_attributes(self):
"""Test the correct attributes for a room are returned"""
@@ -643,7 +1241,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
{"room_id": room_id},
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Set this new alias as the canonical alias for this room
self.helper.send_state(
@@ -675,7 +1273,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
url.encode("ascii"),
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Check that rooms were returned
self.assertTrue("rooms" in channel.json_body)
@@ -1135,7 +1733,7 @@ class RoomTestCase(unittest.HomeserverTestCase):
{"room_id": room_id},
access_token=admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Set this new alias as the canonical alias for this room
self.helper.send_state(
@@ -1185,11 +1783,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.second_tok,
)
- self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(403, channel.code, msg=channel.json_body)
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
def test_invalid_parameter(self):
@@ -1201,11 +1799,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(Codes.MISSING_PARAM, channel.json_body["errcode"])
def test_local_user_does_not_exist(self):
@@ -1217,11 +1815,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(404, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(404, channel.code, msg=channel.json_body)
self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"])
def test_remote_user(self):
@@ -1233,11 +1831,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(
"This endpoint can only be used with local users",
channel.json_body["error"],
@@ -1253,11 +1851,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(404, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(404, channel.code, msg=channel.json_body)
self.assertEqual("No known servers", channel.json_body["error"])
def test_room_is_not_valid(self):
@@ -1270,11 +1868,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(
"invalidroom was not legal room ID or room alias",
channel.json_body["error"],
@@ -1289,11 +1887,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
self.url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(self.public_room_id, channel.json_body["room_id"])
# Validate if user is a member of the room
@@ -1303,7 +1901,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
"/_matrix/client/r0/joined_rooms",
access_token=self.second_tok,
)
- self.assertEquals(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEquals(200, channel.code, msg=channel.json_body)
self.assertEqual(self.public_room_id, channel.json_body["joined_rooms"][0])
def test_join_private_room_if_not_member(self):
@@ -1320,11 +1918,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(403, channel.code, msg=channel.json_body)
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
def test_join_private_room_if_member(self):
@@ -1352,7 +1950,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
"/_matrix/client/r0/joined_rooms",
access_token=self.admin_user_tok,
)
- self.assertEquals(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEquals(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0])
# Join user to room.
@@ -1363,10 +1961,10 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["room_id"])
# Validate if user is a member of the room
@@ -1376,7 +1974,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
"/_matrix/client/r0/joined_rooms",
access_token=self.second_tok,
)
- self.assertEquals(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEquals(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0])
def test_join_private_room_if_owner(self):
@@ -1393,11 +1991,11 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
channel = self.make_request(
"POST",
url,
- content=body.encode(encoding="utf_8"),
+ content=body,
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["room_id"])
# Validate if user is a member of the room
@@ -1407,7 +2005,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
"/_matrix/client/r0/joined_rooms",
access_token=self.second_tok,
)
- self.assertEquals(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEquals(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0])
def test_context_as_non_admin(self):
@@ -1441,9 +2039,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
% (room_id, events[midway]["event_id"]),
access_token=tok,
)
- self.assertEquals(
- 403, int(channel.result["code"]), msg=channel.result["body"]
- )
+ self.assertEquals(403, channel.code, msg=channel.json_body)
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
def test_context_as_admin(self):
@@ -1473,7 +2069,7 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
% (room_id, events[midway]["event_id"]),
access_token=self.admin_user_tok,
)
- self.assertEquals(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEquals(200, channel.code, msg=channel.json_body)
self.assertEquals(
channel.json_body["event"]["event_id"], events[midway]["event_id"]
)
@@ -1532,7 +2128,7 @@ class MakeRoomAdminTestCase(unittest.HomeserverTestCase):
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Now we test that we can join the room and ban a user.
self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok)
@@ -1559,7 +2155,7 @@ class MakeRoomAdminTestCase(unittest.HomeserverTestCase):
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Now we test that we can join the room (we should have received an
# invite) and can ban a user.
@@ -1585,7 +2181,7 @@ class MakeRoomAdminTestCase(unittest.HomeserverTestCase):
access_token=self.admin_user_tok,
)
- self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(200, channel.code, msg=channel.json_body)
# Now we test that we can join the room and ban a user.
self.helper.join(room_id, self.second_user_id, tok=self.second_tok)
@@ -1623,7 +2219,7 @@ class MakeRoomAdminTestCase(unittest.HomeserverTestCase):
#
# (Note we assert the error message to ensure that it's not denied for
# some other reason)
- self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
+ self.assertEqual(400, channel.code, msg=channel.json_body)
self.assertEqual(
channel.json_body["error"],
"No local admin user in room with power to update power levels.",
|