diff --git a/matrix_alertbot/callback.py b/matrix_alertbot/callback.py index 89ef4e2..bf5a70d 100644 --- a/matrix_alertbot/callback.py +++ b/matrix_alertbot/callback.py @@ -5,7 +5,6 @@ import logging from diskcache import Cache from nio.client import AsyncClient from nio.events import ( - Event, InviteMemberEvent, KeyVerificationCancel, KeyVerificationKey, @@ -470,10 +469,3 @@ class Callbacks: raise SendRetryError( f"{response_event.status_code} - {response_event.message}" ) - - async def debug(self, room: MatrixRoom, event: Event) -> None: - logger.debug( - f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | " - f"Event ID {event.event_id} | Sender {event.sender} | " - f"Received some event: {event.source}" - ) diff --git a/pytest.ini b/pytest.ini index 2f6c8d1..e365e4c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] asyncio_mode=strict +addopts=--cov=matrix_alertbot --cov-report=lcov:lcov.info --cov-report=term diff --git a/setup.cfg b/setup.cfg index 2dc6b01..76e5a39 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,7 +50,8 @@ test = flake8-comprehensions>=3.10.0 isort>=5.10.1 mypy>=0.961 - pytest>=7.1.2 + pytest>=7.4.0 + pytest-cov>=4.1.0 pytest-asyncio>=0.18.3 freezegun>=1.2.1 types-PyYAML>=6.0.9 diff --git a/tests/test_alertmanager.py b/tests/test_alertmanager.py index f3987d3..7ad71ca 100644 --- a/tests/test_alertmanager.py +++ b/tests/test_alertmanager.py @@ -24,12 +24,6 @@ from matrix_alertbot.errors import ( ) -async def update_silence_raise_silence_not_found( - fingerprint: str, user: str, duration_seconds: int, *, force: bool = False -) -> str: - raise SilenceNotFoundError - - class FakeCache: def __init__(self, cache_dict: Optional[Dict] = None) -> None: if cache_dict is None: @@ -533,14 +527,20 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) self.assertEqual({"fingerprint1": ("silence2", 864000)}, fake_cache.cache) - @patch.object(matrix_alertbot.alertmanager.AlertmanagerClient, "update_silence") - @patch.object(matrix_alertbot.alertmanager.AlertmanagerClient, "create_silence") + @patch.object( + matrix_alertbot.alertmanager.AlertmanagerClient, + "update_silence", + side_effect=SilenceNotFoundError, + ) + @patch.object( + matrix_alertbot.alertmanager.AlertmanagerClient, + "create_silence", + return_value="silence1", + ) async def test_create_or_update_silence_with_duration_and_silence_not_found( self, fake_create_silence: Mock, fake_update_silence: Mock ) -> None: fake_cache = Mock(spec=Cache) - fake_update_silence.side_effect = update_silence_raise_silence_not_found - fake_create_silence.return_value = "silence1" alertmanager_client = AlertmanagerClient("http://localhost", fake_cache) async with aiotools.closing_async(alertmanager_client): @@ -651,14 +651,20 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) self.assertEqual({"fingerprint1": ("silence2", None)}, fake_cache.cache) - @patch.object(matrix_alertbot.alertmanager.AlertmanagerClient, "update_silence") - @patch.object(matrix_alertbot.alertmanager.AlertmanagerClient, "create_silence") + @patch.object( + matrix_alertbot.alertmanager.AlertmanagerClient, + "update_silence", + side_effect=SilenceNotFoundError, + ) + @patch.object( + matrix_alertbot.alertmanager.AlertmanagerClient, + "create_silence", + return_value="silence1", + ) async def test_create_or_update_silence_without_duration_and_silence_not_found( self, fake_create_silence: Mock, fake_update_silence: Mock ) -> None: fake_cache = Mock(spec=Cache) - fake_update_silence.side_effect = update_silence_raise_silence_not_found - fake_create_silence.return_value = "silence1" alertmanager_client = AlertmanagerClient("http://localhost", fake_cache) async with aiotools.closing_async(alertmanager_client): diff --git a/tests/test_callback.py b/tests/test_callback.py index d3e288c..bd0b771 100644 --- a/tests/test_callback.py +++ b/tests/test_callback.py @@ -2,7 +2,7 @@ from __future__ import annotations import unittest from typing import Dict -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import nio import nio.crypto @@ -14,10 +14,6 @@ import matrix_alertbot.command import matrix_alertbot.matrix -def key_verification_get_mac_raise_protocol_error(): - raise nio.LocalProtocolError - - class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: # Create a Callbacks object and give it some Mock'd objects to use @@ -67,6 +63,42 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): # Check that we attempted to join the room self.fake_matrix_client.join.assert_called_once_with(self.fake_room.room_id) + async def test_invite_in_unauthorized_room(self) -> None: + """Tests the callback for InviteMemberEvents""" + # Tests that the bot attempts to join a room after being invited to it + fake_invite_event = Mock(spec=nio.InviteMemberEvent) + fake_invite_event.sender = "@some_other_fake_user:example.com" + + self.fake_room.room_id = "!unauthorizedroom@example.com" + + # Pretend that we received an invite event + await self.callbacks.invite(self.fake_room, fake_invite_event) + + # Check that we attempted to join the room + self.fake_matrix_client.join.assert_not_called() + + async def test_invite_raise_join_error(self) -> None: + """Tests the callback for InviteMemberEvents""" + # Tests that the bot attempts to join a room after being invited to it + fake_invite_event = Mock(spec=nio.InviteMemberEvent) + fake_invite_event.sender = "@some_other_fake_user:example.com" + + fake_join_error = Mock(spec=nio.JoinError) + fake_join_error.message = "error message" + self.fake_matrix_client.join.return_value = fake_join_error + + # Pretend that we received an invite event + await self.callbacks.invite(self.fake_room, fake_invite_event) + + # Check that we attempted to join the room + self.fake_matrix_client.join.assert_has_calls( + [ + call("!abcdefg:example.com"), + call("!abcdefg:example.com"), + call("!abcdefg:example.com"), + ] + ) + @patch.object(matrix_alertbot.callback.CommandFactory, "create", autospec=True) async def test_message_without_prefix(self, fake_command_create: Mock) -> None: """Tests the callback for RoomMessageText without any command prefix""" @@ -82,6 +114,24 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): # Check that the command was not executed fake_command_create.assert_not_called() + @patch.object(matrix_alertbot.command, "HelpCommand", autospec=True) + async def test_message_help_client_not_in_pool(self, fake_command: Mock) -> None: + """Tests the callback for RoomMessageText without any command prefix""" + # Tests that the bot process messages in the room + fake_message_event = Mock(spec=nio.RoomMessageText) + fake_message_event.event_id = "some event id" + fake_message_event.sender = "@some_other_fake_user:example.com" + fake_message_event.body = "!alert help" + fake_message_event.source = {"content": {}} + + self.fake_matrix_client_pool.matrix_client = None + + # Pretend that we received a text message event + await self.callbacks.message(self.fake_room, fake_message_event) + + # Check that the command was not executed + fake_command.assert_not_called() + @patch.object(matrix_alertbot.command, "HelpCommand", autospec=True) async def test_message_help_not_in_reply_with_prefix( self, fake_command: Mock @@ -271,6 +321,72 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): ) fake_command.return_value.process.assert_called_once() + @patch.object(matrix_alertbot.callback, "logger", autospec=True) + @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) + async def test_message_raise_exception( + self, fake_command: Mock, fake_logger + ) -> None: + """Tests the callback for RoomMessageText with the command prefix""" + # Tests that the bot process messages in the room that contain a command + fake_message_event = Mock(spec=nio.RoomMessageText) + fake_message_event.event_id = "some event id" + fake_message_event.sender = "@some_other_fake_user:example.com" + fake_message_event.body = "!alert ack" + fake_message_event.source = { + "content": { + "m.relates_to": {"m.in_reply_to": {"event_id": "some alert event id"}} + } + } + + fake_command.return_value.process.side_effect = ( + nio.exceptions.LocalProtocolError + ) + + # Pretend that we received a text message event + await self.callbacks.message(self.fake_room, fake_message_event) + + # Check that the command was not executed + fake_command.assert_called_once_with( + self.fake_matrix_client, + self.fake_cache, + self.fake_alertmanager_client, + self.fake_config, + self.fake_room, + fake_message_event.sender, + fake_message_event.event_id, + "some alert event id", + (), + ) + fake_command.return_value.process.assert_called_once() + + fake_logger.exception.assert_called_once() + + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_reaction_client_not_in_pool(self, fake_command: Mock) -> None: + """Tests the callback for RoomMessageText with the command prefix""" + # Tests that the bot process messages in the room that contain a command + fake_alert_event = Mock(spec=nio.RoomMessageText) + fake_alert_event.event_id = "some alert event id" + fake_alert_event.sender = self.fake_matrix_client.user_id + + fake_reaction_event = Mock(spec=nio.ReactionEvent) + fake_reaction_event.event_id = "some event id" + fake_reaction_event.sender = "@some_other_fake_user:example.com" + fake_reaction_event.reacts_to = fake_alert_event.event_id + fake_reaction_event.key = "🤫" + + fake_event_response = Mock(spec=nio.RoomGetEventResponse) + fake_event_response.event = fake_alert_event + self.fake_matrix_client.room_get_event.return_value = fake_event_response + + self.fake_matrix_client_pool.matrix_client = None + + # Pretend that we received a text message event + await self.callbacks.reaction(self.fake_room, fake_reaction_event) + + # Check that we attempted to execute the command + fake_command.assert_not_called() + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) async def test_reaction_to_existing_alert(self, fake_command: Mock) -> None: """Tests the callback for RoomMessageText with the command prefix""" @@ -365,6 +481,52 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_room.room_id, fake_alert_event.event_id ) + @patch.object(matrix_alertbot.callback, "logger", autospec=True) + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_reaction_raise_exception( + self, fake_command: Mock, fake_logger: Mock + ) -> None: + """Tests the callback for RoomMessageText with the command prefix""" + # Tests that the bot process messages in the room that contain a command + fake_alert_event = Mock(spec=nio.RoomMessageText) + fake_alert_event.event_id = "some alert event id" + fake_alert_event.sender = self.fake_matrix_client.user_id + + fake_reaction_event = Mock(spec=nio.ReactionEvent) + fake_reaction_event.event_id = "some event id" + fake_reaction_event.sender = "@some_other_fake_user:example.com" + fake_reaction_event.reacts_to = fake_alert_event.event_id + fake_reaction_event.key = "🤫" + + fake_event_response = Mock(spec=nio.RoomGetEventResponse) + fake_event_response.event = fake_alert_event + self.fake_matrix_client.room_get_event.return_value = fake_event_response + + fake_command.return_value.process.side_effect = ( + nio.exceptions.LocalProtocolError + ) + + # Pretend that we received a text message event + await self.callbacks.reaction(self.fake_room, fake_reaction_event) + + # Check that we attempted to execute the command + fake_command.assert_called_once_with( + self.fake_matrix_client, + self.fake_cache, + self.fake_alertmanager_client, + self.fake_config, + self.fake_room, + fake_reaction_event.sender, + fake_reaction_event.event_id, + "some alert event id", + ) + fake_command.return_value.process.assert_called_once() + self.fake_matrix_client.room_get_event.assert_called_once_with( + self.fake_room.room_id, fake_alert_event.event_id + ) + + fake_logger.exception.assert_called_once() + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) async def test_reaction_unknown(self, fake_command: Mock) -> None: """Tests the callback for RoomMessageText with the command prefix""" @@ -429,6 +591,28 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_command.assert_not_called() self.fake_matrix_client.room_get_event.assert_not_called() + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) + async def test_redaction_client_not_in_pool(self, fake_command: Mock) -> None: + """Tests the callback for RoomMessageText with the command prefix""" + # Tests that the bot process messages in the room that contain a command + fake_alert_event_id = "some alert event id" + + fake_redaction_event = Mock(spec=nio.RedactionEvent) + fake_redaction_event.redacts = "some other event id" + fake_redaction_event.event_id = "some event id" + fake_redaction_event.sender = "@some_other_fake_user:example.com" + + fake_cache_dict = {fake_redaction_event.redacts: fake_alert_event_id} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + + self.fake_matrix_client_pool.matrix_client = None + + # Pretend that we received a text message event + await self.callbacks.redaction(self.fake_room, fake_redaction_event) + + # Check that we attempted to execute the command + fake_command.assert_not_called() + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) async def test_redaction(self, fake_command: Mock) -> None: """Tests the callback for RoomMessageText with the command prefix""" @@ -459,6 +643,45 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): ) fake_command.return_value.process.assert_called_once() + @patch.object(matrix_alertbot.callback, "logger", autospec=True) + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) + async def test_redaction_raise_exception( + self, fake_command: Mock, fake_logger + ) -> None: + """Tests the callback for RoomMessageText with the command prefix""" + # Tests that the bot process messages in the room that contain a command + fake_alert_event_id = "some alert event id" + + fake_redaction_event = Mock(spec=nio.RedactionEvent) + fake_redaction_event.redacts = "some other event id" + fake_redaction_event.event_id = "some event id" + fake_redaction_event.sender = "@some_other_fake_user:example.com" + + fake_cache_dict = {fake_redaction_event.redacts: fake_alert_event_id} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + + fake_command.return_value.process.side_effect = ( + nio.exceptions.LocalProtocolError + ) + + # Pretend that we received a text message event + await self.callbacks.redaction(self.fake_room, fake_redaction_event) + + # Check that we attempted to execute the command + fake_command.assert_called_once_with( + self.fake_matrix_client, + self.fake_cache, + self.fake_alertmanager_client, + self.fake_config, + self.fake_room, + fake_redaction_event.sender, + fake_redaction_event.event_id, + fake_redaction_event.redacts, + ) + fake_command.return_value.process.assert_called_once() + + fake_logger.exception.assert_called_once() + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) async def test_ignore_redaction_sent_by_bot_user(self, fake_command: Mock) -> None: """Tests the callback for RoomMessageText with the command prefix""" @@ -718,7 +941,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_key_verification_event.transaction_id = fake_transaction_id fake_sas = Mock() - fake_sas.get_mac.side_effect = key_verification_get_mac_raise_protocol_error + fake_sas.get_mac.side_effect = nio.exceptions.LocalProtocolError fake_transactions_dict = {fake_transaction_id: fake_sas} self.fake_matrix_client.key_verifications = fake_transactions_dict @@ -751,6 +974,81 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_sas.get_mac.assert_called_once_with() self.fake_matrix_client.to_device.assert_called_once_with(fake_sas.get_mac()) + @patch.object(matrix_alertbot.callback, "logger", autospec=True) + async def test_decryption_failure(self, fake_logger) -> None: + fake_megolm_event = Mock(spec=nio.MegolmEvent) + fake_megolm_event.sender = "@some_other_fake_user:example.com" + fake_megolm_event.event_id = "some event id" + + await self.callbacks.decryption_failure(self.fake_room, fake_megolm_event) + + fake_logger.error.assert_called_once() + + @patch.object(matrix_alertbot.callback, "logger", autospec=True) + async def test_decryption_failure_in_unauthorized_room(self, fake_logger) -> None: + fake_megolm_event = Mock(spec=nio.MegolmEvent) + fake_megolm_event.sender = "@some_other_fake_user:example.com" + fake_megolm_event.event_id = "some event id" + + self.fake_room.room_id = "!unauthorizedroom@example.com" + + await self.callbacks.decryption_failure(self.fake_room, fake_megolm_event) + + fake_logger.error.assert_not_called() + + async def test_unknown_message(self) -> None: + fake_room_unknown_event = Mock(spec=nio.RoomMessageUnknown) + fake_room_unknown_event.source = { + "content": { + "msgtype": "m.key.verification.request", + "methods": ["m.sas.v1"], + } + } + fake_room_unknown_event.event_id = "some event id" + + await self.callbacks.unknown_message(self.fake_room, fake_room_unknown_event) + + self.fake_matrix_client.room_send.assert_called_once_with( + self.fake_room.room_id, + "m.room.message", + { + "msgtype": "m.key.verification.ready", + "methods": ["m.sas.v1"], + "m.relates_to": { + "rel_type": "m.reference", + "event_id": fake_room_unknown_event.event_id, + }, + }, + ) + + async def test_unknown_message_with_msgtype_not_verification_request(self) -> None: + fake_room_unknown_event = Mock(spec=nio.RoomMessageUnknown) + fake_room_unknown_event.source = { + "content": { + "msgtype": "unknown", + "methods": ["m.sas.v1"], + } + } + fake_room_unknown_event.event_id = "some event id" + + await self.callbacks.unknown_message(self.fake_room, fake_room_unknown_event) + + self.fake_matrix_client.room_send.assert_not_called() + + async def test_unknown_message_with_method_not_sas_v1(self) -> None: + fake_room_unknown_event = Mock(spec=nio.RoomMessageUnknown) + fake_room_unknown_event.source = { + "content": { + "msgtype": "m.key.verification.request", + "methods": [], + } + } + fake_room_unknown_event.event_id = "some event id" + + await self.callbacks.unknown_message(self.fake_room, fake_room_unknown_event) + + self.fake_matrix_client.room_send.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_chat_functions.py b/tests/test_chat_functions.py index 93732e9..95cda10 100644 --- a/tests/test_chat_functions.py +++ b/tests/test_chat_functions.py @@ -1,5 +1,4 @@ import unittest -from typing import Any, Dict, Optional from unittest.mock import Mock import nio @@ -11,16 +10,6 @@ from matrix_alertbot.chat_functions import ( ) -async def send_room_raise_send_retry_error( - room_id: str, - message_type: str, - content: Dict[Any, Any], - tx_id: Optional[str] = None, - ignore_unverified_devices: bool = False, -) -> nio.RoomSendResponse: - raise nio.SendRetryError - - class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: pass @@ -179,7 +168,7 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase): async def test_send_text_to_room_raise_send_retry_error(self) -> None: fake_matrix_client = Mock(spec=nio.AsyncClient) - fake_matrix_client.room_send.side_effect = send_room_raise_send_retry_error + fake_matrix_client.room_send.side_effect = nio.exceptions.SendRetryError fake_room_id = "!abcdefgh:example.com" fake_plaintext_body = "some plaintext message" diff --git a/tests/test_config.py b/tests/test_config.py index 8ef0f68..e30ab63 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,12 @@ import os +import sys import unittest from datetime import timedelta from unittest.mock import Mock, patch import yaml +import matrix_alertbot.config from matrix_alertbot.config import DEFAULT_REACTIONS, Config from matrix_alertbot.errors import ( InvalidConfigError, @@ -38,8 +40,15 @@ class ConfigTestCase(unittest.TestCase): @patch("os.path.isdir") @patch("os.path.exists") @patch("os.mkdir") + @patch.object(matrix_alertbot.config, "logger", autospec=True) + @patch.object(matrix_alertbot.config, "logging", autospec=True) def test_read_minimal_config( - self, fake_mkdir: Mock, fake_path_exists: Mock, fake_path_isdir: Mock + self, + fake_logging: Mock, + fake_logger: Mock, + fake_mkdir: Mock, + fake_path_exists: Mock, + fake_path_isdir: Mock, ) -> None: fake_path_isdir.return_value = False fake_path_exists.return_value = False @@ -51,6 +60,11 @@ class ConfigTestCase(unittest.TestCase): fake_path_exists.assert_called_once_with("data/store") fake_mkdir.assert_called_once_with("data/store") + fake_logger.setLevel.assert_called_once_with("INFO") + fake_logger.addHandler.assert_called_once() + fake_logging.StreamHandler.return_value.setLevel("INFO") + fake_logging.StreamHandler.assert_called_once_with(sys.stdout) + self.assertEqual({"@fakes_user:matrix.example.com"}, config.user_ids) self.assertEqual(1, len(config.accounts)) self.assertEqual("password", config.accounts[0].password) @@ -82,8 +96,15 @@ class ConfigTestCase(unittest.TestCase): @patch("os.path.isdir") @patch("os.path.exists") @patch("os.mkdir") + @patch.object(matrix_alertbot.config, "logger", autospec=True) + @patch.object(matrix_alertbot.config, "logging", autospec=True) def test_read_full_config( - self, fake_mkdir: Mock, fake_path_exists: Mock, fake_path_isdir: Mock + self, + fake_logging: Mock, + fake_logger: Mock, + fake_mkdir: Mock, + fake_path_exists: Mock, + fake_path_isdir: Mock, ) -> None: fake_path_isdir.return_value = False fake_path_exists.return_value = False @@ -95,6 +116,11 @@ class ConfigTestCase(unittest.TestCase): fake_path_exists.assert_called_once_with("data/store") fake_mkdir.assert_called_once_with("data/store") + fake_logger.setLevel.assert_called_once_with("DEBUG") + fake_logger.addHandler.assert_called_once() + fake_logging.FileHandler.return_value.setLevel("DEBUG") + fake_logging.FileHandler.assert_called_once_with("fake.log") + self.assertEqual( {"@fakes_user:matrix.example.com", "@other_user:matrix.domain.tld"}, config.user_ids, @@ -333,6 +359,60 @@ class ConfigTestCase(unittest.TestCase): with self.assertRaises(InvalidConfigError): config._parse_config_values() + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + @patch.object(matrix_alertbot.config, "logger") + def test_parse_config_with_both_logging_disabled( + self, + fake_logger: Mock, + fake_mkdir: Mock, + fake_path_exists: Mock, + fake_path_isdir: Mock, + ) -> None: + fake_path_isdir.return_value = False + fake_path_exists.return_value = False + + config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.full.yml") + config = DummyConfig(config_path) + config.config_dict["logging"]["file_logging"]["enabled"] = False + config.config_dict["logging"]["console_logging"]["enabled"] = False + + config._parse_config_values() + + fake_logger.addHandler.assert_not_called() + fake_logger.setLevel.assert_called_once_with("DEBUG") + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + @patch.object(matrix_alertbot.config, "logger", autospec=True) + @patch.object(matrix_alertbot.config, "logging", autospec=True) + def test_parse_config_with_level_logging_different( + self, + fake_logging: Mock, + fake_logger: Mock, + fake_mkdir: Mock, + fake_path_exists: Mock, + fake_path_isdir: Mock, + ) -> None: + fake_path_isdir.return_value = False + fake_path_exists.return_value = False + + config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.full.yml") + config = DummyConfig(config_path) + config.config_dict["logging"]["file_logging"]["enabled"] = True + config.config_dict["logging"]["file_logging"]["level"] = "WARN" + config.config_dict["logging"]["console_logging"]["enabled"] = True + config.config_dict["logging"]["console_logging"]["level"] = "ERROR" + + config._parse_config_values() + + self.assertEqual(2, fake_logger.addHandler.call_count) + fake_logger.setLevel.assert_called_once_with("DEBUG") + fake_logging.FileHandler.return_value.setLevel.assert_called_with("WARN") + fake_logging.StreamHandler.return_value.setLevel.assert_called_with("ERROR") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_matrix.py b/tests/test_matrix.py new file mode 100644 index 0000000..4555bde --- /dev/null +++ b/tests/test_matrix.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import random +import unittest +from unittest.mock import Mock, call, patch + +import nio +from diskcache import Cache + +import matrix_alertbot +import matrix_alertbot.matrix +from matrix_alertbot.alertmanager import AlertmanagerClient +from matrix_alertbot.config import AccountConfig, Config +from matrix_alertbot.matrix import MatrixClientPool + + +def mock_create_matrix_client( + matrix_client_pool: MatrixClientPool, + account: AccountConfig, + alertmanager_client: AlertmanagerClient, + cache: Cache, + config: Config, +) -> nio.AsyncClient: + fake_matrix_client = Mock(spec=nio.AsyncClient) + fake_matrix_client.logged_in = True + return fake_matrix_client + + +class FakeAsyncClientConfig: + def __init__( + self, + max_limit_exceeded: int, + max_timeouts: int, + store_sync_tokens: bool, + encryption_enabled: bool, + ) -> None: + if encryption_enabled: + raise ImportWarning() + + self.max_limit_exceeded = max_limit_exceeded + self.max_timeouts = max_timeouts + self.store_sync_tokens = store_sync_tokens + self.encryption_enabled = encryption_enabled + + +class MatrixClientPoolTestCase(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + random.seed(42) + + self.fake_alertmanager_client = Mock(spec=AlertmanagerClient) + self.fake_cache = Mock(spec=Cache) + + self.fake_account_config_1 = Mock(spec=AccountConfig) + self.fake_account_config_1.id = "@fake_user:matrix.example.com" + self.fake_account_config_1.homeserver_url = "https://matrix.example.com" + self.fake_account_config_1.device_id = "ABCDEFGH" + self.fake_account_config_1.token_file = "account1.token.secret" + self.fake_account_config_2 = Mock(spec=AccountConfig) + self.fake_account_config_2.id = "@other_user:chat.example.com" + self.fake_account_config_2.homeserver_url = "https://chat.example.com" + self.fake_account_config_2.device_id = "IJKLMNOP" + self.fake_account_config_2.token_file = "account2.token.secret" + self.fake_config = Mock(spec=Config) + self.fake_config.store_dir = "/dev/null" + self.fake_config.command_prefix = "!alert" + self.fake_config.accounts = [ + self.fake_account_config_1, + self.fake_account_config_2, + ] + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, "_create_matrix_client", autospec=True + ) + async def test_init_matrix_client_pool(self, fake_create_matrix_client) -> None: + fake_matrix_client = Mock(spec=nio.AsyncClient) + fake_create_matrix_client.return_value = fake_matrix_client + + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + fake_create_matrix_client.assert_has_calls( + [ + call( + matrix_client_pool, + self.fake_account_config_1, + self.fake_alertmanager_client, + self.fake_cache, + self.fake_config, + ), + call( + matrix_client_pool, + self.fake_account_config_2, + self.fake_alertmanager_client, + self.fake_cache, + self.fake_config, + ), + ] + ) + + self.assertEqual(self.fake_account_config_1, matrix_client_pool.account) + self.assertEqual(fake_matrix_client, matrix_client_pool.matrix_client) + self.assertEqual(2, len(matrix_client_pool._accounts)) + self.assertEqual(2, len(matrix_client_pool._matrix_clients)) + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, "_create_matrix_client", autospec=True + ) + async def test_close_matrix_client_pool(self, fake_create_matrix_client) -> None: + fake_matrix_client = Mock(spec=nio.AsyncClient) + fake_create_matrix_client.return_value = fake_matrix_client + + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + await matrix_client_pool.close() + + fake_matrix_client.close.assert_has_calls([(call(), call())]) + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, + "_create_matrix_client", + autospec=True, + side_effect=mock_create_matrix_client, + ) + async def test_switch_active_client(self, fake_create_matrix_client) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + fake_matrix_client_1 = matrix_client_pool.matrix_client + await matrix_client_pool.switch_active_client() + fake_matrix_client_2 = matrix_client_pool.matrix_client + + self.assertEqual(self.fake_account_config_2, matrix_client_pool.account) + self.assertNotEqual(fake_matrix_client_2, fake_matrix_client_1) + + await matrix_client_pool.switch_active_client() + fake_matrix_client_3 = matrix_client_pool.matrix_client + + self.assertEqual(self.fake_account_config_1, matrix_client_pool.account) + self.assertEqual(fake_matrix_client_3, fake_matrix_client_1) + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, + "_create_matrix_client", + autospec=True, + side_effect=mock_create_matrix_client, + ) + async def test_switch_active_client_with_whoami_raise_exception( + self, fake_create_matrix_client + ) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + for fake_matrix_client in matrix_client_pool._matrix_clients.values(): + fake_matrix_client.whoami.side_effect = Exception + + fake_matrix_client_1 = matrix_client_pool.matrix_client + await matrix_client_pool.switch_active_client() + fake_matrix_client_2 = matrix_client_pool.matrix_client + + self.assertEqual(self.fake_account_config_1, matrix_client_pool.account) + self.assertEqual(fake_matrix_client_2, fake_matrix_client_1) + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, + "_create_matrix_client", + autospec=True, + side_effect=mock_create_matrix_client, + ) + async def test_switch_active_client_with_whoami_error( + self, fake_create_matrix_client + ) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + for fake_matrix_client in matrix_client_pool._matrix_clients.values(): + fake_matrix_client.whoami.return_value = Mock( + spec=nio.responses.WhoamiError + ) + + fake_matrix_client_1 = matrix_client_pool.matrix_client + await matrix_client_pool.switch_active_client() + fake_matrix_client_2 = matrix_client_pool.matrix_client + + self.assertEqual(self.fake_account_config_1, matrix_client_pool.account) + self.assertEqual(fake_matrix_client_2, fake_matrix_client_1) + + @patch.object( + matrix_alertbot.matrix.MatrixClientPool, + "_create_matrix_client", + autospec=True, + side_effect=mock_create_matrix_client, + ) + async def test_switch_active_client_with_whoami_error_and_not_logged_in( + self, fake_create_matrix_client + ) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + for fake_matrix_client in matrix_client_pool._matrix_clients.values(): + fake_matrix_client.whoami.return_value = Mock( + spec=nio.responses.WhoamiError + ) + fake_matrix_client.logged_in = False + + fake_matrix_client_1 = matrix_client_pool.matrix_client + await matrix_client_pool.switch_active_client() + fake_matrix_client_2 = matrix_client_pool.matrix_client + + self.assertEqual(self.fake_account_config_1, matrix_client_pool.account) + self.assertEqual(fake_matrix_client_2, fake_matrix_client_1) + + @patch.object( + matrix_alertbot.matrix, "AsyncClientConfig", spec=nio.AsyncClientConfig + ) + async def test_create_matrix_client(self, fake_async_client_config: Mock) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + matrix_client_1 = matrix_client_pool._matrix_clients[self.fake_account_config_1] + self.assertEqual(self.fake_account_config_1.id, matrix_client_1.user) + self.assertEqual( + self.fake_account_config_1.device_id, matrix_client_1.device_id + ) + self.assertEqual( + self.fake_account_config_1.homeserver_url, matrix_client_1.homeserver + ) + self.assertEqual(self.fake_config.store_dir, matrix_client_1.store_path) + self.assertEqual(6, len(matrix_client_1.event_callbacks)) + self.assertEqual(4, len(matrix_client_1.to_device_callbacks)) + + fake_async_client_config.assert_has_calls( + [ + call( + max_limit_exceeded=5, + max_timeouts=3, + store_sync_tokens=True, + encryption_enabled=True, + ), + call( + max_limit_exceeded=5, + max_timeouts=3, + store_sync_tokens=True, + encryption_enabled=True, + ), + ] + ) + + @patch.object( + matrix_alertbot.matrix, + "AsyncClientConfig", + spec=nio.AsyncClientConfig, + side_effect=FakeAsyncClientConfig, + ) + async def test_create_matrix_client_with_encryption_disabled( + self, fake_async_client_config: Mock + ) -> None: + matrix_client_pool = MatrixClientPool( + alertmanager_client=self.fake_alertmanager_client, + cache=self.fake_cache, + config=self.fake_config, + ) + + matrix_client_1 = matrix_client_pool._matrix_clients[self.fake_account_config_1] + self.assertEqual(self.fake_account_config_1.id, matrix_client_1.user) + self.assertEqual( + self.fake_account_config_1.device_id, matrix_client_1.device_id + ) + self.assertEqual( + self.fake_account_config_1.homeserver_url, matrix_client_1.homeserver + ) + self.assertEqual(self.fake_config.store_dir, matrix_client_1.store_path) + self.assertEqual(6, len(matrix_client_1.event_callbacks)) + self.assertEqual(4, len(matrix_client_1.to_device_callbacks)) + self.assertEqual(5, matrix_client_1.config.max_limit_exceeded) + self.assertEqual(3, matrix_client_1.config.max_timeouts) + self.assertTrue(matrix_client_1.config.store_sync_tokens) + self.assertFalse(matrix_client_1.config.encryption_enabled) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 8713377..c1fd410 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -4,27 +4,21 @@ from unittest.mock import Mock, call, patch import aiohttp.test_utils import nio -from aiohttp import web +from aiohttp import web, web_request from diskcache import Cache -from nio.exceptions import LocalProtocolError -from nio.responses import RoomSendResponse import matrix_alertbot.webhook +from matrix_alertbot.alert import Alert, AlertRenderer from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.config import Config from matrix_alertbot.errors import ( AlertmanagerError, + MatrixClientError, SilenceExtendError, SilenceNotFoundError, ) from matrix_alertbot.matrix import MatrixClientPool -from matrix_alertbot.webhook import Webhook - - -def send_text_to_room_raise_error( - client: nio.AsyncClient, room_id: str, plaintext: str, html: str, notice: bool -) -> RoomSendResponse: - raise LocalProtocolError +from matrix_alertbot.webhook import Webhook, create_alert def update_silence_raise_silence_not_found(fingerprint: str) -> str: @@ -45,6 +39,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): self.fake_matrix_client_pool = Mock(spec=MatrixClientPool) self.fake_matrix_client_pool.matrix_client = self.fake_matrix_client self.fake_alertmanager_client = Mock(spec=AlertmanagerClient) + self.fake_alert_renderer = Mock(spec=AlertRenderer) self.fake_cache = Mock(spec=Cache) self.fake_room_id = "!abcdefg:example.com" @@ -57,30 +52,41 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): self.fake_config.cache_expire_time = 0 self.fake_config.template_dir = None + self.fake_request = Mock(spec=web_request.Request) + self.fake_request.app = { + "alertmanager_client": self.fake_alertmanager_client, + "alert_renderer": self.fake_alert_renderer, + "matrix_client_pool": self.fake_matrix_client_pool, + "cache": self.fake_cache, + "config": self.fake_config, + } + + self.fake_alert_1 = { + "fingerprint": "fingerprint1", + "generatorURL": "http://example.com/alert1", + "status": "firing", + "labels": { + "alertname": "alert1", + "severity": "critical", + "job": "job1", + }, + "annotations": {"description": "some description1"}, + } + self.fake_alert_2 = { + "fingerprint": "fingerprint2", + "generatorURL": "http://example.com/alert2", + "status": "resolved", + "labels": { + "alertname": "alert2", + "severity": "warning", + "job": "job2", + }, + "annotations": {"description": "some description2"}, + } self.fake_alerts = { "alerts": [ - { - "fingerprint": "fingerprint1", - "generatorURL": "http://example.com/alert1", - "status": "firing", - "labels": { - "alertname": "alert1", - "severity": "critical", - "job": "job1", - }, - "annotations": {"description": "some description1"}, - }, - { - "fingerprint": "fingerprint2", - "generatorURL": "http://example.com/alert2", - "status": "resolved", - "labels": { - "alertname": "alert2", - "severity": "warning", - "job": "job2", - }, - "annotations": {"description": "some description2"}, - }, + self.fake_alert_1, + self.fake_alert_2, ] } @@ -314,13 +320,14 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): self.fake_cache.set.assert_not_called() self.fake_cache.delete.assert_not_called() + @patch.object(matrix_alertbot.webhook, "logger", autospec=True) @patch.object( matrix_alertbot.webhook, "send_text_to_room", - side_effect=send_text_to_room_raise_error, + side_effect=nio.exceptions.LocalProtocolError("Local protocol error"), ) async def test_post_alerts_raise_send_error( - self, fake_send_text_to_room: Mock + self, fake_send_text_to_room: Mock, fake_logger: Mock ) -> None: self.fake_alertmanager_client.update_silence.side_effect = ( update_silence_raise_silence_not_found @@ -341,6 +348,178 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): self.fake_cache.set.assert_not_called() self.fake_cache.delete.assert_called_once_with("fingerprint1") + fake_logger.error.assert_called_once_with( + "Unable to send alert fingerprint1 to Matrix room !abcdefg:example.com: Local protocol error" + ) + + @patch.object(matrix_alertbot.webhook, "logger", autospec=True) + @patch.object( + matrix_alertbot.webhook, + "create_alert", + side_effect=MatrixClientError("Matrix client error"), + ) + async def test_post_alerts_raise_matrix_client_error( + self, fake_create_alert: Mock, fake_logger: Mock + ) -> None: + self.fake_alertmanager_client.update_silence.side_effect = ( + update_silence_raise_silence_not_found + ) + + data = self.fake_alerts + async with self.client.request( + "POST", f"/alerts/{self.fake_room_id}", json=data + ) as response: + self.assertEqual(500, response.status) + error_msg = await response.text() + + self.assertEqual( + "An error occured when sending alert with fingerprint 'fingerprint1' to Matrix room.", + error_msg, + ) + fake_create_alert.assert_called_once() + + fake_logger.error.assert_called_once_with( + "Unable to send alert fingerprint1 to Matrix room !abcdefg:example.com: Matrix client error" + ) + + @patch.object(matrix_alertbot.webhook, "logger", autospec=True) + @patch.object( + matrix_alertbot.webhook, + "send_text_to_room", + side_effect=Exception("Exception"), + ) + async def test_post_alerts_raise_exception( + self, fake_send_text_to_room: Mock, fake_logger: Mock + ) -> None: + self.fake_alertmanager_client.update_silence.side_effect = ( + update_silence_raise_silence_not_found + ) + + data = self.fake_alerts + async with self.client.request( + "POST", f"/alerts/{self.fake_room_id}", json=data + ) as response: + self.assertEqual(500, response.status) + error_msg = await response.text() + + self.assertEqual( + "An exception occured when sending alert with fingerprint 'fingerprint1' to Matrix room.", + error_msg, + ) + fake_send_text_to_room.assert_called_once() + self.fake_cache.set.assert_not_called() + self.fake_cache.delete.assert_called_once_with("fingerprint1") + + fake_logger.error.assert_called_once_with( + "Unable to send alert fingerprint1 to Matrix room !abcdefg:example.com: Exception" + ) + + async def test_create_alert_update_silence(self) -> None: + fake_alert = Mock(spec=Alert) + fake_alert.firing = True + fake_alert.fingerprint = "fingerprint" + + await create_alert(fake_alert, self.fake_room_id, self.fake_request) + + self.fake_alertmanager_client.update_silence.assert_called_once_with( + fake_alert.fingerprint + ) + self.fake_alert_renderer.render.assert_not_called() + + @patch.object(matrix_alertbot.webhook, "send_text_to_room", autospec=True) + async def test_create_alert_with_silence_not_found_error( + self, fake_send_text_to_room: Mock + ) -> None: + fake_alert = Mock(spec=Alert) + fake_alert.firing = True + fake_alert.fingerprint = "fingerprint" + + self.fake_alertmanager_client.update_silence.side_effect = SilenceNotFoundError + + await create_alert(fake_alert, self.fake_room_id, self.fake_request) + + self.fake_alertmanager_client.update_silence.assert_called_once_with( + fake_alert.fingerprint + ) + self.fake_alert_renderer.render.assert_has_calls( + [call(fake_alert, html=False), call(fake_alert, html=True)] + ) + + fake_send_text_to_room.assert_called_once() + + self.fake_cache.set.assert_called_once_with( + fake_send_text_to_room.return_value.event_id, + fake_alert.fingerprint, + expire=self.fake_config.cache_expire_time, + ) + self.fake_cache.delete.assert_called_once_with(fake_alert.fingerprint) + + @patch.object(matrix_alertbot.webhook, "send_text_to_room", autospec=True) + async def test_create_alert_with_silence_extend_error( + self, fake_send_text_to_room: Mock + ) -> None: + fake_alert = Mock(spec=Alert) + fake_alert.firing = True + fake_alert.fingerprint = "fingerprint" + + self.fake_alertmanager_client.update_silence.side_effect = SilenceExtendError + + await create_alert(fake_alert, self.fake_room_id, self.fake_request) + + self.fake_alertmanager_client.update_silence.assert_called_once_with( + fake_alert.fingerprint + ) + self.fake_alert_renderer.render.assert_has_calls( + [call(fake_alert, html=False), call(fake_alert, html=True)] + ) + + fake_send_text_to_room.assert_called_once() + + self.fake_cache.set.assert_called_once_with( + fake_send_text_to_room.return_value.event_id, + fake_alert.fingerprint, + expire=self.fake_config.cache_expire_time, + ) + self.fake_cache.delete.assert_not_called() + + @patch.object(matrix_alertbot.webhook, "send_text_to_room", autospec=True) + async def test_create_alert_not_firing(self, fake_send_text_to_room: Mock) -> None: + fake_alert = Mock(spec=Alert) + fake_alert.firing = False + fake_alert.fingerprint = "fingerprint" + + await create_alert(fake_alert, self.fake_room_id, self.fake_request) + + self.fake_alertmanager_client.update_silence.assert_not_called() + self.fake_alert_renderer.render.assert_has_calls( + [call(fake_alert, html=False), call(fake_alert, html=True)] + ) + + fake_send_text_to_room.assert_called_once() + + self.fake_cache.set.assert_not_called() + self.fake_cache.delete.assert_called_once_with(fake_alert.fingerprint) + + @patch.object(matrix_alertbot.webhook, "send_text_to_room", autospec=True) + async def test_create_alert_not_firing_raise_matrix_client_error( + self, fake_send_text_to_room: Mock + ) -> None: + fake_alert = Mock(spec=Alert) + fake_alert.firing = False + fake_alert.fingerprint = "fingerprint" + + self.fake_matrix_client_pool.matrix_client = None + + with self.assertRaises(MatrixClientError): + await create_alert(fake_alert, self.fake_room_id, self.fake_request) + + self.fake_alertmanager_client.update_silence.assert_not_called() + self.fake_alert_renderer.render.assert_has_calls( + [call(fake_alert, html=False), call(fake_alert, html=True)] + ) + + fake_send_text_to_room.assert_not_called() + async def test_health(self) -> None: async with self.client.request("GET", "/health") as response: self.assertEqual(200, response.status)