From 558d08fc2399859732ab1ebe64720db768dec66b Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 3 Apr 2026 15:22:25 -0700 Subject: [PATCH 01/23] add database calls to fetch/delete room reports --- synapse/storage/databases/main/room.py | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 7ac88e4c2aa..a04d613f302 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -1679,6 +1679,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 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 + JOIN room_stats_state + ON room_stats_state.room_id = rr.room_id + WHERE rr.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 @@ -1740,6 +1791,105 @@ def _get_event_report_txn( "get_event_report", _get_event_report_txn, report_id ) + async def get_room_reports_paginate( + self, + start: int, + limit: int, + direction: Direction = Direction.BACKWARDS, + user_id: str | None = None, + room_id: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: + """Retrieve a paginated list of room reports + + Args: + start: event offset to begin the query from + limit: number of rows to retrieve + direction: Whether to fetch the most recent first (backwards) or the + oldest first (forwards) + 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 + total number of room reports matching the filter criteria + """ + + def _get_room_reports_paginate_txn( + txn: LoggingTransaction, + ) -> tuple[list[dict[str, Any]], int]: + filters = [] + args: list[object] = [] + + if user_id: + filters.append("rr.user_id LIKE ?") + args.extend(["%" + user_id + "%"]) + if room_id: + filters.append("rr.room_id LIKE ?") + args.extend(["%" + room_id + "%"]) + + if direction == Direction.BACKWARDS: + order = "DESC" + else: + order = "ASC" + + where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" + + # Don't count reports against rooms which have been deleted/purged + sql = """ + SELECT COUNT(*) as total_room_reports + FROM room_reports AS rr + JOIN room_stats_state ON room_stats_state.room_id = rr.room_id + {} + """.format(where_clause) + txn.execute(sql, args) + count = cast(tuple[int], txn.fetchone())[0] + + sql = """ + 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 + JOIN room_stats_state + ON room_stats_state.room_id = rr.room_id + {where_clause} + ORDER BY rr.received_ts {order} + LIMIT ? + OFFSET ? + """.format( + where_clause=where_clause, + order=order, + ) + + args += [limit, start] + txn.execute(sql, args) + + room_reports = [] + for row in txn: + room_reports.append( + { + "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_reports, count + + return await self.db_pool.runInteraction( + "get_room_reports_paginate", _get_room_reports_paginate_txn + ) + async def get_event_reports_paginate( self, start: int, @@ -1861,6 +2011,27 @@ def _get_event_reports_paginate_txn( "get_event_reports_paginate", _get_event_reports_paginate_txn ) + async def delete_room_report(self, report_id: int) -> bool: + """Remove a room report from database. + + Args: + report_id: Report to delete + + Returns: + Whether the report was successfully deleted or not. + """ + try: + await self.db_pool.simple_delete_one( + table="room_reports", + keyvalues={"id": report_id}, + desc="delete_room_report", + ) + except StoreError: + # Deletion failed because report does not exist + return False + + return True + async def delete_event_report(self, report_id: int) -> bool: """Remove an event report from database. From 89875345f0e35c69003b6d688a5e9553e31bea6f Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 3 Apr 2026 15:23:07 -0700 Subject: [PATCH 02/23] add admin endpoints to fetch/delete room reports --- synapse/rest/admin/__init__.py | 6 + synapse/rest/admin/room_reports.py | 170 +++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 synapse/rest/admin/room_reports.py diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index b209404cd18..090f216cd7f 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, @@ -312,6 +316,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) AccountDataRestServlet(hs).register(http_server) PushersRestServlet(hs).register(http_server) MakeRoomAdminRestServlet(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..12251360b8e --- /dev/null +++ b/synapse/rest/admin/room_reports.py @@ -0,0 +1,170 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 New Vector, 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: +# . +# +# Originally licensed under the Apache License, Version 2.0: +# . +# +# [This file includes modifications made by New Vector Limited] +# +# + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING + +from synapse.api.constants import Direction +from synapse.api.errors import Codes, NotFoundError, SynapseError +from synapse.http.servlet import RestServlet, parse_enum, 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. + The parameter `dir` can be used to define the order of results. + 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 and an integer representing the total number of + reported rooms that exist given this query + """ + + 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) + + start = parse_integer(request, "from", default=0) + limit = parse_integer(request, "limit", default=100) + direction = parse_enum(request, "dir", Direction, Direction.BACKWARDS) + room_id = parse_string(request, "room_id") + user_id = parse_string(request, "user_id") + + if start < 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "The start parameter must be a positive integer.", + errcode=Codes.INVALID_PARAM, + ) + + if limit < 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "The limit parameter must be a positive integer.", + errcode=Codes.INVALID_PARAM, + ) + + room_reports, total = await self._store.get_room_reports_paginate( + start, limit, direction, user_id, room_id + ) + ret = {"room_reports": room_reports, "total": total} + if (start + limit) < total: + ret["next_token"] = start + len(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 positive integer." + ) + try: + resolved_report_id = int(report_id) + except ValueError: + raise SynapseError( + HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM + ) + + if resolved_report_id < 0: + 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 + + async def on_DELETE( + 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 positive integer." + ) + try: + resolved_report_id = int(report_id) + except ValueError: + raise SynapseError( + HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM + ) + + if resolved_report_id < 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM + ) + + if await self._store.delete_room_report(resolved_report_id): + return HTTPStatus.OK, {} + + raise NotFoundError("Room report not found") From 2634ed3b4868c4bd49fdb35f7fd64806f6e648e0 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 3 Apr 2026 15:23:10 -0700 Subject: [PATCH 03/23] tests --- tests/rest/admin/test_room_reports.py | 702 ++++++++++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 tests/rest/admin/test_room_reports.py diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py new file mode 100644 index 00000000000..2e69002ea9c --- /dev/null +++ b/tests/rest/admin/test_room_reports.py @@ -0,0 +1,702 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 New Vector, 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: +# . +# +# Originally licensed under the Apache License, Version 2.0: +# . +# +# [This file includes modifications made by New Vector Limited] +# +# + +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_ids = {} + for i in range(10): + if i <= 4: + room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + self.room_ids[i] = room_id + else: + room_id = self.helper.create_room_as( + self.other_user, tok=self.other_user_tok + ) + self.room_ids[i] = room_id + + for room_num, room_id in self.room_ids.items(): + if room_num <= 4: + self._report_room(room_id, self.other_user_tok) + else: + self._report_room(room_id, self.admin_user_tok) + + self.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.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.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.url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["room_reports"]) + + def test_limit(self) -> None: + """ + Testing list of reported rooms with limit + """ + + channel = self.make_request( + "GET", + self.url + "?limit=5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 5) + self.assertEqual(channel.json_body["next_token"], 5) + self._check_fields(channel.json_body["room_reports"]) + + def test_from(self) -> None: + """ + Testing list of reported rooms with a defined starting point (from) + """ + + channel = self.make_request( + "GET", + self.url + "?from=5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 5) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["room_reports"]) + + def test_limit_and_from(self) -> None: + """ + Testing list of reported rooms with a defined starting point and limit + """ + + channel = self.make_request( + "GET", + self.url + "?from=5&limit=3", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(channel.json_body["next_token"], 8) + self.assertEqual(len(channel.json_body["room_reports"]), 3) + self._check_fields(channel.json_body["room_reports"]) + + def test_filter_room(self) -> None: + """ + Testing list of reported rooms with a filter of room + """ + + channel = self.make_request( + "GET", + self.url + "?room_id=%s" % self.room_ids[1], + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 1) + self.assertEqual(len(channel.json_body["room_reports"]), 1) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["room_reports"]) + self.assertEqual( + channel.json_body["room_reports"][0]["room_id"], self.room_ids[1] + ) + + def test_filter_user(self) -> None: + """ + Testing list of reported rooms with a filter of user + """ + + channel = self.make_request( + "GET", + self.url + "?user_id=%s" % self.other_user, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 5) + self.assertEqual(len(channel.json_body["room_reports"]), 5) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["room_reports"]) + + 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.url + "?user_id=%s&room_id=%s" % (self.other_user, self.room_ids[4]), + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 1) + self.assertEqual(len(channel.json_body["room_reports"]), 1) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["room_reports"]) + + self.assertEqual( + channel.json_body["room_reports"][0]["user_id"], self.other_user + ) + self.assertEqual( + channel.json_body["room_reports"][0]["room_id"], self.room_ids[4] + ) + + def test_valid_search_order(self) -> None: + """ + Testing search order. Order by timestamps. + """ + + # fetch the most recent first, largest timestamp + channel = self.make_request( + "GET", + self.url + "?dir=b", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + report = 1 + while report < len(channel.json_body["room_reports"]): + self.assertGreaterEqual( + channel.json_body["room_reports"][report - 1]["received_ts"], + channel.json_body["room_reports"][report]["received_ts"], + ) + report += 1 + + # fetch the oldest first, smallest timestamp + channel = self.make_request( + "GET", + self.url + "?dir=f", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + report = 1 + while report < len(channel.json_body["room_reports"]): + self.assertLessEqual( + channel.json_body["room_reports"][report - 1]["received_ts"], + channel.json_body["room_reports"][report]["received_ts"], + ) + report += 1 + + def test_invalid_search_order(self) -> None: + """ + Testing that a invalid search order returns a 400 + """ + + channel = self.make_request( + "GET", + self.url + "?dir=bar", + 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( + "Query parameter 'dir' must be one of ['b', 'f']", + channel.json_body["error"], + ) + + def test_limit_is_negative(self) -> None: + """ + Testing that a negative limit parameter returns a 400 + """ + + channel = self.make_request( + "GET", + self.url + "?limit=-5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) + + def test_from_is_negative(self) -> None: + """ + Testing that a negative from parameter returns a 400 + """ + + channel = self.make_request( + "GET", + self.url + "?from=-5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(400, channel.code, msg=channel.json_body) + self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) + + def test_next_token(self) -> None: + """ + Testing that `next_token` appears at the right place + """ + + # `next_token` does not appear + # Number of results is the number of entries + channel = self.make_request( + "GET", + self.url + "?limit=10", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + self.assertNotIn("next_token", channel.json_body) + + # `next_token` does not appear + # Number of max results is larger than the number of entries + channel = self.make_request( + "GET", + self.url + "?limit=11", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 10) + self.assertNotIn("next_token", channel.json_body) + + # `next_token` does appear + # Number of max results is smaller than the number of entries + channel = self.make_request( + "GET", + self.url + "?limit=9", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 9) + self.assertEqual(channel.json_body["next_token"], 9) + + # Check + # Set `from` to value of `next_token` for request remaining entries + # `next_token` does not appear + channel = self.make_request( + "GET", + self.url + "?from=9", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 10) + self.assertEqual(len(channel.json_body["room_reports"]), 1) + self.assertNotIn("next_token", channel.json_body) + + def _report_room(self, room_id: str, user_tok: str) -> None: + """Report rooms""" + + channel = self.make_request( + "POST", + "_matrix/client/v3/rooms/%s/report" % (room_id), + {"reason": "this makes me sad"}, + access_token=user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + def _check_fields(self, content: list[JsonDict]) -> None: + """Checks that all attributes are present in a room report""" + for c in content: + self.assertIn("id", c) + self.assertIn("received_ts", c) + self.assertIn("room_id", c) + self.assertIn("user_id", c) + self.assertIn("canonical_alias", c) + self.assertIn("name", c) + self.assertIn("reason", c) + self.assertIn("topic", c) + + def test_count_correct_despite_table_deletions(self) -> None: + """ + Tests that the count matches the number of rows, even if rows in joined tables + are missing. + """ + + # 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_ids[1]}, desc="_" + ) + ) + + channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + # The 'total' field is 9 because only 9 reports will actually + # be retrievable since we deleted the row in the room_stats_state + # table. + self.assertEqual(channel.json_body["total"], 9) + # This is consistent with the number of rows actually returned. + self.assertEqual(len(channel.json_body["room_reports"]), 9) + + +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 + ) + + self._report_room(self.room_id1, self.admin_user_tok) + + # first created room report gets `id`=2 + self.url = "/_synapse/admin/v1/room_reports/2" + + def test_no_auth(self) -> None: + """ + Try to get room report without authentication. + """ + channel = self.make_request("GET", self.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.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.url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self._check_fields(channel.json_body) + + def test_invalid_report_id(self) -> None: + """ + Testing that an invalid `report_id` returns a 400. + """ + + # `report_id` is negative + channel = self.make_request( + "GET", + "/_synapse/admin/v1/room_reports/-123", + 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 positive integer.", + channel.json_body["error"], + ) + + # `report_id` is a non-numerical string + 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 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 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_fields(self, content: JsonDict) -> None: + """Checks that all attributes are present in a room report""" + self.assertIn("id", content) + self.assertIn("received_ts", content) + self.assertIn("room_id", content) + self.assertIn("user_id", content) + self.assertIn("canonical_alias", content) + self.assertIn("name", content) + self.assertIn("topic", content) + self.assertIn("reason", content) + + def _report_room(self, room_id: str, user_tok: str) -> None: + """Report rooms""" + + channel = self.make_request( + "POST", + "_matrix/client/v3/rooms/%s/report" % (room_id), + {"reason": "this makes me sad"}, + access_token=user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + +class DeleteRoomReportTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self._store = hs.get_datastores().main + + 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") + + # create report + room_report_id = self.get_success( + self._store.add_room_report( + "room_id", + self.other_user, + "this makes me sad", + self.clock.time_msec(), + ) + ) + + self.url = f"/_synapse/admin/v1/room_reports/{room_report_id}" + + def test_no_auth(self) -> None: + """ + Try to delete a room report without authentication. + """ + channel = self.make_request("DELETE", self.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( + "DELETE", + self.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_delete_success(self) -> None: + """ + Testing delete a report. + """ + + channel = self.make_request( + "DELETE", + self.url, + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual({}, channel.json_body) + + channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + + # check that report was deleted + self.assertEqual(404, channel.code, msg=channel.json_body) + self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) + + def test_invalid_report_id(self) -> None: + """ + Testing that an invalid `report_id` returns a 400. + """ + + # `report_id` is negative + channel = self.make_request( + "DELETE", + "/_synapse/admin/v1/room_reports/-123", + 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 positive integer.", + channel.json_body["error"], + ) + + # `report_id` is a non-numerical string + channel = self.make_request( + "DELETE", + "/_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 positive integer.", + channel.json_body["error"], + ) + + # `report_id` is undefined + channel = self.make_request( + "DELETE", + "/_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 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( + "DELETE", + "/_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"]) From 28c35fbe1e0f6bdb9876c31db5b8bc7eae350268 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 3 Apr 2026 15:31:54 -0700 Subject: [PATCH 04/23] newsfragment --- changelog.d/19648.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19648.feature diff --git a/changelog.d/19648.feature b/changelog.d/19648.feature new file mode 100644 index 00000000000..4c8c842097d --- /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, fetch and delete room reports. \ No newline at end of file From d5742d58ac3367a7e0213313b6689962479947c4 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 14 Apr 2026 11:44:33 -0700 Subject: [PATCH 05/23] small fixes --- synapse/rest/admin/room_reports.py | 9 ++------- synapse/storage/databases/main/room.py | 13 +++++-------- tests/rest/admin/test_room_reports.py | 9 ++------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index 12251360b8e..255834e505a 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -1,7 +1,7 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2026 New Vector, Ltd +# 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 @@ -11,12 +11,7 @@ # See the GNU Affero General Public License for more details: # . # -# Originally licensed under the Apache License, Version 2.0: -# . -# -# [This file includes modifications made by New Vector Limited] -# -# + import logging from http import HTTPStatus diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index a04d613f302..6b002771c30 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -1835,16 +1835,16 @@ def _get_room_reports_paginate_txn( where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" # Don't count reports against rooms which have been deleted/purged - sql = """ + sql = f""" SELECT COUNT(*) as total_room_reports FROM room_reports AS rr JOIN room_stats_state ON room_stats_state.room_id = rr.room_id - {} - """.format(where_clause) + {where_clause} + """ txn.execute(sql, args) count = cast(tuple[int], txn.fetchone())[0] - sql = """ + sql = f""" SELECT rr.id, rr.received_ts, @@ -1861,10 +1861,7 @@ def _get_room_reports_paginate_txn( ORDER BY rr.received_ts {order} LIMIT ? OFFSET ? - """.format( - where_clause=where_clause, - order=order, - ) + """ args += [limit, start] txn.execute(sql, args) diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 2e69002ea9c..94bfb499f10 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -1,7 +1,7 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2026 New Vector, Ltd +# 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 @@ -11,12 +11,7 @@ # See the GNU Affero General Public License for more details: # . # -# Originally licensed under the Apache License, Version 2.0: -# . -# -# [This file includes modifications made by New Vector Limited] -# -# + from twisted.internet.testing import MemoryReactor From eaea03f8d3b93ab2f70eb66ddcb6a2a21edafc82 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 18 May 2026 10:59:34 -0700 Subject: [PATCH 06/23] add documentation for endpoint --- docs/SUMMARY.md | 1 + docs/admin_api/room_reports.md | 139 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/admin_api/room_reports.md 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..77418cdf45f --- /dev/null +++ b/docs/admin_api/room_reports.md @@ -0,0 +1,139 @@ +# 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_token": 2, + "total": 4 +} +``` + +Note: Reports for deleted or purged rooms are not returned. + +To paginate, check for `next_token` and if present, call the endpoint again with `from` +set to the value of `next_token`. This will return a new page. + +If the endpoint does not return a `next_token` 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`: positive integer - Optional. Used for pagination, denoting the offset in the + returned results. This should be treated as an opaque value and not explicitly set to + anything other than the return value of `next_token` from a previous call. Defaults to `0`. +* `dir`: string - Optional. Direction of room report order. Whether to fetch the most recent + first (`b`) or the oldest first (`f`). Defaults to `b`. +* `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_token`: integer - Indication for pagination. See above. +* `total`: integer - Total number of room reports related to the query + (`user_id` and `room_id`). + +# 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. + +# Delete a specific room report + +This API deletes a specific room report. If the request is successful, the response body +will be an empty JSON object. + +The api is: +``` +DELETE /_synapse/admin/v1/room_reports/ +``` + +**URL parameters:** + +* `report_id`: string - The ID of the room report to delete. \ No newline at end of file From 1d1ca6681828e5da9ec98962c7a42cc9afcbfe13 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 26 May 2026 13:27:22 -0700 Subject: [PATCH 07/23] use `next_batch` --- docs/admin_api/room_reports.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md index 77418cdf45f..5446e9e0257 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -36,17 +36,17 @@ It returns a JSON body like the following: "topic": null } ], - "next_token": 2, + "next_batch": 2, "total": 4 } ``` Note: Reports for deleted or purged rooms are not returned. -To paginate, check for `next_token` and if present, call the endpoint again with `from` -set to the value of `next_token`. This will return a new page. +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_token` then there are no more reports to +If the endpoint does not return a `next_batch` then there are no more reports to paginate through. **URL query parameters:** @@ -77,7 +77,7 @@ The following fields are returned in the JSON response body: 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_token`: integer - Indication for pagination. See above. +* `next_batch`: integer - Indication for pagination. See above. * `total`: integer - Total number of room reports related to the query (`user_id` and `room_id`). From 58e2c0496e363214b75fe478602354c5777e8680 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 26 May 2026 13:36:51 -0700 Subject: [PATCH 08/23] don't check for ids less than 0 --- synapse/rest/admin/room_reports.py | 5 ----- tests/rest/admin/test_room_reports.py | 14 -------------- 2 files changed, 19 deletions(-) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index 255834e505a..be27c86a55a 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -128,11 +128,6 @@ async def on_GET( HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM ) - if resolved_report_id < 0: - 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") diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 94bfb499f10..81396b62411 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -479,20 +479,6 @@ def test_invalid_report_id(self) -> None: Testing that an invalid `report_id` returns a 400. """ - # `report_id` is negative - channel = self.make_request( - "GET", - "/_synapse/admin/v1/room_reports/-123", - 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 positive integer.", - channel.json_body["error"], - ) - # `report_id` is a non-numerical string channel = self.make_request( "GET", From 38debcfe47760559fa9f91b1b539773feb59bb81 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 26 May 2026 13:42:19 -0700 Subject: [PATCH 09/23] align pagination with spec and don't use offset --- docs/admin_api/room_reports.md | 10 ++-- synapse/rest/admin/room_reports.py | 25 +++++---- synapse/storage/databases/main/room.py | 72 ++++++++++++-------------- 3 files changed, 53 insertions(+), 54 deletions(-) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md index 5446e9e0257..fda61f8f54b 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -41,7 +41,8 @@ It returns a JSON body like the following: } ``` -Note: Reports for deleted or purged rooms are not returned. +Note: Reports for deleted or purged rooms are not returned. The endpoint returns reports in descending +chronological order. 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. @@ -53,11 +54,8 @@ paginate through. * `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`: positive integer - Optional. Used for pagination, denoting the offset in the - returned results. This should be treated as an opaque value and not explicitly set to - anything other than the return value of `next_token` from a previous call. Defaults to `0`. -* `dir`: string - Optional. Direction of room report order. Whether to fetch the most recent - first (`b`) or the oldest first (`f`). Defaults to `b`. +* `from`: positive integer - Optional. Used for pagination, denoting the unix ms timestamp to return results + from in descending order. Defaults to the current time. * `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. diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index be27c86a55a..bcf66c787c1 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -17,9 +17,8 @@ from http import HTTPStatus from typing import TYPE_CHECKING -from synapse.api.constants import Direction from synapse.api.errors import Codes, NotFoundError, SynapseError -from synapse.http.servlet import RestServlet, parse_enum, parse_integer, parse_string +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 @@ -43,8 +42,7 @@ class RoomReportsRestServlet(RestServlet): Args: The parameters `from` and `limit` are required only for pagination. - By default, a `limit` of 100 is used. - The parameter `dir` can be used to define the order of results. + By default, a `limit` of 100 is used, and the `from` parameter defaults to the current time. 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: @@ -57,13 +55,14 @@ class RoomReportsRestServlet(RestServlet): def __init__(self, hs: "HomeServer"): self._auth = hs.get_auth() self._store = hs.get_datastores().main + self._clock = hs.get_clock() async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: await assert_requester_is_admin(self._auth, request) - start = parse_integer(request, "from", default=0) + now = int(self._clock.time_msec()) + 1 + start = parse_integer(request, "from", default=now) limit = parse_integer(request, "limit", default=100) - direction = parse_enum(request, "dir", Direction, Direction.BACKWARDS) room_id = parse_string(request, "room_id") user_id = parse_string(request, "user_id") @@ -82,11 +81,17 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: ) room_reports, total = await self._store.get_room_reports_paginate( - start, limit, direction, user_id, room_id + start, limit, user_id, room_id ) - ret = {"room_reports": room_reports, "total": total} - if (start + limit) < total: - ret["next_token"] = start + len(room_reports) + + ret = {} + has_more = len(room_reports) > limit + # trim the extra room if it exists + room_reports = room_reports[:limit] + if has_more: + ret["next_batch"] = room_reports[-1]["received_ts"] + + ret.update({"room_reports": room_reports, "total": total}) return HTTPStatus.OK, ret diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 6048ddf61f9..6d0d37fbd4d 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2028,18 +2028,18 @@ def _get_room_report_txn( txn: LoggingTransaction, report_id: int ) -> dict[str, Any] | None: sql = """ - SELECT rr.id, \ - rr.received_ts, \ - rr.room_id, \ - rr.user_id, \ - rr.reason, \ + 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 rr - JOIN room_stats_state - ON room_stats_state.room_id = rr.room_id - WHERE rr.id = ? \ + 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]) @@ -2130,17 +2130,14 @@ async def get_room_reports_paginate( self, start: int, limit: int, - direction: Direction = Direction.BACKWARDS, user_id: str | None = None, room_id: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """Retrieve a paginated list of room reports Args: - start: event offset to begin the query from + start: timestamp to start from limit: number of rows to retrieve - direction: Whether to fetch the most recent first (backwards) or the - oldest first (forwards) 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: @@ -2162,23 +2159,23 @@ def _get_room_reports_paginate_txn( filters.append("rr.room_id LIKE ?") args.extend(["%" + room_id + "%"]) - if direction == Direction.BACKWARDS: - order = "DESC" - else: - order = "ASC" - where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" - # Don't count reports against rooms which have been deleted/purged + # Don't count reports against rooms which have been deleted/purged. sql = f""" SELECT COUNT(*) as total_room_reports FROM room_reports AS rr - JOIN room_stats_state ON room_stats_state.room_id = rr.room_id + INNER JOIN room_stats_state ON room_stats_state.room_id = rr.room_id {where_clause} """ txn.execute(sql, args) count = cast(tuple[int], txn.fetchone())[0] + filters.append("rr.received_ts < ?") + args.append(start) + + where_clause = "WHERE " + " AND ".join(filters) + sql = f""" SELECT rr.id, @@ -2190,31 +2187,30 @@ def _get_room_reports_paginate_txn( room_stats_state.name, room_stats_state.topic FROM room_reports AS rr - JOIN room_stats_state + INNER JOIN room_stats_state ON room_stats_state.room_id = rr.room_id {where_clause} - ORDER BY rr.received_ts {order} + ORDER BY rr.received_ts DESC LIMIT ? - OFFSET ? """ - args += [limit, start] + # fetch an extra row to determine if it exists for pagination + args.append(limit + 1) txn.execute(sql, args) - room_reports = [] - for row in txn: - room_reports.append( - { - "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], - } - ) + 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 + ] return room_reports, count From 80e4ceb2be5a9c35be3d287bdb9111f5acb373a0 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 26 May 2026 13:42:54 -0700 Subject: [PATCH 10/23] add indexes on `room_reports` for columns we filter on --- synapse/storage/databases/main/room.py | 14 ++++++++++++++ .../main/delta/94/03_room_reports_user_id_idx.sql | 15 +++++++++++++++ .../main/delta/94/04_room_reports_room_id_idx.sql | 15 +++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 synapse/storage/schema/main/delta/94/03_room_reports_user_id_idx.sql create mode 100644 synapse/storage/schema/main/delta/94/04_room_reports_room_id_idx.sql diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 6d0d37fbd4d..2c36cccbb1f 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2681,6 +2681,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', '{}'); From 0f1c2ef5a91f40d3f027fb1cb06ece647a923c0d Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 26 May 2026 13:42:59 -0700 Subject: [PATCH 11/23] update tests --- tests/rest/admin/test_room_reports.py | 150 ++++++++++---------------- 1 file changed, 59 insertions(+), 91 deletions(-) diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 81396b62411..419171b4f08 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -41,17 +41,16 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.other_user_tok = self.login("user", "pass") self.room_ids = {} - for i in range(10): - if i <= 4: - room_id = self.helper.create_room_as( - self.admin_user, tok=self.admin_user_tok - ) - self.room_ids[i] = room_id - else: - room_id = self.helper.create_room_as( - self.other_user, tok=self.other_user_tok - ) - self.room_ids[i] = room_id + for i in range(4): + room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + self.room_ids[i] = room_id + for i in range(4, 10): + room_id = self.helper.create_room_as( + self.other_user, tok=self.other_user_tok + ) + self.room_ids[i] = room_id for room_num, room_id in self.room_ids.items(): if room_num <= 4: @@ -98,13 +97,20 @@ def test_default_success(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) def test_limit(self) -> None: """ Testing list of reported rooms with limit """ + # grab the 5th report to get the timestamp + channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + report_5 = channel.json_body["room_reports"][4] channel = self.make_request( "GET", @@ -115,41 +121,59 @@ def test_limit(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 5) - self.assertEqual(channel.json_body["next_token"], 5) + self.assertEqual(channel.json_body["next_batch"], report_5["received_ts"]) self._check_fields(channel.json_body["room_reports"]) def test_from(self) -> None: """ Testing list of reported rooms with a defined starting point (from) """ + # grab the fifth report to get the timestamp + channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + report_5 = channel.json_body["room_reports"][4] + from_ts = report_5["received_ts"] channel = self.make_request( "GET", - self.url + "?from=5", + self.url + f"?from={from_ts}", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 5) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) def test_limit_and_from(self) -> None: """ Testing list of reported rooms with a defined starting point and limit """ + # grab the second most recent report to get the timestamp + channel = self.make_request( + "GET", + self.url, + access_token=self.admin_user_tok, + ) + report_2 = channel.json_body["room_reports"][1] + from_ts = report_2["received_ts"] + + report_5 = channel.json_body["room_reports"][4] channel = self.make_request( "GET", - self.url + "?from=5&limit=3", + self.url + f"?from={from_ts}&limit=3", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) - self.assertEqual(channel.json_body["next_token"], 8) self.assertEqual(len(channel.json_body["room_reports"]), 3) + self.assertEqual(channel.json_body["next_batch"], report_5["received_ts"]) self._check_fields(channel.json_body["room_reports"]) def test_filter_room(self) -> None: @@ -166,7 +190,7 @@ def test_filter_room(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 1) self.assertEqual(len(channel.json_body["room_reports"]), 1) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) self.assertEqual( channel.json_body["room_reports"][0]["room_id"], self.room_ids[1] @@ -186,7 +210,7 @@ def test_filter_user(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 5) self.assertEqual(len(channel.json_body["room_reports"]), 5) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) for report in channel.json_body["room_reports"]: @@ -206,7 +230,7 @@ def test_filter_user_and_room(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 1) self.assertEqual(len(channel.json_body["room_reports"]), 1) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) self.assertEqual( @@ -216,65 +240,6 @@ def test_filter_user_and_room(self) -> None: channel.json_body["room_reports"][0]["room_id"], self.room_ids[4] ) - def test_valid_search_order(self) -> None: - """ - Testing search order. Order by timestamps. - """ - - # fetch the most recent first, largest timestamp - channel = self.make_request( - "GET", - self.url + "?dir=b", - access_token=self.admin_user_tok, - ) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) - self.assertEqual(len(channel.json_body["room_reports"]), 10) - report = 1 - while report < len(channel.json_body["room_reports"]): - self.assertGreaterEqual( - channel.json_body["room_reports"][report - 1]["received_ts"], - channel.json_body["room_reports"][report]["received_ts"], - ) - report += 1 - - # fetch the oldest first, smallest timestamp - channel = self.make_request( - "GET", - self.url + "?dir=f", - access_token=self.admin_user_tok, - ) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) - self.assertEqual(len(channel.json_body["room_reports"]), 10) - report = 1 - while report < len(channel.json_body["room_reports"]): - self.assertLessEqual( - channel.json_body["room_reports"][report - 1]["received_ts"], - channel.json_body["room_reports"][report]["received_ts"], - ) - report += 1 - - def test_invalid_search_order(self) -> None: - """ - Testing that a invalid search order returns a 400 - """ - - channel = self.make_request( - "GET", - self.url + "?dir=bar", - 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( - "Query parameter 'dir' must be one of ['b', 'f']", - channel.json_body["error"], - ) - def test_limit_is_negative(self) -> None: """ Testing that a negative limit parameter returns a 400 @@ -303,12 +268,12 @@ def test_from_is_negative(self) -> None: self.assertEqual(400, channel.code, msg=channel.json_body) self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) - def test_next_token(self) -> None: + def test_next_batch(self) -> None: """ - Testing that `next_token` appears at the right place + Testing that `next_batch` appears at the right place """ - # `next_token` does not appear + # `next_batch` does not appear # Number of results is the number of entries channel = self.make_request( "GET", @@ -319,9 +284,12 @@ def test_next_token(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) + + # fetch ts of 2nd oldest report + report_9 = channel.json_body["room_reports"][8] - # `next_token` does not appear + # `next_batch` does not appear # Number of max results is larger than the number of entries channel = self.make_request( "GET", @@ -332,9 +300,9 @@ def test_next_token(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) - # `next_token` does appear + # `next_batch` does appear # Number of max results is smaller than the number of entries channel = self.make_request( "GET", @@ -345,21 +313,21 @@ def test_next_token(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 9) - self.assertEqual(channel.json_body["next_token"], 9) + self.assertEqual(channel.json_body["next_batch"], report_9["received_ts"]) # Check - # Set `from` to value of `next_token` for request remaining entries - # `next_token` does not appear + # Set `from` to value of `next_batch` for request remaining entries + # `next_batch` does not appear channel = self.make_request( "GET", - self.url + "?from=9", + self.url + f"?from={report_9['received_ts']}", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 1) - self.assertNotIn("next_token", channel.json_body) + self.assertNotIn("next_batch", channel.json_body) def _report_room(self, room_id: str, user_tok: str) -> None: """Report rooms""" From d5edac4fbff6cd66780c7101711cf5350b7e107e Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 11:57:15 -0700 Subject: [PATCH 12/23] add comment on total room report count returned --- synapse/storage/databases/main/room.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 2c36cccbb1f..c710e2b301c 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2162,6 +2162,9 @@ def _get_room_reports_paginate_txn( where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" # Don't count reports against rooms which have been deleted/purged. + # This is intentional as these reports are not returned by the pagination query below + # and represent reports that cannot be acted upon - as the rooms they reference no + # longer exist on the server sql = f""" SELECT COUNT(*) as total_room_reports FROM room_reports AS rr From 2fe0a9be7c8f51616cf52432fc95d44dcc2a7844 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 12:28:25 -0700 Subject: [PATCH 13/23] paginate with id vs timestamp --- synapse/rest/admin/room_reports.py | 11 +++++------ synapse/storage/databases/main/room.py | 13 +++++++------ tests/rest/admin/test_room_reports.py | 22 +++++++++++----------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index bcf66c787c1..a79905081e5 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -42,7 +42,8 @@ class RoomReportsRestServlet(RestServlet): 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 the current time. + By default, a `limit` of 100 is used, and the `from` parameter defaults to the + most recent report. 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: @@ -55,18 +56,16 @@ class RoomReportsRestServlet(RestServlet): def __init__(self, hs: "HomeServer"): self._auth = hs.get_auth() self._store = hs.get_datastores().main - self._clock = hs.get_clock() async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: await assert_requester_is_admin(self._auth, request) - now = int(self._clock.time_msec()) + 1 - start = parse_integer(request, "from", default=now) + start = 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 start < 0: + if start is not None and start < 0: raise SynapseError( HTTPStatus.BAD_REQUEST, "The start parameter must be a positive integer.", @@ -89,7 +88,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: # trim the extra room if it exists room_reports = room_reports[:limit] if has_more: - ret["next_batch"] = room_reports[-1]["received_ts"] + ret["next_batch"] = room_reports[-1]["id"] ret.update({"room_reports": room_reports, "total": total}) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index c710e2b301c..080c7ce5542 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2128,7 +2128,7 @@ def _get_event_report_txn( async def get_room_reports_paginate( self, - start: int, + start: int | None, limit: int, user_id: str | None = None, room_id: str | None = None, @@ -2136,7 +2136,7 @@ async def get_room_reports_paginate( """Retrieve a paginated list of room reports Args: - start: timestamp to start from + start: id to start from - the most recent report if None 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 @@ -2174,10 +2174,11 @@ def _get_room_reports_paginate_txn( txn.execute(sql, args) count = cast(tuple[int], txn.fetchone())[0] - filters.append("rr.received_ts < ?") - args.append(start) + if start is not None: + filters.append("rr.id < ?") + args.append(start) - where_clause = "WHERE " + " AND ".join(filters) + where_clause = "WHERE " + " AND ".join(filters) if filters else "" sql = f""" SELECT @@ -2193,7 +2194,7 @@ def _get_room_reports_paginate_txn( INNER JOIN room_stats_state ON room_stats_state.room_id = rr.room_id {where_clause} - ORDER BY rr.received_ts DESC + ORDER BY rr.id DESC LIMIT ? """ diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 419171b4f08..47e2c641938 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -104,7 +104,7 @@ def test_limit(self) -> None: """ Testing list of reported rooms with limit """ - # grab the 5th report to get the timestamp + # grab the 5th report to get its id channel = self.make_request( "GET", self.url, @@ -121,25 +121,25 @@ def test_limit(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 5) - self.assertEqual(channel.json_body["next_batch"], report_5["received_ts"]) + self.assertEqual(channel.json_body["next_batch"], report_5["id"]) self._check_fields(channel.json_body["room_reports"]) def test_from(self) -> None: """ Testing list of reported rooms with a defined starting point (from) """ - # grab the fifth report to get the timestamp + # grab the fifth report to get its id channel = self.make_request( "GET", self.url, access_token=self.admin_user_tok, ) report_5 = channel.json_body["room_reports"][4] - from_ts = report_5["received_ts"] + from_id = report_5["id"] channel = self.make_request( "GET", - self.url + f"?from={from_ts}", + self.url + f"?from={from_id}", access_token=self.admin_user_tok, ) @@ -153,27 +153,27 @@ def test_limit_and_from(self) -> None: """ Testing list of reported rooms with a defined starting point and limit """ - # grab the second most recent report to get the timestamp + # grab the second most recent report to get its id channel = self.make_request( "GET", self.url, access_token=self.admin_user_tok, ) report_2 = channel.json_body["room_reports"][1] - from_ts = report_2["received_ts"] + from_id = report_2["id"] report_5 = channel.json_body["room_reports"][4] channel = self.make_request( "GET", - self.url + f"?from={from_ts}&limit=3", + self.url + f"?from={from_id}&limit=3", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 3) - self.assertEqual(channel.json_body["next_batch"], report_5["received_ts"]) + self.assertEqual(channel.json_body["next_batch"], report_5["id"]) self._check_fields(channel.json_body["room_reports"]) def test_filter_room(self) -> None: @@ -313,14 +313,14 @@ def test_next_batch(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 9) - self.assertEqual(channel.json_body["next_batch"], report_9["received_ts"]) + self.assertEqual(channel.json_body["next_batch"], report_9["id"]) # Check # Set `from` to value of `next_batch` for request remaining entries # `next_batch` does not appear channel = self.make_request( "GET", - self.url + f"?from={report_9['received_ts']}", + self.url + f"?from={report_9['id']}", access_token=self.admin_user_tok, ) From e0061ed1bce3be06901fcde0c5175ea872ced37a Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 13:03:52 -0700 Subject: [PATCH 14/23] force keyword args --- synapse/rest/admin/room_reports.py | 2 +- synapse/storage/databases/main/room.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index a79905081e5..025605853cf 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -80,7 +80,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: ) room_reports, total = await self._store.get_room_reports_paginate( - start, limit, user_id, room_id + start=start, limit=limit, user_id=user_id, room_id=room_id ) ret = {} diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 080c7ce5542..d7fb8147066 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2128,6 +2128,7 @@ def _get_event_report_txn( async def get_room_reports_paginate( self, + *, start: int | None, limit: int, user_id: str | None = None, From 0f89f2483ed2c244a1e4ed7f18cb043d0922af60 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 13:05:34 -0700 Subject: [PATCH 15/23] remove extra trainling slashes --- synapse/storage/databases/main/room.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index d7fb8147066..9910f030fd2 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2028,18 +2028,18 @@ 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 \ + 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 = ? \ + WHERE report.id = ? """ txn.execute(sql, [report_id]) From e1896fb42a9b7df7187bfb6c3d78c4e791c41b57 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 13:09:04 -0700 Subject: [PATCH 16/23] use exact matches rather than LIKE --- synapse/storage/databases/main/room.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 9910f030fd2..e2295e04fc8 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2154,11 +2154,11 @@ def _get_room_reports_paginate_txn( args: list[object] = [] if user_id: - filters.append("rr.user_id LIKE ?") - args.extend(["%" + user_id + "%"]) + filters.append("rr.user_id = ?") + args.extend([user_id]) if room_id: - filters.append("rr.room_id LIKE ?") - args.extend(["%" + room_id + "%"]) + filters.append("rr.room_id = ?") + args.extend([room_id]) where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" From 63f363290aeb59fb3dd76727a2dab2107f2a2836 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 14:28:24 -0700 Subject: [PATCH 17/23] remove deletion endpoint --- docs/admin_api/room_reports.md | 14 --- synapse/rest/admin/room_reports.py | 25 ----- tests/rest/admin/test_room_reports.py | 137 -------------------------- 3 files changed, 176 deletions(-) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md index fda61f8f54b..32027339508 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -121,17 +121,3 @@ The following fields are returned in the JSON response body: * `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. - -# Delete a specific room report - -This API deletes a specific room report. If the request is successful, the response body -will be an empty JSON object. - -The api is: -``` -DELETE /_synapse/admin/v1/room_reports/ -``` - -**URL parameters:** - -* `report_id`: string - The ID of the room report to delete. \ No newline at end of file diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index 025605853cf..f459aef1068 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -137,28 +137,3 @@ async def on_GET( raise NotFoundError("Room report not found") return HTTPStatus.OK, ret - - async def on_DELETE( - 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 positive integer." - ) - try: - resolved_report_id = int(report_id) - except ValueError: - raise SynapseError( - HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM - ) - - if resolved_report_id < 0: - raise SynapseError( - HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM - ) - - if await self._store.delete_room_report(resolved_report_id): - return HTTPStatus.OK, {} - - raise NotFoundError("Room report not found") diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 47e2c641938..40716fd4439 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -512,140 +512,3 @@ def _report_room(self, room_id: str, user_tok: str) -> None: shorthand=False, ) self.assertEqual(200, channel.code, msg=channel.json_body) - - -class DeleteRoomReportTestCase(unittest.HomeserverTestCase): - servlets = [ - synapse.rest.admin.register_servlets, - login.register_servlets, - ] - - def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: - self._store = hs.get_datastores().main - - 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") - - # create report - room_report_id = self.get_success( - self._store.add_room_report( - "room_id", - self.other_user, - "this makes me sad", - self.clock.time_msec(), - ) - ) - - self.url = f"/_synapse/admin/v1/room_reports/{room_report_id}" - - def test_no_auth(self) -> None: - """ - Try to delete a room report without authentication. - """ - channel = self.make_request("DELETE", self.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( - "DELETE", - self.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_delete_success(self) -> None: - """ - Testing delete a report. - """ - - channel = self.make_request( - "DELETE", - self.url, - access_token=self.admin_user_tok, - ) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual({}, channel.json_body) - - channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, - ) - - # check that report was deleted - self.assertEqual(404, channel.code, msg=channel.json_body) - self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) - - def test_invalid_report_id(self) -> None: - """ - Testing that an invalid `report_id` returns a 400. - """ - - # `report_id` is negative - channel = self.make_request( - "DELETE", - "/_synapse/admin/v1/room_reports/-123", - 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 positive integer.", - channel.json_body["error"], - ) - - # `report_id` is a non-numerical string - channel = self.make_request( - "DELETE", - "/_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 positive integer.", - channel.json_body["error"], - ) - - # `report_id` is undefined - channel = self.make_request( - "DELETE", - "/_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 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( - "DELETE", - "/_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"]) From 081610c13e8d1396b4ff9a0cfab81c93cbdd20b7 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 1 Jun 2026 14:29:47 -0700 Subject: [PATCH 18/23] fix docs --- docs/admin_api/room_reports.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md index 32027339508..dee862bffdd 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -54,8 +54,8 @@ paginate through. * `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`: positive integer - Optional. Used for pagination, denoting the unix ms timestamp to return results - from in descending order. Defaults to the current time. +* `from`: positive integer - Optional. Used for pagination, report id to return results + from in descending order. Defaults to the most recent report if not provided. * `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. From a935f3381daf3e4021cd4379cc59e3dd9128917b Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 2 Jun 2026 10:41:05 -0700 Subject: [PATCH 19/23] remove total count --- docs/admin_api/room_reports.md | 4 +--- synapse/rest/admin/room_reports.py | 4 ++-- synapse/storage/databases/main/room.py | 21 +++------------------ tests/rest/admin/test_room_reports.py | 16 ---------------- 4 files changed, 6 insertions(+), 39 deletions(-) diff --git a/docs/admin_api/room_reports.md b/docs/admin_api/room_reports.md index dee862bffdd..08549130a9a 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -37,7 +37,6 @@ It returns a JSON body like the following: } ], "next_batch": 2, - "total": 4 } ``` @@ -76,8 +75,7 @@ The following fields are returned in the JSON response body: * `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. -* `total`: integer - Total number of room reports related to the query - (`user_id` and `room_id`). + # Show details of a specific room report diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index f459aef1068..fe09014749b 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -79,7 +79,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: errcode=Codes.INVALID_PARAM, ) - room_reports, total = await self._store.get_room_reports_paginate( + room_reports = await self._store.get_room_reports_paginate( start=start, limit=limit, user_id=user_id, room_id=room_id ) @@ -90,7 +90,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: if has_more: ret["next_batch"] = room_reports[-1]["id"] - ret.update({"room_reports": room_reports, "total": total}) + ret.update({"room_reports": room_reports}) return HTTPStatus.OK, ret diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index e2295e04fc8..320081b0166 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2133,7 +2133,7 @@ async def get_room_reports_paginate( limit: int, user_id: str | None = None, room_id: str | None = None, - ) -> tuple[list[dict[str, Any]], int]: + ) -> list[dict[str, Any]]: """Retrieve a paginated list of room reports Args: @@ -2149,7 +2149,7 @@ async def get_room_reports_paginate( def _get_room_reports_paginate_txn( txn: LoggingTransaction, - ) -> tuple[list[dict[str, Any]], int]: + ) -> list[dict[str, Any]]: filters = [] args: list[object] = [] @@ -2160,21 +2160,6 @@ def _get_room_reports_paginate_txn( filters.append("rr.room_id = ?") args.extend([room_id]) - where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" - - # Don't count reports against rooms which have been deleted/purged. - # This is intentional as these reports are not returned by the pagination query below - # and represent reports that cannot be acted upon - as the rooms they reference no - # longer exist on the server - sql = f""" - SELECT COUNT(*) as total_room_reports - FROM room_reports AS rr - INNER JOIN room_stats_state ON room_stats_state.room_id = rr.room_id - {where_clause} - """ - txn.execute(sql, args) - count = cast(tuple[int], txn.fetchone())[0] - if start is not None: filters.append("rr.id < ?") args.append(start) @@ -2217,7 +2202,7 @@ def _get_room_reports_paginate_txn( for row in txn ] - return room_reports, count + return room_reports return await self.db_pool.runInteraction( "get_room_reports_paginate", _get_room_reports_paginate_txn diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 40716fd4439..f0304017ca8 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -95,7 +95,6 @@ def test_default_success(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) @@ -119,7 +118,6 @@ def test_limit(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 5) self.assertEqual(channel.json_body["next_batch"], report_5["id"]) self._check_fields(channel.json_body["room_reports"]) @@ -144,7 +142,6 @@ def test_from(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 5) self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) @@ -171,7 +168,6 @@ def test_limit_and_from(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 3) self.assertEqual(channel.json_body["next_batch"], report_5["id"]) self._check_fields(channel.json_body["room_reports"]) @@ -188,7 +184,6 @@ def test_filter_room(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 1) self.assertEqual(len(channel.json_body["room_reports"]), 1) self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) @@ -208,7 +203,6 @@ def test_filter_user(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 5) self.assertEqual(len(channel.json_body["room_reports"]), 5) self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) @@ -228,7 +222,6 @@ def test_filter_user_and_room(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 1) self.assertEqual(len(channel.json_body["room_reports"]), 1) self.assertNotIn("next_batch", channel.json_body) self._check_fields(channel.json_body["room_reports"]) @@ -282,7 +275,6 @@ def test_next_batch(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) self.assertNotIn("next_batch", channel.json_body) @@ -298,7 +290,6 @@ def test_next_batch(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 10) self.assertNotIn("next_batch", channel.json_body) @@ -311,7 +302,6 @@ def test_next_batch(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 9) self.assertEqual(channel.json_body["next_batch"], report_9["id"]) @@ -325,7 +315,6 @@ def test_next_batch(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(channel.json_body["total"], 10) self.assertEqual(len(channel.json_body["room_reports"]), 1) self.assertNotIn("next_batch", channel.json_body) @@ -373,11 +362,6 @@ def test_count_correct_despite_table_deletions(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - # The 'total' field is 9 because only 9 reports will actually - # be retrievable since we deleted the row in the room_stats_state - # table. - self.assertEqual(channel.json_body["total"], 9) - # This is consistent with the number of rows actually returned. self.assertEqual(len(channel.json_body["room_reports"]), 9) From bbf7ad9f03581a1e8ed2895b651f7d6b9733068f Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 2 Jun 2026 15:05:48 -0700 Subject: [PATCH 20/23] endpoint and database changes --- synapse/rest/admin/room_reports.py | 32 +++++----------- synapse/storage/databases/main/room.py | 52 ++++++++++---------------- 2 files changed, 30 insertions(+), 54 deletions(-) diff --git a/synapse/rest/admin/room_reports.py b/synapse/rest/admin/room_reports.py index fe09014749b..91e9aad5aee 100644 --- a/synapse/rest/admin/room_reports.py +++ b/synapse/rest/admin/room_reports.py @@ -42,13 +42,12 @@ class RoomReportsRestServlet(RestServlet): 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 the - most recent report. + 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 and an integer representing the total number of - reported rooms that exist given this query + A list of reported rooms filtered by the query parameters """ PATTERNS = admin_patterns("/room_reports$") @@ -60,34 +59,25 @@ def __init__(self, hs: "HomeServer"): async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: await assert_requester_is_admin(self._auth, request) - start = parse_integer(request, "from") + 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 start is not None and start < 0: - raise SynapseError( - HTTPStatus.BAD_REQUEST, - "The start parameter must be a positive integer.", - errcode=Codes.INVALID_PARAM, - ) - - if limit < 0: + if limit <= 0: raise SynapseError( HTTPStatus.BAD_REQUEST, "The limit parameter must be a positive integer.", errcode=Codes.INVALID_PARAM, ) - room_reports = await self._store.get_room_reports_paginate( - start=start, limit=limit, user_id=user_id, room_id=room_id + 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 = {} - has_more = len(room_reports) > limit - # trim the extra room if it exists - room_reports = room_reports[:limit] - if has_more: + + if limited: ret["next_batch"] = room_reports[-1]["id"] ret.update({"room_reports": room_reports}) @@ -122,9 +112,7 @@ async def on_GET( ) -> tuple[int, JsonDict]: await assert_requester_is_admin(self._auth, request) - message = ( - "The report_id parameter must be a string representing a positive integer." - ) + 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: diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 320081b0166..70cf432c143 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2129,43 +2129,47 @@ def _get_event_report_txn( async def get_room_reports_paginate( self, *, - start: int | None, + from_id: int | None, limit: int, user_id: str | None = None, room_id: str | None = None, - ) -> list[dict[str, Any]]: + ) -> tuple[list[dict[str, Any]], bool]: """Retrieve a paginated list of room reports Args: - start: id to start from - the most recent report if None + 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 - total number of room reports matching the filter criteria + a boolean indicating whether there are more reports available """ def _get_room_reports_paginate_txn( txn: LoggingTransaction, - ) -> list[dict[str, Any]]: + ) -> tuple[list[dict[str, Any]], bool]: filters = [] - args: list[object] = [] + args: list[str | int] = [] if user_id: filters.append("rr.user_id = ?") - args.extend([user_id]) + args.append(user_id) if room_id: filters.append("rr.room_id = ?") - args.extend([room_id]) + args.append(room_id) - if start is not None: + if from_id is not None: filters.append("rr.id < ?") - args.append(start) + 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, @@ -2202,7 +2206,12 @@ def _get_room_reports_paginate_txn( for row in txn ] - return room_reports + 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 @@ -2329,27 +2338,6 @@ def _get_event_reports_paginate_txn( "get_event_reports_paginate", _get_event_reports_paginate_txn ) - async def delete_room_report(self, report_id: int) -> bool: - """Remove a room report from database. - - Args: - report_id: Report to delete - - Returns: - Whether the report was successfully deleted or not. - """ - try: - await self.db_pool.simple_delete_one( - table="room_reports", - keyvalues={"id": report_id}, - desc="delete_room_report", - ) - except StoreError: - # Deletion failed because report does not exist - return False - - return True - async def delete_event_report(self, report_id: int) -> bool: """Remove an event report from database. From 52af00b0f6773efed757afb6e70efc2f9ee627f5 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 2 Jun 2026 15:14:44 -0700 Subject: [PATCH 21/23] changes to docs and newsfragment --- changelog.d/19648.feature | 2 +- docs/admin_api/room_reports.md | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/changelog.d/19648.feature b/changelog.d/19648.feature index 4c8c842097d..a57548be2e3 100644 --- a/changelog.d/19648.feature +++ b/changelog.d/19648.feature @@ -1 +1 @@ -Add [Admin API](https://matrix-org.github.io/synapse/develop/usage/administration/admin_api/index.html) endpoints to list, fetch and delete room reports. \ No newline at end of file +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/admin_api/room_reports.md b/docs/admin_api/room_reports.md index 08549130a9a..391bf3cf8a4 100644 --- a/docs/admin_api/room_reports.md +++ b/docs/admin_api/room_reports.md @@ -5,7 +5,7 @@ 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: +The API is: ``` GET /_synapse/admin/v1/room_reports ``` @@ -36,12 +36,12 @@ It returns a JSON body like the following: "topic": null } ], - "next_batch": 2, + "next_batch": 2 } ``` -Note: Reports for deleted or purged rooms are not returned. The endpoint returns reports in descending -chronological order. +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. @@ -53,8 +53,7 @@ paginate through. * `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`: positive integer - Optional. Used for pagination, report id to return results - from in descending order. Defaults to the most recent report 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. From 2a7866441fc8fd140141c3c685d4350129d2dc49 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 3 Jun 2026 12:33:27 -0700 Subject: [PATCH 22/23] changes to tests --- tests/rest/admin/test_room_reports.py | 328 ++++++++++---------------- 1 file changed, 126 insertions(+), 202 deletions(-) diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index f0304017ca8..3be6baf624b 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -11,8 +11,6 @@ # See the GNU Affero General Public License for more details: # . # - - from twisted.internet.testing import MemoryReactor import synapse.rest.admin @@ -40,23 +38,23 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.other_user = self.register_user("user", "pass") self.other_user_tok = self.login("user", "pass") - self.room_ids = {} - for i in range(4): + self.room_reports = [] + for _ in range(5): room_id = self.helper.create_room_as( self.admin_user, tok=self.admin_user_tok ) - self.room_ids[i] = room_id - for i in range(4, 10): + 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 ) - self.room_ids[i] = room_id - - for room_num, room_id in self.room_ids.items(): - if room_num <= 4: - self._report_room(room_id, self.other_user_tok) - else: - self._report_room(room_id, self.admin_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.url = "/_synapse/admin/v1/room_reports" @@ -96,21 +94,18 @@ def test_default_success(self) -> None: 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) - self._check_fields(channel.json_body["room_reports"]) + # 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_limit(self) -> None: + def test_pagination(self) -> None: """ - Testing list of reported rooms with limit + Test pagination of the returned room reports. """ - # grab the 5th report to get its id - channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, - ) - report_5 = channel.json_body["room_reports"][4] - + # First page of results channel = self.make_request( "GET", self.url + "?limit=5", @@ -118,77 +113,50 @@ def test_limit(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 5) - self.assertEqual(channel.json_body["next_batch"], report_5["id"]) - self._check_fields(channel.json_body["room_reports"]) - - def test_from(self) -> None: - """ - Testing list of reported rooms with a defined starting point (from) - """ - # grab the fifth report to get its id - channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, + 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:])) ) - report_5 = channel.json_body["room_reports"][4] - from_id = report_5["id"] + # Request second page of results using next_batch token + next_batch = channel.json_body["next_batch"] channel = self.make_request( "GET", - self.url + f"?from={from_id}", + self.url + f"?limit=5&from={next_batch}", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 5) + second_page = channel.json_body["room_reports"] + self.assertEqual(len(second_page), 5) self.assertNotIn("next_batch", channel.json_body) - self._check_fields(channel.json_body["room_reports"]) - - def test_limit_and_from(self) -> None: - """ - Testing list of reported rooms with a defined starting point and limit - """ - # grab the second most recent report to get its id - channel = self.make_request( - "GET", - self.url, - access_token=self.admin_user_tok, - ) - report_2 = channel.json_body["room_reports"][1] - from_id = report_2["id"] - - report_5 = channel.json_body["room_reports"][4] - - channel = self.make_request( - "GET", - self.url + f"?from={from_id}&limit=3", - access_token=self.admin_user_tok, + # 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])) ) - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 3) - self.assertEqual(channel.json_body["next_batch"], report_5["id"]) - self._check_fields(channel.json_body["room_reports"]) - 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.url + "?room_id=%s" % self.room_ids[1], + self.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_fields(channel.json_body["room_reports"]) - self.assertEqual( - channel.json_body["room_reports"][0]["room_id"], self.room_ids[1] + self._check_expected_room_report_fields( + channel.json_body["room_reports"], [self.room_reports[3]] ) def test_filter_user(self) -> None: @@ -198,14 +166,17 @@ def test_filter_user(self) -> None: channel = self.make_request( "GET", - self.url + "?user_id=%s" % self.other_user, + self.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) - self._check_fields(channel.json_body["room_reports"]) + # 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) @@ -217,141 +188,59 @@ def test_filter_user_and_room(self) -> None: channel = self.make_request( "GET", - self.url + "?user_id=%s&room_id=%s" % (self.other_user, self.room_ids[4]), + self.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_fields(channel.json_body["room_reports"]) + self._check_expected_room_report_fields( + channel.json_body["room_reports"], [self.room_reports[4]] + ) self.assertEqual( channel.json_body["room_reports"][0]["user_id"], self.other_user ) self.assertEqual( - channel.json_body["room_reports"][0]["room_id"], self.room_ids[4] - ) - - def test_limit_is_negative(self) -> None: - """ - Testing that a negative limit parameter returns a 400 - """ - - channel = self.make_request( - "GET", - self.url + "?limit=-5", - access_token=self.admin_user_tok, - ) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) - - def test_from_is_negative(self) -> None: - """ - Testing that a negative from parameter returns a 400 - """ - - channel = self.make_request( - "GET", - self.url + "?from=-5", - access_token=self.admin_user_tok, - ) - - self.assertEqual(400, channel.code, msg=channel.json_body) - self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"]) - - def test_next_batch(self) -> None: - """ - Testing that `next_batch` appears at the right place - """ - - # `next_batch` does not appear - # Number of results is the number of entries - channel = self.make_request( - "GET", - self.url + "?limit=10", - access_token=self.admin_user_tok, - ) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 10) - self.assertNotIn("next_batch", channel.json_body) - - # fetch ts of 2nd oldest report - report_9 = channel.json_body["room_reports"][8] - - # `next_batch` does not appear - # Number of max results is larger than the number of entries - channel = self.make_request( - "GET", - self.url + "?limit=11", - access_token=self.admin_user_tok, - ) - - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 10) - self.assertNotIn("next_batch", channel.json_body) - - # `next_batch` does appear - # Number of max results is smaller than the number of entries - channel = self.make_request( - "GET", - self.url + "?limit=9", - access_token=self.admin_user_tok, + channel.json_body["room_reports"][0]["room_id"], + self.room_reports[4]["room_id"], ) - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertEqual(len(channel.json_body["room_reports"]), 9) - self.assertEqual(channel.json_body["next_batch"], report_9["id"]) - - # Check - # Set `from` to value of `next_batch` for request remaining entries - # `next_batch` does not appear - channel = self.make_request( - "GET", - self.url + f"?from={report_9['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) - - def _report_room(self, room_id: str, user_tok: str) -> None: + def _report_room(self, room_id: str, user_tok: str) -> int: """Report rooms""" channel = self.make_request( "POST", - "_matrix/client/v3/rooms/%s/report" % (room_id), + 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 _check_fields(self, content: list[JsonDict]) -> None: - """Checks that all attributes are present in a room report""" - for c in content: - self.assertIn("id", c) - self.assertIn("received_ts", c) - self.assertIn("room_id", c) - self.assertIn("user_id", c) - self.assertIn("canonical_alias", c) - self.assertIn("name", c) - self.assertIn("reason", c) - self.assertIn("topic", c) - - def test_count_correct_despite_table_deletions(self) -> None: + def test_room_reports_for_deleted_rooms_are_not_returned(self) -> None: """ - Tests that the count matches the number of rows, even if rows in joined tables - are missing. + 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_ids[1]}, desc="_" + "room_stats_state", + {"room_id": self.room_reports[1]["room_id"]}, + desc="_", ) ) @@ -364,6 +253,23 @@ def test_count_correct_despite_table_deletions(self) -> None: 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 = [ @@ -384,16 +290,18 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.other_user, tok=self.other_user_tok, is_public=True ) - self._report_room(self.room_id1, self.admin_user_tok) + 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} + ] - # first created room report gets `id`=2 - self.url = "/_synapse/admin/v1/room_reports/2" + 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.url, {}) + 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"]) @@ -405,7 +313,7 @@ def test_requester_is_no_admin(self) -> None: channel = self.make_request( "GET", - self.url, + self.fetch_report_url, access_token=self.other_user_tok, ) @@ -419,19 +327,19 @@ def test_default_success(self) -> None: channel = self.make_request( "GET", - self.url, + self.fetch_report_url, access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code, msg=channel.json_body) - self._check_fields(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 a non-numerical string + # `report_id` is invalid (should be a numerical report ID) channel = self.make_request( "GET", "/_synapse/admin/v1/room_reports/abcdef", @@ -441,7 +349,7 @@ def test_invalid_report_id(self) -> None: 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 positive integer.", + "The report_id parameter must be a string representing a room report ID (positive integer).", channel.json_body["error"], ) @@ -455,7 +363,7 @@ def test_invalid_report_id(self) -> None: 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 positive integer.", + "The report_id parameter must be a string representing a room report ID (positive integer).", channel.json_body["error"], ) @@ -474,25 +382,41 @@ def test_report_id_not_found(self) -> None: self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"]) self.assertEqual("Room report not found", channel.json_body["error"]) - def _check_fields(self, content: JsonDict) -> None: - """Checks that all attributes are present in a room report""" - self.assertIn("id", content) - self.assertIn("received_ts", content) - self.assertIn("room_id", content) - self.assertIn("user_id", content) - self.assertIn("canonical_alias", content) - self.assertIn("name", content) - self.assertIn("topic", content) - self.assertIn("reason", content) - - def _report_room(self, room_id: str, user_tok: str) -> None: + 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", - "_matrix/client/v3/rooms/%s/report" % (room_id), + 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 From 64c3023d0da10bc1e643aecac1ea71983227d9db Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 3 Jun 2026 13:09:16 -0700 Subject: [PATCH 23/23] more test cleanup --- tests/rest/admin/test_room_reports.py | 29 ++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/rest/admin/test_room_reports.py b/tests/rest/admin/test_room_reports.py index 3be6baf624b..5b5a3299d37 100644 --- a/tests/rest/admin/test_room_reports.py +++ b/tests/rest/admin/test_room_reports.py @@ -56,13 +56,13 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: {"id": report_id, "room_id": room_id, "user_id": self.admin_user} ) - self.url = "/_synapse/admin/v1/room_reports" + 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.url, {}) + 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"]) @@ -74,7 +74,7 @@ def test_requester_is_no_admin(self) -> None: channel = self.make_request( "GET", - self.url, + self.list_reports_url, access_token=self.other_user_tok, ) @@ -88,7 +88,7 @@ def test_default_success(self) -> None: channel = self.make_request( "GET", - self.url, + self.list_reports_url, access_token=self.admin_user_tok, ) @@ -108,7 +108,7 @@ def test_pagination(self) -> None: # First page of results channel = self.make_request( "GET", - self.url + "?limit=5", + self.list_reports_url + "?limit=5", access_token=self.admin_user_tok, ) @@ -126,7 +126,7 @@ def test_pagination(self) -> None: next_batch = channel.json_body["next_batch"] channel = self.make_request( "GET", - self.url + f"?limit=5&from={next_batch}", + self.list_reports_url + f"?limit=5&from={next_batch}", access_token=self.admin_user_tok, ) @@ -148,7 +148,7 @@ def test_filter_room(self) -> None: channel = self.make_request( "GET", - self.url + f"?room_id={room_id}", + self.list_reports_url + f"?room_id={room_id}", access_token=self.admin_user_tok, ) @@ -166,13 +166,14 @@ def test_filter_user(self) -> None: channel = self.make_request( "GET", - self.url + f"?user_id={self.other_user}", + 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])) @@ -188,7 +189,7 @@ def test_filter_user_and_room(self) -> None: channel = self.make_request( "GET", - self.url + self.list_reports_url + f"?user_id={self.other_user}&room_id={self.room_reports[4]['room_id']}", access_token=self.admin_user_tok, ) @@ -200,14 +201,6 @@ def test_filter_user_and_room(self) -> None: channel.json_body["room_reports"], [self.room_reports[4]] ) - self.assertEqual( - channel.json_body["room_reports"][0]["user_id"], self.other_user - ) - self.assertEqual( - channel.json_body["room_reports"][0]["room_id"], - self.room_reports[4]["room_id"], - ) - def _report_room(self, room_id: str, user_tok: str) -> int: """Report rooms""" @@ -246,7 +239,7 @@ def test_room_reports_for_deleted_rooms_are_not_returned(self) -> None: channel = self.make_request( "GET", - self.url, + self.list_reports_url, access_token=self.admin_user_tok, )