diff --git a/plugins/module_utils/synapse.py b/plugins/module_utils/synapse.py index b8bf394..cd0022e 100644 --- a/plugins/module_utils/synapse.py +++ b/plugins/module_utils/synapse.py @@ -36,6 +36,7 @@ class AdminApi: # init subclasses self.ratelimit = self.__Ratelimit(self) + self.join = self.__Join(self) # Make API request def get(self, path: str) -> requests.Response: @@ -118,6 +119,19 @@ class AdminApi: user_id = AdminApi.url_encode(user_id) return self.__parent.delete(self.API_PATH.format(user_id=user_id)).json() + class __Join: + API_PATH = "v1/join/{room_id}" + + def __init__(self, parent): + self.__parent = parent + + def join(self, room_id: str, user_id: str) -> dict: + room_id = AdminApi.url_encode(room_id) + return self.__parent.post( + self.API_PATH.format(room_id=room_id), + json={"user_id": user_id}, + ).json() + class Exceptions: class HTTPException(BaseException): diff --git a/plugins/modules/matrix_member.py b/plugins/modules/matrix_member.py index b2b85eb..e45f18d 100644 --- a/plugins/modules/matrix_member.py +++ b/plugins/modules/matrix_member.py @@ -68,8 +68,18 @@ options: default: false type: bool required: false + force_join: + description: + - If state=member, the module force joins the user by calling the + `/_synapse/admin/v1/join/{room-id}` endpoint of synapses admin API. + - Only works for synapse homeservers and if the provided credentials have admin privileges. + - Only works for local users. + default: false + type: bool + required: false requirements: - matrix-nio (Python library) + - requests (Python library) """ EXAMPLES = """ @@ -99,6 +109,10 @@ try: from ansible_collections.famedly.matrix.plugins.module_utils.matrix import ( AnsibleNioModule, ) + from ansible_collections.famedly.matrix.plugins.module_utils.synapse import ( + AdminApi, + Exceptions, + ) from nio import ( RoomGetStateError, RoomBanError, @@ -176,12 +190,19 @@ async def invite_to_room(client, room_id, user_id, res): res["invited"].append(user_id) +async def force_join_into_room(admin_client, room_id, user_id, res): + admin_client.join.join(room_id, user_id) + res["changed"] = True + res["joined"].append(user_id) + + async def run_module(): module_args = dict( state=dict(choices=["member", "kicked", "banned"], required=True), room_id=dict(type="str", required=True), user_ids=dict(type="list", required=True, elements="str"), exclusive=dict(type="bool", required=False, default=False), + force_join=dict(type="bool", required=False, default=False), ) result = dict( @@ -190,6 +211,7 @@ async def run_module(): unbanned=[], kicked=[], invited=[], + joined=[], members=[], msg="", ) @@ -202,10 +224,13 @@ async def run_module(): action = module.params["state"] room_id = module.params["room_id"] user_ids = module.params["user_ids"] + force_join = module.params["force_join"] # Check for valid parameter combination if module.params["exclusive"] and action != "member": await module.fail_json(msg="exclusive=True can only be used with state=member") + if module.params["force_join"] and action != "member": + await module.fail_json(msg="force_join=True can only be used with state=member") # Handle ansible check mode if module.check_mode: @@ -215,6 +240,9 @@ async def run_module(): # Create client object client = module.client + admin_client = AdminApi( + home_server=module.params["hs_url"], access_token=module.params["token"] + ) try: # Query all room members (invited users count as member, as they _can_ be in the room) @@ -233,7 +261,12 @@ async def run_module(): if action == "member" and user_id not in present_members: if user_id in banned_members: await unban_from_room(client, room_id, user_id, result) - await invite_to_room(client, room_id, user_id, result) + if force_join: + await force_join_into_room( + admin_client, room_id, user_id, result + ) + else: + await invite_to_room(client, room_id, user_id, result) elif action == "kicked" and user_id in present_members: await kick_from_room(client, room_id, user_id, result) elif action == "kicked" and user_id in banned_members: @@ -242,6 +275,9 @@ async def run_module(): await ban_from_room(client, room_id, user_id, result) except NioOperationError: await module.fail_json(**result) + except (Exceptions.HTTPException, Exceptions.MatrixException) as e: + result["msg"] = str(e) + await module.fail_json(**result) else: # Handle exclusive mode: get state and make lists of users to be kicked or invited to_invite = list(filter(lambda m: m not in present_members, user_ids)) @@ -249,11 +285,17 @@ async def run_module(): try: for user_id in to_invite: - await invite_to_room(client, room_id, user_id, result) + if force_join: + await force_join_into_room(admin_client, room_id, user_id, result) + else: + await invite_to_room(client, room_id, user_id, result) for user_id in to_kick: await kick_from_room(client, room_id, user_id, result) except NioOperationError: await module.fail_json(**result) + except (Exceptions.HTTPException, Exceptions.MatrixException) as e: + result["msg"] = str(e) + await module.fail_json(**result) # Get all current members from the room try: diff --git a/tests/unit/test_AnsibleModuleMatrixMember.py b/tests/unit/test_AnsibleModuleMatrixMember.py index 753c196..2d893cd 100644 --- a/tests/unit/test_AnsibleModuleMatrixMember.py +++ b/tests/unit/test_AnsibleModuleMatrixMember.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, print_function, annotations +import json import types import pytest @@ -19,6 +20,11 @@ from ansible_collections.famedly.matrix.tests.unit.mock_nio.utils.RoomSimulator ) from ansible_collections.famedly.matrix.tests.unit.mock_nio.room import failure +from ansible_collections.famedly.matrix.plugins.module_utils import synapse +from ansible_collections.famedly.matrix.tests.unit.mock_synapse.requests.RequestsBase import ( + RequestsBase, +) + from ansible_collections.famedly.matrix.tests.unit.utils import ( AnsibleExitJson, AnsibleFailJson, @@ -71,6 +77,20 @@ class TestAnsibleModuleMatrixMember: ) monkeypatch.setenv("ROOM_SIMULATOR", simulator.export()) + @staticmethod + def patchAdminApiModule( + monkeypatch: MonkeyPatch, target_module, mock_class: type(RequestsBase) + ): + # Mock Admin API + for method in RequestsBase.__dict__: + if isinstance( + getattr(mock_class, method), + (types.FunctionType, types.BuiltinFunctionType), + ): + monkeypatch.setattr( + synapse.requests, method, getattr(mock_class, method) + ) + def test_check_mode(self, monkeypatch): self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) set_module_args( @@ -82,7 +102,7 @@ class TestAnsibleModuleMatrixMember: "state": "member", "user_ids": ["@user1:matrix.example.tld", "@user2:matrix.example.tld"], }, - check_mode=True + check_mode=True, ) with pytest.raises(AnsibleExitJson) as result: matrix_member.main() @@ -242,7 +262,7 @@ class TestAnsibleModuleMatrixMember: "room_id": "!myroomid:matrix.example.tld", "state": "member", "exclusive": True, - "user_ids": ["@user2:matrix.example.tld"], + "user_ids": ["@user2:matrix.example.tld", "@user3:matrix.example.tld"], } ) with pytest.raises(AnsibleExitJson) as result: @@ -252,9 +272,132 @@ class TestAnsibleModuleMatrixMember: assert_expression(ansible_result["banned"] == []) assert_expression(ansible_result["unbanned"] == []) assert_expression(ansible_result["kicked"] == ["@user1:matrix.example.tld"]) - assert_expression(ansible_result["invited"] == []) + assert_expression(ansible_result["invited"] == ["@user3:matrix.example.tld"]) assert_expression( - list(ansible_result["members"]) == ["@user2:matrix.example.tld"] + list(ansible_result["members"]) + == ["@user2:matrix.example.tld", "@user3:matrix.example.tld"] + ) + + def test_force_join(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "supersecrettoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "member", + "force_join": True, + "user_ids": ["@user3:matrix.example.tld"], + } + ) + response = { + "_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": { + "status": 200, + "content": '{"room_id": "!myroomid:matrix.example.tld"}', + } + } + monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response)) + with pytest.raises(AnsibleExitJson) as result: + matrix_member.main() + ansible_result = result.value.result + print(ansible_result) + assert_expression(ansible_result["changed"] is True) + assert_expression(ansible_result["banned"] == []) + assert_expression(ansible_result["unbanned"] == []) + assert_expression(ansible_result["kicked"] == []) + assert_expression(ansible_result["invited"] == []) + assert_expression(ansible_result["joined"] == ["@user3:matrix.example.tld"]) + # TODO: make this assertion work. Needs proper mocking of Admin API that + # calls the mocked nio in order to have the members changed + # assert_expression( + # list(ansible_result["members"]) + # == [ + # "@user1:matrix.example.tld", + # "@user2:matrix.example.tld", + # "@user3:matrix.example.tld", + # ] + # ) + + def test_exclusive_force_join(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "supersecrettoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "member", + "force_join": True, + "exclusive": True, + "user_ids": ["@user3:matrix.example.tld"], + } + ) + response = { + "_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": { + "status": 200, + "content": '{"room_id": "!myroomid:matrix.example.tld"}', + } + } + monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response)) + with pytest.raises(AnsibleExitJson) as result: + matrix_member.main() + ansible_result = result.value.result + print(ansible_result) + assert_expression(ansible_result["changed"] is True) + assert_expression(ansible_result["banned"] == []) + assert_expression(ansible_result["unbanned"] == []) + assert_expression( + ansible_result["kicked"] + == ["@user1:matrix.example.tld", "@user2:matrix.example.tld"] + ) + assert_expression(ansible_result["invited"] == []) + assert_expression(ansible_result["joined"] == ["@user3:matrix.example.tld"]) + # TODO: make this assertion work. Needs proper mocking of Admin API that + # calls the mocked nio in order to have the members changed + # assert_expression( + # list(ansible_result["members"]) + # == [ + # "@user3:matrix.example.tld", + # ] + # ) + + def test_exclusive_kick_fail(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "supersecrettoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "kicked", + "user_ids": ["@user1:matrix.example.tld"], + "exclusive": True, + }, + ) + with pytest.raises(AnsibleFailJson) as result: + matrix_member.main() + assert_expression( + "exclusive=True can only be used with state=member" + in result.value.result["msg"] + ) + + def test_force_join_kick_fail(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "supersecrettoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "kicked", + "user_ids": ["@user1:matrix.example.tld"], + "force_join": True, + }, + ) + with pytest.raises(AnsibleFailJson) as result: + matrix_member.main() + assert_expression( + "force_join=True can only be used with state=member" + in result.value.result["msg"] ) def test_add_user_fail(self, monkeypatch): @@ -350,3 +493,54 @@ class TestAnsibleModuleMatrixMember: ) with pytest.raises(AnsibleFailJson) as result: matrix_member.main() + + def test_force_join_fail_privileges(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "notaserveradmintoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "member", + "force_join": True, + "user_ids": ["@user3:matrix.example.tld"], + } + ) + response = { + "_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": { + "status": 403, + "content": '{"errcode": "M_FORBIDDEN", "error": "You are not a server admin"}', + } + } + monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response)) + with pytest.raises(AnsibleFailJson) as result: + matrix_member.main() + print(result.value.result["msg"]) + assert_expression("M_FORBIDDEN" in result.value.result["msg"]) + + def test_exclusive_force_join_fail_privileges(self, monkeypatch): + self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess) + self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase) + set_module_args( + { + "hs_url": "matrix.example.tld", + "token": "notaserveradmintoken", + "room_id": "!myroomid:matrix.example.tld", + "state": "member", + "force_join": True, + "exclusive": True, + "user_ids": ["@user3:matrix.example.tld"], + } + ) + response = { + "_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": { + "status": 403, + "content": '{"errcode": "M_FORBIDDEN", "error": "You are not a server admin"}', + } + } + monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response)) + with pytest.raises(AnsibleFailJson) as result: + matrix_member.main() + print(result.value.result["msg"]) + assert_expression("M_FORBIDDEN" in result.value.result["msg"])