From ae67ff7a37f0e66acef4455d6dbca489d2d051b4 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Fri, 13 Nov 2020 17:51:48 +0000 Subject: [PATCH 1/8] Allow server admin to get admin bit in room --- synapse/rest/admin/rooms.py | 101 +++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index f5304ff43dd4..40891f9226e2 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -14,9 +14,9 @@ # limitations under the License. import logging from http import HTTPStatus -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional -from synapse.api.constants import EventTypes, JoinRules +from synapse.api.constants import EventTypes, JoinRules, Membership from synapse.api.errors import Codes, NotFoundError, SynapseError from synapse.http.servlet import ( RestServlet, @@ -34,6 +34,9 @@ from synapse.storage.databases.main.room import RoomSortOrder from synapse.types import RoomAlias, RoomID, UserID, create_requester +if TYPE_CHECKING: + from synapse.server import HomeServer + logger = logging.getLogger(__name__) @@ -338,3 +341,97 @@ async def on_POST(self, request, room_identifier): ) return 200, {"room_id": room_id} + + +class MakeRoomAdminRoomServlet(RestServlet): + PATTERNS = admin_patterns("/make_room_admin/(?P[^/]*)") + + def __init__(self, hs: "HomeServer"): + self.hs = hs + self.auth = hs.get_auth() + self.room_member_handler = hs.get_room_member_handler() + self.event_creation_handler = hs.get_event_creation_handler() + self.state_handler = hs.get_state_handler() + self.is_mine_id = hs.is_mine_id + + async def on_POST(self, request, room_identifier): + requester = await self.auth.get_user_by_req(request) + await assert_user_is_admin(self.auth, requester.user) + content = parse_json_object_from_request(request, allow_empty_body=True) + + if RoomID.is_valid(room_identifier): + room_id = room_identifier + elif RoomAlias.is_valid(room_identifier): + room_alias = RoomAlias.from_string(room_identifier) + room_id, _ = await self.room_member_handler.lookup_room_alias(room_alias) + room_id = room_id.to_string() + else: + raise SynapseError( + 400, "%s was not legal room ID or room alias" % (room_identifier,) + ) + + user_to_add = content.get("user_id", requester.user.to_string()) + + room_state = await self.state_handler.get_current_state(room_id) + + if not room_state: + raise SynapseError(400, "Server not in room") + + create_event = room_state[(EventTypes.Create, "")] + power_levels = room_state.get((EventTypes.PowerLevels, "")) + + if power_levels is not None: + admin_users = [ + user_id + for user_id, power in power_levels.content.get("users") + if self.is_mine_id(user_id) + ] + admin_users.sort(key=lambda user: power_levels.content.get("users")[user]) + + admin_user_id = admin_users[-1] + + pl_content = power_levels.content + else: + admin_user_id = create_event.sender + if not self.is_mine_id(admin_user_id): + raise SynapseError(400, "No local admin user in room") + + fake_requester = create_requester( + admin_user_id, authenticated_entity=requester.authenticated_entity, + ) + + new_pl_content = dict(pl_content) + new_pl_content["users"] = dict(pl_content.get("users", {})) + new_pl_content["users"][user_to_add] = new_pl_content["users"][admin_user_id] + + await self.event_creation_handler.create_and_send_nonmember_event( + fake_requester, event_dict=new_pl_content, + ) + + member_event = room_state.get((EventTypes.Member, user_to_add)) + is_joined = False + if member_event: + is_joined = member_event.content["membership"] in ( + Membership.JOIN, + Membership.INVITE, + ) + + if is_joined: + return 200, {} + + join_rules = room_state.get((EventTypes.JoinRules, "")) + is_public = False + if join_rules: + is_public = join_rules.content.get("join_rule") == JoinRules.PUBLIC + + if is_public: + return 200, {} + + await self.room_member_handler.update_membership( + fake_requester, + target=UserID.from_string(user_to_add), + room_id=room_id, + action=Membership.INVITE, + ) + + return 200, {} From 076eac40945f4f2f27f4edeb94257db2f9044dc4 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Fri, 13 Nov 2020 17:55:51 +0000 Subject: [PATCH 2/8] Newsfile --- changelog.d/8756.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8756.feature diff --git a/changelog.d/8756.feature b/changelog.d/8756.feature new file mode 100644 index 000000000000..f0ae38b6857a --- /dev/null +++ b/changelog.d/8756.feature @@ -0,0 +1 @@ +Add admin API that let's server admins get power in rooms that local users have power in. From 6c52df437a912767fc0ba2e6ea5578f8ae673a28 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Tue, 17 Nov 2020 10:19:41 +0000 Subject: [PATCH 3/8] Update changelog.d/8756.feature Co-authored-by: Matthew Hodgson --- changelog.d/8756.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/8756.feature b/changelog.d/8756.feature index f0ae38b6857a..03eb79fb0a9d 100644 --- a/changelog.d/8756.feature +++ b/changelog.d/8756.feature @@ -1 +1 @@ -Add admin API that let's server admins get power in rooms that local users have power in. +Add admin API that lets server admins get power in rooms in which local users have power. From 628e84dd8d77cd1a792db9e010c670a345244cbe Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Tue, 17 Nov 2020 10:24:39 +0000 Subject: [PATCH 4/8] Fixup --- synapse/rest/admin/__init__.py | 2 ++ synapse/rest/admin/rooms.py | 61 ++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 2a4f7a1740b5..afb7a2fbfeb7 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -42,6 +42,7 @@ DeleteRoomRestServlet, JoinRoomAliasServlet, ListRoomRestServlet, + MakeRoomAdminRoomServlet, RoomMembersRestServlet, RoomRestServlet, ShutdownRoomRestServlet, @@ -232,6 +233,7 @@ def register_servlets(hs, http_server): EventReportDetailRestServlet(hs).register(http_server) EventReportsRestServlet(hs).register(http_server) PushersRestServlet(hs).register(http_server) + MakeRoomAdminRoomServlet(hs).register(http_server) def register_servlets_for_client_rest_resource(hs, http_server): diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index 40891f9226e2..b07172aa2693 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, List, Optional from synapse.api.constants import EventTypes, JoinRules, Membership -from synapse.api.errors import Codes, NotFoundError, SynapseError +from synapse.api.errors import AuthError, Codes, NotFoundError, SynapseError from synapse.http.servlet import ( RestServlet, assert_params_in_dict, @@ -344,6 +344,17 @@ async def on_POST(self, request, room_identifier): class MakeRoomAdminRoomServlet(RestServlet): + """Allows a server admin to get power in a room if a local user has power in + a room. Will also invite the user if they're not in the room and its a + private room. Can specify another user (rather than the admin user) to be + granted power, e.g.: + + POST /_synapse/admin/v1/make_room_admin/ + { + "user_id": "@foo:example.com" + } + """ + PATTERNS = admin_patterns("/make_room_admin/(?P[^/]*)") def __init__(self, hs: "HomeServer"): @@ -359,6 +370,7 @@ async def on_POST(self, request, room_identifier): await assert_user_is_admin(self.auth, requester.user) content = parse_json_object_from_request(request, allow_empty_body=True) + # Resolve to a room ID, if necessary. if RoomID.is_valid(room_identifier): room_id = room_identifier elif RoomAlias.is_valid(room_identifier): @@ -370,10 +382,11 @@ async def on_POST(self, request, room_identifier): 400, "%s was not legal room ID or room alias" % (room_identifier,) ) + # Which user to grant room admin rights to. user_to_add = content.get("user_id", requester.user.to_string()) + # Figure out which local users currently have power in the room, if any. room_state = await self.state_handler.get_current_state(room_id) - if not room_state: raise SynapseError(400, "Server not in room") @@ -381,33 +394,59 @@ async def on_POST(self, request, room_identifier): power_levels = room_state.get((EventTypes.PowerLevels, "")) if power_levels is not None: + # We pick the local user with the highest power. + user_power = power_levels.content.get("users", {}) admin_users = [ user_id - for user_id, power in power_levels.content.get("users") + for user_id, power in user_power.items() if self.is_mine_id(user_id) ] - admin_users.sort(key=lambda user: power_levels.content.get("users")[user]) + admin_users.sort(key=lambda user: user_power[user]) + + if not admin_users: + raise SynapseError(400, "No local admin user in room") admin_user_id = admin_users[-1] pl_content = power_levels.content else: + # If there is no power level events then the creator has rights. + pl_content = {} admin_user_id = create_event.sender if not self.is_mine_id(admin_user_id): - raise SynapseError(400, "No local admin user in room") - - fake_requester = create_requester( - admin_user_id, authenticated_entity=requester.authenticated_entity, - ) + raise SynapseError( + 400, "No local admin user in room", + ) + # Grant the user power equal to the room admin by attempting to send an + # updated power level event. new_pl_content = dict(pl_content) new_pl_content["users"] = dict(pl_content.get("users", {})) new_pl_content["users"][user_to_add] = new_pl_content["users"][admin_user_id] - await self.event_creation_handler.create_and_send_nonmember_event( - fake_requester, event_dict=new_pl_content, + fake_requester = create_requester( + admin_user_id, authenticated_entity=requester.authenticated_entity, ) + try: + await self.event_creation_handler.create_and_send_nonmember_event( + fake_requester, + event_dict={ + "content": new_pl_content, + "sender": admin_user_id, + "type": EventTypes.PowerLevels, + "state_key": "", + "room_id": room_id, + }, + ) + except AuthError: + # The admin user we found turned out not to have enough power. + raise SynapseError( + 400, "No local admin user in room with power to update power levels." + ) + + # Now we check if the user we're granting admin rights to is already in + # the room. If not and its not a public room we invite them. member_event = room_state.get((EventTypes.Member, user_to_add)) is_joined = False if member_event: From 4d9f5bec836466852d940492c66109b65e274bd3 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Tue, 17 Nov 2020 10:37:57 +0000 Subject: [PATCH 5/8] Add tests --- tests/rest/admin/test_room.py | 133 ++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index 535d68f28429..a2d20ad8d28d 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -20,6 +20,7 @@ from mock import Mock import synapse.rest.admin +from synapse.api.constants import EventTypes, Membership from synapse.api.errors import Codes from synapse.rest.client.v1 import directory, events, login, room @@ -1437,6 +1438,138 @@ def test_join_private_room_if_owner(self): self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0]) +class MakeRoomAdminTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + room.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor, clock, homeserver): + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.creator = self.register_user("creator", "test") + self.creator_tok = self.login("creator", "test") + + self.second_user_id = self.register_user("second", "test") + self.second_tok = self.login("second", "test") + + self.public_room_id = self.helper.create_room_as( + self.creator, tok=self.creator_tok, is_public=True + ) + self.url = "/_synapse/admin/v1/make_room_admin/{}".format(self.public_room_id) + + def test_public_room(self): + """Test that getting admin in a public room works. + """ + room_id = self.helper.create_room_as( + self.creator, tok=self.creator_tok, is_public=True + ) + + request, channel = self.make_request( + "POST", + "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + content={}, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"]) + + # Now we test that we can join the room and ban a user. + self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok) + self.helper.change_membership( + room_id, + self.admin_user, + "@test:test", + Membership.BAN, + tok=self.admin_user_tok, + ) + + def test_private_room(self): + """Test that getting admin in a private room works and we get invited. + """ + room_id = self.helper.create_room_as( + self.creator, tok=self.creator_tok, is_public=False, + ) + + request, channel = self.make_request( + "POST", + "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + content={}, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"]) + + # Now we test that we can join the room (we should have received an + # inviate) and can ban a user. + self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok) + self.helper.change_membership( + room_id, + self.admin_user, + "@test:test", + Membership.BAN, + tok=self.admin_user_tok, + ) + + def test_other_user(self): + """Test that giving admin in a public room works to a non-admin user works. + """ + room_id = self.helper.create_room_as( + self.creator, tok=self.creator_tok, is_public=True + ) + + request, channel = self.make_request( + "POST", + "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + content={"user_id": self.second_user_id}, + access_token=self.admin_user_tok, + ) + self.render(request) + + self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"]) + + # Now we test that we can join the room and ban a user. + self.helper.join(room_id, self.second_user_id, tok=self.second_tok) + self.helper.change_membership( + room_id, + self.second_user_id, + "@test:test", + Membership.BAN, + tok=self.second_tok, + ) + + def test_not_enough_power(self): + """Test that we get a sensible error if there are no local room admins. + """ + room_id = self.helper.create_room_as( + self.creator, tok=self.creator_tok, is_public=True + ) + + # The creator drops admin rights in the room. + pl = self.helper.get_state( + room_id, EventTypes.PowerLevels, tok=self.creator_tok + ) + pl["users"][self.creator] = 0 + self.helper.send_state( + room_id, EventTypes.PowerLevels, body=pl, tok=self.creator_tok + ) + + request, channel = self.make_request( + "POST", + "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + content={}, + access_token=self.admin_user_tok, + ) + self.render(request) + + # We expect this to fail with a 400 as there are no room admins. + self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"]) + + PURGE_TABLES = [ "current_state_events", "event_backward_extremities", From 1fde1a72b841f6040ce8fb82b063693d296abb1a Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Tue, 17 Nov 2020 10:57:11 +0000 Subject: [PATCH 6/8] Docs --- docs/admin_api/rooms.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md index 0c05b0ed555c..0cb6d389c23f 100644 --- a/docs/admin_api/rooms.md +++ b/docs/admin_api/rooms.md @@ -451,3 +451,19 @@ The following fields are returned in the JSON response body: * `local_aliases` - An array of strings representing the local aliases that were migrated from the old room to the new. * `new_room_id` - A string representing the room ID of the new room. + + +# Make Room Admin API + +Grants the server admin power in a room if a local user has power in the room; +inviting the server admin if they're not in the room and its not a publically +joinable room. + +The caller can optionally specify another user to be granted power, e.g.: + +``` + POST /_synapse/admin/v1/make_room_admin/ + { + "user_id": "@foo:example.com" + } +``` From 9e669a7f0c6b1cbe7e2a9fd2fd0ddf208d05d8c8 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Fri, 18 Dec 2020 14:42:49 +0000 Subject: [PATCH 7/8] Fixup --- docs/admin_api/rooms.md | 8 ++++---- synapse/rest/admin/__init__.py | 4 ++-- synapse/rest/admin/rooms.py | 10 ++++------ tests/rest/admin/test_room.py | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md index 57959b47a9de..535cbcdb733f 100644 --- a/docs/admin_api/rooms.md +++ b/docs/admin_api/rooms.md @@ -499,11 +499,11 @@ You will have to manually handle, if you so choose, the following: # Make Room Admin API -Grants the server admin power in a room if a local user has power in the room; -inviting the server admin if they're not in the room and its not a publically -joinable room. +Grants another user the highest power available to a local user who is in the room. +If the user is not in the room, and it is not publicly joinable, then invite the user. -The caller can optionally specify another user to be granted power, e.g.: +By default the server admin (the caller) is granted power, but another user can +optionally be specified, e.g.: ``` POST /_synapse/admin/v1/make_room_admin/ diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 12cdae3ebf81..6f7dc0650347 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -38,7 +38,7 @@ DeleteRoomRestServlet, JoinRoomAliasServlet, ListRoomRestServlet, - MakeRoomAdminRoomServlet, + MakeRoomAdminRestServlet, RoomMembersRestServlet, RoomRestServlet, ShutdownRoomRestServlet, @@ -229,7 +229,7 @@ def register_servlets(hs, http_server): EventReportDetailRestServlet(hs).register(http_server) EventReportsRestServlet(hs).register(http_server) PushersRestServlet(hs).register(http_server) - MakeRoomAdminRoomServlet(hs).register(http_server) + MakeRoomAdminRestServlet(hs).register(http_server) def register_servlets_for_client_rest_resource(hs, http_server): diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index bd2c7d4c5240..a8e9c1dc8e91 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -370,9 +370,9 @@ async def on_POST( return 200, {"room_id": room_id} -class MakeRoomAdminRoomServlet(RestServlet): +class MakeRoomAdminRestServlet(RestServlet): """Allows a server admin to get power in a room if a local user has power in - a room. Will also invite the user if they're not in the room and its a + a room. Will also invite the user if they're not in the room and it's a private room. Can specify another user (rather than the admin user) to be granted power, e.g.: @@ -424,9 +424,7 @@ async def on_POST(self, request, room_identifier): # We pick the local user with the highest power. user_power = power_levels.content.get("users", {}) admin_users = [ - user_id - for user_id, power in user_power.items() - if self.is_mine_id(user_id) + user_id for user_id in user_power if self.is_mine_id(user_id) ] admin_users.sort(key=lambda user: user_power[user]) @@ -473,7 +471,7 @@ async def on_POST(self, request, room_identifier): ) # Now we check if the user we're granting admin rights to is already in - # the room. If not and its not a public room we invite them. + # the room. If not and it's not a public room we invite them. member_event = room_state.get((EventTypes.Member, user_to_add)) is_joined = False if member_event: diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index 55d36e4f1f4f..c4f65c983ff0 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -1498,7 +1498,7 @@ def test_private_room(self): self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"]) # Now we test that we can join the room (we should have received an - # inviate) and can ban a user. + # invite) and can ban a user. self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok) self.helper.change_membership( room_id, From 64c32c1dbe58b51ba31e7ca2b9b06a762d216221 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Fri, 18 Dec 2020 14:48:07 +0000 Subject: [PATCH 8/8] Stick API under /rooms/ --- docs/admin_api/rooms.md | 2 +- synapse/rest/admin/rooms.py | 4 ++-- tests/rest/admin/test_room.py | 19 ++++++++++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md index 535cbcdb733f..9e560003a921 100644 --- a/docs/admin_api/rooms.md +++ b/docs/admin_api/rooms.md @@ -506,7 +506,7 @@ By default the server admin (the caller) is granted power, but another user can optionally be specified, e.g.: ``` - POST /_synapse/admin/v1/make_room_admin/ + POST /_synapse/admin/v1/rooms//make_room_admin { "user_id": "@foo:example.com" } diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index a8e9c1dc8e91..ab7cc9102a66 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -376,13 +376,13 @@ class MakeRoomAdminRestServlet(RestServlet): private room. Can specify another user (rather than the admin user) to be granted power, e.g.: - POST /_synapse/admin/v1/make_room_admin/ + POST/_synapse/admin/v1/rooms//make_room_admin { "user_id": "@foo:example.com" } """ - PATTERNS = admin_patterns("/make_room_admin/(?P[^/]*)") + PATTERNS = admin_patterns("/rooms/(?P[^/]*)/make_room_admin") def __init__(self, hs: "HomeServer"): self.hs = hs diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index c4f65c983ff0..60a5fcecf7f4 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -1453,7 +1453,9 @@ def prepare(self, reactor, clock, homeserver): self.public_room_id = self.helper.create_room_as( self.creator, tok=self.creator_tok, is_public=True ) - self.url = "/_synapse/admin/v1/make_room_admin/{}".format(self.public_room_id) + self.url = "/_synapse/admin/v1/rooms/{}/make_room_admin".format( + self.public_room_id + ) def test_public_room(self): """Test that getting admin in a public room works. @@ -1464,7 +1466,7 @@ def test_public_room(self): channel = self.make_request( "POST", - "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + "/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id), content={}, access_token=self.admin_user_tok, ) @@ -1490,7 +1492,7 @@ def test_private_room(self): channel = self.make_request( "POST", - "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + "/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id), content={}, access_token=self.admin_user_tok, ) @@ -1517,7 +1519,7 @@ def test_other_user(self): channel = self.make_request( "POST", - "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + "/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id), content={"user_id": self.second_user_id}, access_token=self.admin_user_tok, ) @@ -1552,13 +1554,20 @@ def test_not_enough_power(self): channel = self.make_request( "POST", - "/_synapse/admin/v1/make_room_admin/{}".format(room_id), + "/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id), content={}, access_token=self.admin_user_tok, ) # We expect this to fail with a 400 as there are no room admins. + # + # (Note we assert the error message to ensure that it's not denied for + # some other reason) self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"]) + self.assertEqual( + channel.json_body["error"], + "No local admin user in room with power to update power levels.", + ) PURGE_TABLES = [