From 647fb5919050a16ac02c7c6903a620d1dd8a1727 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 17 Apr 2026 03:01:23 -0700 Subject: [PATCH] Add Admin API endpoints to manage user reports (#19657) Adds [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) endpoints to list, fetch and delete user reports from the homeserver. Follows on from #18120, which added the endpoints to report users. --- changelog.d/19657.feature | 2 + synapse/rest/admin/__init__.py | 6 + synapse/rest/admin/user_reports.py | 173 +++++++ synapse/storage/databases/main/room.py | 126 +++++ tests/rest/admin/test_user_reports.py | 644 +++++++++++++++++++++++++ 5 files changed, 951 insertions(+) create mode 100644 changelog.d/19657.feature create mode 100644 synapse/rest/admin/user_reports.py create mode 100644 tests/rest/admin/test_user_reports.py diff --git a/changelog.d/19657.feature b/changelog.d/19657.feature new file mode 100644 index 0000000000..f87ef9fed8 --- /dev/null +++ b/changelog.d/19657.feature @@ -0,0 +1,2 @@ +Adds [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) endpoints to +list, fetch and delete user reports. \ No newline at end of file diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index b209404cd1..0774b6ed40 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -96,6 +96,10 @@ from synapse.rest.admin.statistics import ( LargestRoomsStatistics, UserMediaStatisticsRestServlet, ) +from synapse.rest.admin.user_reports import ( + UserReportDetailRestServlet, + UserReportsRestServlet, +) from synapse.rest.admin.username_available import UsernameAvailableRestServlet from synapse.rest.admin.users import ( AccountDataRestServlet, @@ -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) + UserReportsRestServlet(hs).register(http_server) + UserReportDetailRestServlet(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/user_reports.py b/synapse/rest/admin/user_reports.py new file mode 100644 index 0000000000..119dc86517 --- /dev/null +++ b/synapse/rest/admin/user_reports.py @@ -0,0 +1,173 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING + +from synapse.api.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 UserReportsRestServlet(RestServlet): + """ + List all reported users that are known to the homeserver. Results are returned + in a dictionary containing report information. Supports pagination. + The requester must have administrator access in Synapse. + + GET /_synapse/admin/v1/user_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 `user_id` query parameter filters by the user ID of the reporter of the target user. + The `target_user_id` query parameter filters by user id of the target user. + Returns: + A list of user reprots and an integer representing the total number of user + reports that exist given this query + """ + + PATTERNS = admin_patterns("/user_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) + user_id = parse_string(request, "user_id") + target_user_id = parse_string(request, "target_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, + ) + + user_reports, total = await self._store.get_user_reports_paginate( + start, limit, direction, user_id, target_user_id + ) + ret = {"user_reports": user_reports, "total": total} + if (start + limit) < total: + ret["next_token"] = start + len(user_reports) + + return HTTPStatus.OK, ret + + +class UserReportDetailRestServlet(RestServlet): + """ + Get a specific user report 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/user_reports/ + returns: + 200 OK with details report if success otherwise an error. + + Args: + The parameter `report_id` is the ID of the user report in the database. + Returns: + JSON blob of information about the user report + """ + + PATTERNS = admin_patterns("/user_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_user_report(resolved_report_id) + if not ret: + raise NotFoundError("User report not found") + + id, received_ts, target_user_id, user_id, reason = ret + response = { + "id": id, + "received_ts": received_ts, + "target_user_id": target_user_id, + "user_id": user_id, + "reason": reason, + } + + return HTTPStatus.OK, response + + 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_user_report(resolved_report_id): + return HTTPStatus.OK, {} + + raise NotFoundError("User report not found") diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 8dbecc16e4..a0c42082f0 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2277,6 +2277,132 @@ class RoomWorkerStore(CacheInvalidationWorkerStore): return True + async def get_user_report( + self, report_id: int + ) -> tuple[int, int, str, str, str] | None: + """Retrieve a user report + + Args: + report_id: ID of user report in database + Returns: + JSON dict of information from a user report or None if the + report does not exist. + """ + + return await self.db_pool.simple_select_one( + table="user_reports", + keyvalues={"id": report_id}, + retcols=("id", "received_ts", "target_user_id", "user_id", "reason"), + allow_none=True, + desc="get_user_report", + ) + + async def get_user_reports_paginate( + self, + start: int, + limit: int, + direction: Direction = Direction.BACKWARDS, + user_id: str | None = None, + target_user_id: str | None = None, + ) -> tuple[list[JsonDict], int]: + """Retrieve a paginated list of user 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 of the reporter. Ignored if user_id is None + target_user_id: search for user_id of the target. Ignored if target_user_id is None + Returns: + Tuple of: + json list of user reports + total number of user reports matching the filter criteria + """ + + def _get_user_reports_paginate_txn( + txn: LoggingTransaction, + ) -> tuple[list[dict[str, Any]], int]: + filters = [] + args: list[object] = [] + + if user_id: + filters.append("user_id LIKE ?") + args.extend(["%" + user_id + "%"]) + if target_user_id: + filters.append("target_user_id LIKE ?") + args.extend(["%" + target_user_id + "%"]) + + if direction == Direction.BACKWARDS: + order = "DESC" + else: + order = "ASC" + + where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else "" + + sql = f""" + SELECT COUNT(*) as total_user_reports + FROM user_reports {where_clause} + """ + txn.execute(sql, args) + count = cast(tuple[int], txn.fetchone())[0] + + sql = f""" + SELECT + id, + received_ts, + target_user_id, + user_id, + reason + FROM user_reports + {where_clause} + ORDER BY received_ts {order} + LIMIT ? + OFFSET ? + """ + + args += [limit, start] + txn.execute(sql, args) + + user_reports = [] + for row in txn: + user_reports.append( + { + "id": row[0], + "received_ts": row[1], + "target_user_id": row[2], + "user_id": row[3], + "reason": row[4], + } + ) + + return user_reports, count + + return await self.db_pool.runInteraction( + "get_user_reports_paginate", _get_user_reports_paginate_txn + ) + + async def delete_user_report(self, report_id: int) -> bool: + """Remove a user 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="user_reports", + keyvalues={"id": report_id}, + desc="delete_user_report", + ) + except StoreError: + # Deletion failed because report does not exist + return False + + return True + async def set_room_is_public(self, room_id: str, is_public: bool) -> None: await self.db_pool.simple_update_one( table="rooms", diff --git a/tests/rest/admin/test_user_reports.py b/tests/rest/admin/test_user_reports.py new file mode 100644 index 0000000000..94ae242d86 --- /dev/null +++ b/tests/rest/admin/test_user_reports.py @@ -0,0 +1,644 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + + +from twisted.internet.testing import MemoryReactor + +import synapse.rest.admin +from synapse.api.errors import Codes +from synapse.rest.client import login, reporting, room +from synapse.server import HomeServer +from synapse.types import JsonDict +from synapse.util.clock import Clock + +from tests import unittest + + +class UserReportsTestCase(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.users = {} + for i in range(10): + self.users[i] = self.register_user(f"user{i}", "pass") + + # users 1 and 2 report all other users + reporter_1_tok = self.login(self.users[0], "pass") + reporter_2_tok = self.login(self.users[1], "pass") + for num, user in self.users.items(): + if num <= 1: + continue + if num % 2 == 0: + self._report_user(user, reporter_1_tok) + else: + self._report_user(user, reporter_2_tok) + + self.url = "/_synapse/admin/v1/user_reports" + + def test_no_auth(self) -> None: + """ + Try to get a user 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. + """ + rando_tok = self.login(self.users[4], "pass") + channel = self.make_request( + "GET", + self.url, + access_token=rando_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 users + """ + + 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"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 8) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["user_reports"]) + + def test_limit(self) -> None: + """ + Testing list of reported users 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"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 5) + self.assertEqual(channel.json_body["next_token"], 5) + self._check_fields(channel.json_body["user_reports"]) + + def test_from(self) -> None: + """ + Testing list of reported users 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"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 3) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["user_reports"]) + + def test_limit_and_from(self) -> None: + """ + Testing list of reported users with a defined starting point and limit + """ + + channel = self.make_request( + "GET", + self.url + "?from=2&limit=5", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 8) + self.assertEqual(channel.json_body["next_token"], 7) + self.assertEqual(len(channel.json_body["user_reports"]), 5) + self._check_fields(channel.json_body["user_reports"]) + + def test_filter_by_target_user_id(self) -> None: + """ + Testing list of reported users with a filter of target_user_id + """ + + channel = self.make_request( + "GET", + self.url + "?target_user_id=%s" % self.users[3], + 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["user_reports"]), 1) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["user_reports"]) + + for report in channel.json_body["user_reports"]: + self.assertEqual(report["target_user_id"], self.users[3]) + + def test_filter_user(self) -> None: + """ + Testing list of reported users with a filter of reporting user + """ + + channel = self.make_request( + "GET", + self.url + "?user_id=%s" % self.users[0], + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 4) + self.assertEqual(len(channel.json_body["user_reports"]), 4) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["user_reports"]) + + for report in channel.json_body["user_reports"]: + self.assertEqual(report["user_id"], self.users[0]) + + def test_filter_user_and_target_user(self) -> None: + """ + Testing list of reported users with a filter of reporting user and target_user + """ + + channel = self.make_request( + "GET", + self.url + "?user_id=%s&target_user_id=%s" % (self.users[1], self.users[7]), + 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["user_reports"]), 1) + self.assertNotIn("next_token", channel.json_body) + self._check_fields(channel.json_body["user_reports"]) + + for report in channel.json_body["user_reports"]: + self.assertEqual(report["user_id"], self.users[1]) + self.assertEqual(report["target_user_id"], self.users[7]) + + 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"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 8) + report = 1 + while report < len(channel.json_body["user_reports"]): + self.assertGreaterEqual( + channel.json_body["user_reports"][report - 1]["received_ts"], + channel.json_body["user_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"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 8) + report = 1 + while report < len(channel.json_body["user_reports"]): + self.assertLessEqual( + channel.json_body["user_reports"][report - 1]["received_ts"], + channel.json_body["user_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=8", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 8) + 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=10", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 8) + 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=6", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 6) + self.assertEqual(channel.json_body["next_token"], 6) + + # 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=6", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(channel.json_body["total"], 8) + self.assertEqual(len(channel.json_body["user_reports"]), 2) + self.assertNotIn("next_token", channel.json_body) + + def _report_user(self, target_user: str, reporter_tok: str) -> None: + """Report a user""" + channel = self.make_request( + "POST", + "_matrix/client/v3/users/%s/report" % (target_user), + {"reason": "stone-cold bummer"}, + access_token=reporter_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 user report""" + for c in content: + self.assertIn("id", c) + self.assertIn("received_ts", c) + self.assertIn("user_id", c) + self.assertIn("target_user_id", c) + self.assertIn("reason", c) + + +class UserReportDetailTestCase(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.bad_user = self.register_user("user", "pass") + self.bad_user_tok = self.login("user", "pass") + + channel = self.make_request( + "POST", + "_matrix/client/v3/users/%s/report" % (self.bad_user), + {"reason": "stone-cold bummer"}, + access_token=self.admin_user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + # first created user report gets `id`=2 + self.url = "/_synapse/admin/v1/user_reports/2" + + def test_no_auth(self) -> None: + """ + Try to get user 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.bad_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 user + """ + + 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/user_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/user_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/user_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/user_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("User report not found", channel.json_body["error"]) + + def _check_fields(self, content: JsonDict) -> None: + """Checks that all attributes are present in a user report""" + self.assertIn("id", content) + self.assertIn("received_ts", content) + self.assertIn("target_user_id", content) + self.assertIn("user_id", content) + self.assertIn("reason", content) + + +class DeleteUserReportTestCase(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.bad_user = self.register_user("user", "pass") + self.other_user_tok = self.login("user", "pass") + + # create report + self.get_success( + self._store.add_user_report( + self.bad_user, + self.admin_user, + "super bummer", + self.clock.time_msec(), + ) + ) + + self.url = "/_synapse/admin/v1/user_reports/2" + + def test_no_auth(self) -> None: + """ + Try to delete user 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/user_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/user_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/user_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/user_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("User report not found", channel.json_body["error"])