diff --git a/changelog.d/19648.feature b/changelog.d/19648.feature new file mode 100644 index 00000000000..a57548be2e3 --- /dev/null +++ b/changelog.d/19648.feature @@ -0,0 +1 @@ +Add [Admin API](https://matrix-org.github.io/synapse/develop/usage/administration/admin_api/index.html) endpoints to list and fetch room reports. \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 980f51d078c..03d88ae63e2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -63,6 +63,7 @@ - [Background Updates](usage/administration/admin_api/background_updates.md) - [Fetch Event](admin_api/fetch_event.md) - [Event Reports](admin_api/event_reports.md) + - [Room Reports](admin_api/room_reports.md) - [Experimental Features](admin_api/experimental_features.md) - [Media](admin_api/media_admin_api.md) - [Purge History](admin_api/purge_history_api.md) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md new file mode 100644 index 00000000000..391bf3cf8a4 --- /dev/null +++ b/docs/admin_api/room_reports.md @@ -0,0 +1,120 @@ +# Show reported rooms + +This API returns information about reported rooms. + +To use it, you will need to authenticate by providing an `access_token` +for a server admin: see [Admin API](../usage/administration/admin_api/). + +The API is: +``` +GET /_synapse/admin/v1/room_reports +``` + +It returns a JSON body like the following: + +```json +{ + "room_reports": [ + { + "id": 2, + "received_ts": 1570897107409, + "room_id": "!ERAgBpSOcCCuTJqQPk:matrix.org", + "user_id": "@foo:matrix.org", + "reason": "This room contains spam", + "canonical_alias": "#alias1:matrix.org", + "name": "Matrix chat", + "topic": "Discussions about Matrix" + }, + { + "id": 3, + "received_ts": 1598889612059, + "room_id": "!eGvUQuTCkHGVwNMOjv:matrix.org", + "user_id": "@bar:matrix.org", + "reason": "Inappropriate content", + "canonical_alias": null, + "name": "Nefarious room", + "topic": null + } + ], + "next_batch": 2 +} +``` + +Note: Reports for deleted or purged rooms are not returned. The endpoint returns reports in reverse +chronological order - most recent first, oldest last. + +To paginate, check for `next_batch` and if present, call the endpoint again with `from` +set to the value of `next_batch`. This will return a new page. + +If the endpoint does not return a `next_batch` then there are no more reports to +paginate through. + +**URL query parameters:** + +* `limit`: positive integer - Optional. Used for pagination, denoting the maximum number + of items to return in this call. Defaults to `100` if not provided. +* `from`: string - Pagination token. This token can be obtained from the `next_batch` returned by a previous response from this endpoint. +* `user_id`: string - Optional. Filter by the user ID of the reporter. This is the user who + reported the room. +* `room_id`: string - Optional. Filter by room id. + +**Response** + +The following fields are returned in the JSON response body: + +* `id`: integer - ID of room report. +* `received_ts`: integer - The timestamp (in milliseconds since the unix epoch) when this + report was sent. +* `room_id`: string - The ID of the reported room. +* `user_id`: string - This is the user who reported the room. +* `reason`: string - Comment made by the `user_id` in this report indicating why the room + was reported. May be blank or `null`. +* `canonical_alias`: string - The canonical alias of the room. `null` if the room does not + have a canonical alias set. +* `name`: string - The name of the room. +* `topic`: string - The topic of the room. `null` if the room does not have a topic set. +* `next_batch`: integer - Indication for pagination. See above. + + +# Show details of a specific room report + +This API returns information about a specific room report. + +The api is: +``` +GET /_synapse/admin/v1/room_reports/ +``` + +It returns a JSON body like the following: + +```json +{ + "id": 2, + "received_ts": 1570897107409, + "room_id": "!ERAgBpSOcCCuTJqQPk:matrix.org", + "user_id": "@foo:matrix.org", + "reason": "This room contains spam", + "canonical_alias": "#alias1:matrix.org", + "name": "Matrix HQ", + "topic": "Discussions about Matrix" +} +``` + +**URL parameters:** + +* `report_id`: string - The ID of the room report. + +**Response** + +The following fields are returned in the JSON response body: + +* `id`: integer - ID of room report. +* `received_ts`: integer - The timestamp (in milliseconds since the unix epoch) when this + report was sent. +* `room_id`: string - The ID of the reported room. +* `name`: string - The name of the room. +* `user_id`: string - This is the user who reported the room. +* `reason`: string - Comment made by the `user_id` in this report. May be blank. +* `canonical_alias`: string - The canonical alias of the room. `null` if the room does not + have a canonical alias set. +* `topic`: string - The topic of the room. `null` if the room does not have a topic set. diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 0774b6ed405..d9fdffb9d1d 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -73,6 +73,10 @@ NewRegistrationTokenRestServlet, RegistrationTokenRestServlet, ) +from synapse.rest.admin.room_reports import ( + RoomReportDetailRestServlet, + RoomReportsRestServlet, +) from synapse.rest.admin.rooms import ( AdminRoomHierarchy, BlockRoomRestServlet, @@ -316,6 +320,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: LargestRoomsStatistics(hs).register(http_server) EventReportDetailRestServlet(hs).register(http_server) EventReportsRestServlet(hs).register(http_server) + RoomReportDetailRestServlet(hs).register(http_server) + RoomReportsRestServlet(hs).register(http_server) UserReportsRestServlet(hs).register(http_server) UserReportDetailRestServlet(hs).register(http_server) AccountDataRestServlet(hs).register(http_server) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py new file mode 100644 index 00000000000..91e9aad5aee --- /dev/null +++ b/synapse/rest/admin/room_reports.py @@ -0,0 +1,127 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING + +from synapse.api.errors import Codes, NotFoundError, SynapseError +from synapse.http.servlet import RestServlet, parse_integer, parse_string +from synapse.http.site import SynapseRequest +from synapse.rest.admin._base import admin_patterns, assert_requester_is_admin +from synapse.types import JsonDict + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class RoomReportsRestServlet(RestServlet): + """ + List all existing rooms that have been reported to the homeserver. Results are returned + in a dictionary containing report information. Supports pagination. Does not return results + for deleted/purged rooms. + The requester must have administrator access in Synapse. + + GET /_synapse/admin/v1/room_reports + returns: + 200 OK with list of reports if success otherwise an error. + + Args: + The parameters `from` and `limit` are required only for pagination. + By default, a `limit` of 100 is used, and the `from` parameter defaults to None, + indicating that the most recent report (largest report id) should be returned. + The `room_id` query parameter filters by room id. + The `user_id` query parameter filters by the user ID of the reporter of the room. + Returns: + A list of reported rooms filtered by the query parameters + """ + + PATTERNS = admin_patterns("/room_reports$") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self._store = hs.get_datastores().main + + async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: + await assert_requester_is_admin(self._auth, request) + + from_id = parse_integer(request, "from") + limit = parse_integer(request, "limit", default=100) + room_id = parse_string(request, "room_id") + user_id = parse_string(request, "user_id") + + if limit <= 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "The limit parameter must be a positive integer.", + errcode=Codes.INVALID_PARAM, + ) + + room_reports, limited = await self._store.get_room_reports_paginate( + from_id=from_id, limit=limit, user_id=user_id, room_id=room_id + ) + + ret = {} + + if limited: + ret["next_batch"] = room_reports[-1]["id"] + + ret.update({"room_reports": room_reports}) + + return HTTPStatus.OK, ret + + +class RoomReportDetailRestServlet(RestServlet): + """ + Get a specific reported room that is known to the homeserver. Results are returned + in a dictionary containing report information. + The requester must have administrator access in Synapse. + + GET /_synapse/admin/v1/room_reports/ + returns: + 200 OK with details report if success otherwise an error. + + Args: + The parameter `report_id` is the ID of the room report in the database. + Returns: + JSON blob of information about the room report + """ + + PATTERNS = admin_patterns("/room_reports/(?P[^/]*)$") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self._store = hs.get_datastores().main + + async def on_GET( + self, request: SynapseRequest, report_id: str + ) -> tuple[int, JsonDict]: + await assert_requester_is_admin(self._auth, request) + + message = "The report_id parameter must be a string representing a room report ID (positive integer)." + try: + resolved_report_id = int(report_id) + except ValueError: + raise SynapseError( + HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM + ) + + ret = await self._store.get_room_report(resolved_report_id) + if not ret: + raise NotFoundError("Room report not found") + + return HTTPStatus.OK, ret diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 95aa2cb7dcf..70cf432c143 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2014,6 +2014,57 @@ def get_un_partial_stated_rooms_from_stream_txn( get_un_partial_stated_rooms_from_stream_txn, ) + async def get_room_report(self, report_id: int) -> dict[str, Any] | None: + """Retrieve a room report + + Args: + report_id: ID of reported room in database + Returns: + JSON dict of information from an event report or None if the + report does not exist. + """ + + def _get_room_report_txn( + txn: LoggingTransaction, report_id: int + ) -> dict[str, Any] | None: + sql = """ + SELECT report.id, + report.received_ts, + report.room_id, + report.user_id, + report.reason, + room_stats_state.canonical_alias, + room_stats_state.name, + room_stats_state.topic + FROM room_reports AS report + INNER JOIN room_stats_state + ON room_stats_state.room_id = report.room_id + WHERE report.id = ? + """ + + txn.execute(sql, [report_id]) + row = txn.fetchone() + + if not row: + return None + + room_report = { + "id": row[0], + "received_ts": row[1], + "room_id": row[2], + "user_id": row[3], + "reason": row[4], + "canonical_alias": row[5], + "name": row[6], + "topic": row[7], + } + + return room_report + + return await self.db_pool.runInteraction( + "get_room_report", _get_room_report_txn, report_id + ) + async def get_event_report(self, report_id: int) -> dict[str, Any] | None: """Retrieve an event report @@ -2075,6 +2126,97 @@ def _get_event_report_txn( "get_event_report", _get_event_report_txn, report_id ) + async def get_room_reports_paginate( + self, + *, + from_id: int | None, + limit: int, + user_id: str | None = None, + room_id: str | None = None, + ) -> tuple[list[dict[str, Any]], bool]: + """Retrieve a paginated list of room reports + + Args: + from_id: the room report id to start from - if not provided the reports will + start at the most recent report (largest report id) and descend + limit: number of rows to retrieve + user_id: search for user_id. Ignored if user_id is None + room_id: filter reports against a specific room_id. Ignored if room_id is None + Returns: + Tuple of: + json list of room reports + a boolean indicating whether there are more reports available + """ + + def _get_room_reports_paginate_txn( + txn: LoggingTransaction, + ) -> tuple[list[dict[str, Any]], bool]: + filters = [] + args: list[str | int] = [] + + if user_id: + filters.append("rr.user_id = ?") + args.append(user_id) + if room_id: + filters.append("rr.room_id = ?") + args.append(room_id) + + if from_id is not None: + filters.append("rr.id < ?") + args.append(from_id) + + where_clause = "WHERE " + " AND ".join(filters) if filters else "" + + # By the nature of the `INNER JOIN`, this avoid reports from rooms that have + # been deleted/purged. This is useful as it removes duplicate/stale reports for + # rooms that have already been "actioned". + sql = f""" + SELECT + rr.id, + rr.received_ts, + rr.room_id, + rr.user_id, + rr.reason, + room_stats_state.canonical_alias, + room_stats_state.name, + room_stats_state.topic + FROM room_reports AS rr + INNER JOIN room_stats_state + ON room_stats_state.room_id = rr.room_id + {where_clause} + ORDER BY rr.id DESC + LIMIT ? + """ + + # fetch an extra row to determine if it exists for pagination + args.append(limit + 1) + txn.execute(sql, args) + + room_reports = [ + { + "id": row[0], + "received_ts": row[1], + "room_id": row[2], + "user_id": row[3], + "reason": row[4], + "canonical_alias": row[5], + "name": row[6], + "topic": row[7], + } + for row in txn + ] + + limited = len(room_reports) > limit + # trim the extra rooms if it exists + if limited: + room_reports = room_reports[:limit] + + return room_reports, limited + + return await self.db_pool.runInteraction( + "get_room_reports_paginate", _get_room_reports_paginate_txn + ) + async def get_event_reports_paginate( self, start: int, @@ -2517,6 +2659,20 @@ def __init__( self._background_populate_rooms_creator_column, ) + self.db_pool.updates.register_background_index_update( + update_name="room_reports_user_id_idx", + index_name="room_reports_user_id_idx", + table="room_reports", + columns=("user_id",), + ) + + self.db_pool.updates.register_background_index_update( + update_name="room_reports_room_id_idx", + index_name="room_reports_room_id_idx", + table="room_reports", + columns=("room_id",), + ) + async def _background_insert_retention( self, progress: JsonDict, batch_size: int ) -> int: diff --git a/synapse/storage/schema/main/delta/94/03_room_reports_user_id_idx.sql b/synapse/storage/schema/main/delta/94/03_room_reports_user_id_idx.sql new file mode 100644 index 00000000000..79fce629783 --- /dev/null +++ b/synapse/storage/schema/main/delta/94/03_room_reports_user_id_idx.sql @@ -0,0 +1,15 @@ +-- +-- This file is licensed under the Affero General Public License (AGPL) version 3. +-- +-- Copyright (C) 2026 Element Creations, Ltd +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- See the GNU Affero General Public License for more details: +-- . + +INSERT INTO background_updates (ordering, update_name, progress_json) VALUES + (9403, 'room_reports_user_id_idx', '{}'); diff --git a/synapse/storage/schema/main/delta/94/04_room_reports_room_id_idx.sql b/synapse/storage/schema/main/delta/94/04_room_reports_room_id_idx.sql new file mode 100644 index 00000000000..167519788db --- /dev/null +++ b/synapse/storage/schema/main/delta/94/04_room_reports_room_id_idx.sql @@ -0,0 +1,15 @@ +-- +-- This file is licensed under the Affero General Public License (AGPL) version 3. +-- +-- Copyright (C) 2026 Element Creations, Ltd +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- See the GNU Affero General Public License for more details: +-- . + +INSERT INTO background_updates (ordering, update_name, progress_json) VALUES + (9404, 'room_reports_room_id_idx', '{}'); diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py new file mode 100644 index 00000000000..5b5a3299d37 --- /dev/null +++ b/tests/rest/admin/test_room_reports.py @@ -0,0 +1,415 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +from twisted.internet.testing import MemoryReactor + +import synapse.rest.admin +from synapse.api.errors import Codes +from synapse.rest.client import login, reporting, room +from synapse.server import HomeServer +from synapse.types import JsonDict +from synapse.util.clock import Clock + +from tests import unittest + + +class RoomReportsTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + room.register_servlets, + reporting.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + 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_reports = [] + for _ in range(5): + room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + report_id = self._report_room(room_id, self.other_user_tok) + self.room_reports.append( + {"id": report_id, "room_id": room_id, "user_id": self.other_user} + ) + for _ in range(5, 10): + room_id = self.helper.create_room_as( + self.other_user, tok=self.other_user_tok + ) + report_id = self._report_room(room_id, self.admin_user_tok) + self.room_reports.append( + {"id": report_id, "room_id": room_id, "user_id": self.admin_user} + ) + + self.list_reports_url = "/_synapse/admin/v1/room_reports" + + def test_no_auth(self) -> None: + """ + Try to get a room report without authentication. + """ + channel = self.make_request("GET", self.list_reports_url, {}) + + self.assertEqual(401, channel.code, msg=channel.json_body) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self) -> None: + """ + If the user is not a server admin, an error 403 is returned. + """ + + channel = self.make_request( + "GET", + self.list_reports_url, + access_token=self.other_user_tok, + ) + + self.assertEqual(403, channel.code, msg=channel.json_body) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_default_success(self) -> None: + """ + Testing list of reported rooms + """ + + channel = self.make_request( + "GET", + self.list_reports_url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + # No pagination token because the page was big enough to hold all of the reports + self.assertNotIn("next_batch", channel.json_body) + # we reverse the list of room reports to check against as they are in chronological order + self._check_expected_room_report_fields( + channel.json_body["room_reports"], list(reversed(self.room_reports)) + ) + + def test_pagination(self) -> None: + """ + Test pagination of the returned room reports. + """ + # First page of results + channel = self.make_request( + "GET", + self.list_reports_url + "?limit=5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + first_page = channel.json_body["room_reports"] + self.assertEqual(len(first_page), 5) + self.assertIn("next_batch", channel.json_body) + # endpoint should return the 5 most recent reports in reverse chronological order + # we reverse the list of recorded room reports to check against as they are in chronological order + self._check_expected_room_report_fields( + first_page, list(reversed(self.room_reports[5:])) + ) + + # Request second page of results using next_batch token + next_batch = channel.json_body["next_batch"] + channel = self.make_request( + "GET", + self.list_reports_url + f"?limit=5&from={next_batch}", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + second_page = channel.json_body["room_reports"] + self.assertEqual(len(second_page), 5) + self.assertNotIn("next_batch", channel.json_body) + # endpoint should return the 5 oldest reports in reverse chronological order + # we reverse the list of recorded room reports to check against as they are in chronological order + self._check_expected_room_report_fields( + second_page, list(reversed(self.room_reports[:5])) + ) + + def test_filter_room(self) -> None: + """ + Testing list of reported rooms with a filter of room + """ + room_id = self.room_reports[3]["room_id"] + + channel = self.make_request( + "GET", + self.list_reports_url + f"?room_id={room_id}", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(len(channel.json_body["room_reports"]), 1) + self.assertNotIn("next_batch", channel.json_body) + self._check_expected_room_report_fields( + channel.json_body["room_reports"], [self.room_reports[3]] + ) + + def test_filter_user(self) -> None: + """ + Testing list of reported rooms with a filter of user + """ + + channel = self.make_request( + "GET", + self.list_reports_url + f"?user_id={self.other_user}", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(len(channel.json_body["room_reports"]), 5) + self.assertNotIn("next_batch", channel.json_body) + # the last 5 reports were made by the other user + # we reverse the list of room reports to check against as they are in chronological order + self._check_expected_room_report_fields( + channel.json_body["room_reports"], list(reversed(self.room_reports[:5])) + ) + + for report in channel.json_body["room_reports"]: + self.assertEqual(report["user_id"], self.other_user) + + def test_filter_user_and_room(self) -> None: + """ + Testing list of reported rooms with a filter of user and room + """ + + channel = self.make_request( + "GET", + self.list_reports_url + + f"?user_id={self.other_user}&room_id={self.room_reports[4]['room_id']}", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(len(channel.json_body["room_reports"]), 1) + self.assertNotIn("next_batch", channel.json_body) + self._check_expected_room_report_fields( + channel.json_body["room_reports"], [self.room_reports[4]] + ) + + def _report_room(self, room_id: str, user_tok: str) -> int: + """Report rooms""" + + channel = self.make_request( + "POST", + f"_matrix/client/v3/rooms/{room_id}/report", + {"reason": "this makes me sad"}, + access_token=user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + # there is no endpoint to get the id of a room report, so we fetch it from the db + (id,) = self.get_success( + self.hs.get_datastores().main.db_pool.simple_select_one( + table="room_reports", + keyvalues={"room_id": room_id}, + retcols=("id",), + allow_none=False, + ) + ) + return id + + def test_room_reports_for_deleted_rooms_are_not_returned(self) -> None: + """ + Tests that room reports for rooms that have been deleted are not returned. + """ + + # Delete rows from room_stats_state for one of our rooms. + self.get_success( + self.hs.get_datastores().main.db_pool.simple_delete( + "room_stats_state", + {"room_id": self.room_reports[1]["room_id"]}, + desc="_", + ) + ) + + channel = self.make_request( + "GET", + self.list_reports_url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(len(channel.json_body["room_reports"]), 9) + + def _check_expected_room_report_fields( + self, content: list[JsonDict], expected_content: list[JsonDict] + ) -> None: + """Checks that all attributes are present in a room report and expected values are found""" + for i, room_report in enumerate(content): + self.assertIn("id", room_report) + self.assertEqual(room_report["id"], expected_content[i]["id"]) + self.assertIn("received_ts", room_report) + self.assertIn("room_id", room_report) + self.assertEqual(room_report["room_id"], expected_content[i]["room_id"]) + self.assertIn("user_id", room_report) + self.assertEqual(room_report["user_id"], expected_content[i]["user_id"]) + self.assertIn("canonical_alias", room_report) + self.assertIn("name", room_report) + self.assertIn("reason", room_report) + self.assertIn("topic", room_report) + + +class RoomReportDetailTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + room.register_servlets, + reporting.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + 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_id1 = self.helper.create_room_as( + self.other_user, tok=self.other_user_tok, is_public=True + ) + + id = self._report_room(self.room_id1, self.admin_user_tok) + self.room_reports = [ + {"id": id, "room_id": self.room_id1, "user_id": self.admin_user} + ] + + self.fetch_report_url = f"/_synapse/admin/v1/room_reports/{id}" + + def test_no_auth(self) -> None: + """ + Try to get room report without authentication. + """ + channel = self.make_request("GET", self.fetch_report_url, {}) + + self.assertEqual(401, channel.code, msg=channel.json_body) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_no_admin(self) -> None: + """ + If the user is not a server admin, an error 403 is returned. + """ + + channel = self.make_request( + "GET", + self.fetch_report_url, + access_token=self.other_user_tok, + ) + + self.assertEqual(403, channel.code, msg=channel.json_body) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_default_success(self) -> None: + """ + Testing get a reported room + """ + + channel = self.make_request( + "GET", + self.fetch_report_url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self._check_expected_room_report_fields([channel.json_body], self.room_reports) + + def test_invalid_report_id(self) -> None: + """ + Testing that an invalid `report_id` returns a 400. + """ + + # `report_id` is invalid (should be a numerical report ID) + channel = self.make_request( + "GET", + "/_synapse/admin/v1/room_reports/abcdef", + access_token=self.admin_user_tok, + ) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) + self.assertEqual( + "The report_id parameter must be a string representing a room report ID (positive integer).", + channel.json_body["error"], + ) + + # `report_id` is undefined + channel = self.make_request( + "GET", + "/_synapse/admin/v1/room_reports/", + access_token=self.admin_user_tok, + ) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) + self.assertEqual( + "The report_id parameter must be a string representing a room report ID (positive integer).", + channel.json_body["error"], + ) + + def test_report_id_not_found(self) -> None: + """ + Testing that a not existing `report_id` returns a 404. + """ + + channel = self.make_request( + "GET", + "/_synapse/admin/v1/room_reports/123", + access_token=self.admin_user_tok, + ) + + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + self.assertEqual("Room report not found", channel.json_body["error"]) + + def _check_expected_room_report_fields( + self, content: list[JsonDict], expected_content: list[JsonDict] + ) -> None: + """Checks that all attributes are present in a room report and expected values are found""" + for i, room_report in enumerate(content): + self.assertIn("id", room_report) + self.assertEqual(room_report["id"], expected_content[i]["id"]) + self.assertIn("received_ts", room_report) + self.assertIn("room_id", room_report) + self.assertEqual(room_report["room_id"], expected_content[i]["room_id"]) + self.assertIn("user_id", room_report) + self.assertEqual(room_report["user_id"], expected_content[i]["user_id"]) + self.assertIn("canonical_alias", room_report) + self.assertIn("name", room_report) + self.assertIn("reason", room_report) + self.assertIn("topic", room_report) + + def _report_room(self, room_id: str, user_tok: str) -> int: + """Report rooms""" + + channel = self.make_request( + "POST", + f"_matrix/client/v3/rooms/{room_id}/report", + {"reason": "this makes me sad"}, + access_token=user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + # there is no endpoint to get the id of a room report, so we fetch it from the db + (id,) = self.get_success( + self.hs.get_datastores().main.db_pool.simple_select_one( + table="room_reports", + keyvalues={"room_id": room_id}, + retcols=("id",), + allow_none=False, + ) + ) + return id