diff --git a/matrix_alertbot/config.py b/matrix_alertbot/config.py index 6633f45..9cb170d 100644 --- a/matrix_alertbot/config.py +++ b/matrix_alertbot/config.py @@ -7,7 +7,11 @@ from typing import Any, List, Optional import pytimeparse import yaml -from matrix_alertbot.errors import ConfigError +from matrix_alertbot.errors import ( + InvalidConfigError, + ParseConfigError, + RequiredConfigKeyError, +) logger = logging.getLogger() logging.getLogger("peewee").setLevel( @@ -21,7 +25,7 @@ class Config: def __init__(self, filepath: str): self.filepath = filepath if not os.path.isfile(filepath): - raise ConfigError(f"Config file '{filepath}' does not exist") + raise ParseConfigError(f"Config file '{filepath}' does not exist") # Load in the config file at the given filepath with open(filepath) as file_stream: @@ -44,7 +48,7 @@ class Config: ["logging", "file_logging", "enabled"], default=False ) file_logging_filepath = self._get_cfg( - ["logging", "file_logging", "filepath"], default="bot.log" + ["logging", "file_logging", "filepath"], default="matrix-alertbot.log" ) if file_logging_enabled: file_handler = logging.FileHandler(file_logging_filepath) @@ -60,67 +64,77 @@ class Config: logger.addHandler(console_handler) # Storage setup - self.store_path = self._get_cfg(["storage", "store_path"], required=True) + self.store_dir: str = self._get_cfg(["storage", "path"], required=True) # Create the store folder if it doesn't exist - if not os.path.isdir(self.store_path): - if not os.path.exists(self.store_path): - os.mkdir(self.store_path) + if not os.path.isdir(self.store_dir): + if not os.path.exists(self.store_dir): + os.mkdir(self.store_dir) else: - raise ConfigError( - f"storage.store_path '{self.store_path}' is not a directory" + raise InvalidConfigError( + f"storage.path '{self.store_dir}' is not a directory" ) # Cache setup - self.cache_dir = self._get_cfg(["cache", "directory"], required=True) - expire_time = self._get_cfg(["cache", "expire_time"], default="1w") + self.cache_dir: str = self._get_cfg(["cache", "path"], required=True) + expire_time: str = self._get_cfg(["cache", "expire_time"], default="1w") self.cache_expire_time = pytimeparse.parse(expire_time) # Alertmanager client setup - self.alertmanager_url = self._get_cfg(["alertmanager", "url"], required=True) + self.alertmanager_url: str = self._get_cfg( + ["alertmanager", "url"], required=True + ) # Matrix bot account setup - self.user_id = self._get_cfg(["matrix", "user_id"], required=True) - if not re.match("@.*:.*", self.user_id): - raise ConfigError("matrix.user_id must be in the form @name:domain") + self.user_id: str = self._get_cfg(["matrix", "user_id"], required=True) + if not re.match("@.+:.+", self.user_id): + raise InvalidConfigError("matrix.user_id must be in the form @name:domain") - self.user_password = self._get_cfg(["matrix", "user_password"], required=False) - self.user_token = self._get_cfg(["matrix", "user_token"], required=False) - if not self.user_token and not self.user_password: - raise ConfigError("Must supply either user token or password") - - self.device_id = self._get_cfg(["matrix", "device_id"], required=True) - self.device_name = self._get_cfg( - ["matrix", "device_name"], default="nio-template" + self.user_password: str = self._get_cfg( + ["matrix", "user_password"], required=False ) - self.homeserver_url = self._get_cfg(["matrix", "url"], required=True) - self.room_id = self._get_cfg(["matrix", "room"], required=True) + self.user_token: str = self._get_cfg(["matrix", "user_token"], required=False) + if not self.user_token and not self.user_password: + raise RequiredConfigKeyError("Must supply either user token or password") - self.address = self._get_cfg(["webhook", "address"], required=False) - self.port = self._get_cfg(["webhook", "port"], required=False) - self.socket = self._get_cfg(["webhook", "socket"], required=False) + self.device_id: str = self._get_cfg(["matrix", "device_id"], required=True) + self.device_name: str = self._get_cfg( + ["matrix", "device_name"], default="matrix-alertbot" + ) + self.homeserver_url: str = self._get_cfg(["matrix", "url"], required=True) + self.room_id: str = self._get_cfg(["matrix", "room"], required=True) + + self.address: str = self._get_cfg(["webhook", "address"], required=False) + self.port: int = self._get_cfg(["webhook", "port"], required=False) + self.socket: str = self._get_cfg(["webhook", "socket"], required=False) if ( not (self.address or self.port or self.socket) - or (self.socket and self.address and self.port) or (self.address and not self.port) or (not self.address and self.port) ): - raise ConfigError( + raise RequiredConfigKeyError( "Must supply either webhook.socket or both webhook.address and webhook.port" ) - self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " " + elif self.socket and self.address and self.port: + raise InvalidConfigError( + "Supplied both webhook.socket and both webhook.address" + ) + + self.command_prefix: str = ( + self._get_cfg(["command_prefix"], default="!alert") + " " + ) def _get_cfg( self, path: List[str], default: Optional[Any] = None, - required: Optional[bool] = True, + required: bool = True, ) -> Any: """Get a config option from a path and option name, specifying whether it is required. Raises: - ConfigError: If required is True and the object is not found (and there is + RequiredConfigKeyError: If required is True and the object is not found (and there is no default value provided), a ConfigError will be raised. """ # Sift through the the config until we reach our option @@ -131,8 +145,10 @@ class Config: # If at any point we don't get our expected option... if config is None: # Raise an error if it was required - if required and not default: - raise ConfigError(f"Config option {'.'.join(path)} is required") + if required and default is None: + raise RequiredConfigKeyError( + f"Config option {'.'.join(path)} is required" + ) # or return the default value return default diff --git a/matrix_alertbot/errors.py b/matrix_alertbot/errors.py index 30c2cf2..763ef14 100644 --- a/matrix_alertbot/errors.py +++ b/matrix_alertbot/errors.py @@ -7,6 +7,24 @@ class ConfigError(Exception): pass +class ParseConfigError(ConfigError): + """An error encountered when config file cannot be parsed.""" + + pass + + +class InvalidConfigError(ParseConfigError): + """An error encountered when a config key is not valid.""" + + pass + + +class RequiredConfigKeyError(ConfigError): + """An error encountered when a required config key is missing.""" + + pass + + class AlertmanagerError(Exception): """An error encountered with Alertmanager.""" diff --git a/matrix_alertbot/main.py b/matrix_alertbot/main.py index 7995ae9..cc2280a 100644 --- a/matrix_alertbot/main.py +++ b/matrix_alertbot/main.py @@ -40,7 +40,7 @@ def create_matrix_client(config: Config) -> AsyncClient: config.homeserver_url, config.user_id, device_id=config.device_id, - store_path=config.store_path, + store_path=config.store_dir, config=client_config, ) diff --git a/tests/resources/config/config.full.yml b/tests/resources/config/config.full.yml new file mode 100644 index 0000000..eb9eb62 --- /dev/null +++ b/tests/resources/config/config.full.yml @@ -0,0 +1,54 @@ +# Welcome to the sample config file +# Below you will find various config sections and options +# Default values are shown + +# The string to prefix messages with to talk to the bot in group chats +command_prefix: "!alert" + +# Options for connecting to the bot's Matrix account +matrix: + # The Matrix User ID of the bot account + user_id: "@fakes_user:matrix.example.com" + # Matrix account password (optional if access token used) + user_password: "password" + # Matrix account access token (optional if password used) + #user_token: "" + # The URL of the homeserver to connect to + url: https://matrix.example.com + # The device ID that is **non pre-existing** device + # If this device ID already exists, messages will be dropped silently in encrypted rooms + device_id: ABCDEFGHIJ + # What to name the logged in device + device_name: fake_device_name + room: "!abcdefgh:matrix.example.com" + +webhook: + socket: matrix-alertbot.socket + +alertmanager: + url: http://localhost:9093 + +cache: + # The path to a directory for caching alerts and silences + path: "data/cache" + +storage: + # The path to a directory for internal bot storage + # containing encryption keys, sync tokens, etc. + path: "data/store" + +# Logging setup +logging: + # Logging level + # Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose + level: DEBUG + # Configure logging to a file + file_logging: + # Whether logging to a file is enabled + enabled: true + # The path to the file to log to. May be relative or absolute + filepath: fake.log + # Configure logging to the console output + console_logging: + # Whether logging to the console is enabled + enabled: false diff --git a/tests/resources/config/config.minimal.yml b/tests/resources/config/config.minimal.yml new file mode 100644 index 0000000..48f0b50 --- /dev/null +++ b/tests/resources/config/config.minimal.yml @@ -0,0 +1,34 @@ +# Welcome to the sample config file +# Below you will find various config sections and options +# Default values are shown + +# Options for connecting to the bot's Matrix account +matrix: + # The Matrix User ID of the bot account + user_id: "@fakes_user:matrix.example.com" + # Matrix account password (optional if access token used) + user_password: "password" + # Matrix account access token (optional if password used) + #user_token: "" + # The URL of the homeserver to connect to + url: https://matrix.example.com + # The device ID that is **non pre-existing** device + # If this device ID already exists, messages will be dropped silently in encrypted rooms + device_id: ABCDEFGHIJ + room: "!abcdefgh:matrix.example.com" + +webhook: + address: 0.0.0.0 + port: 8080 + +alertmanager: + url: http://localhost:9093 + +cache: + # The path to a directory for caching alerts and silences + path: "data/cache" + +storage: + # The path to a directory for internal bot storage + # containing encryption keys, sync tokens, etc. + path: "data/store" diff --git a/tests/test_config.py b/tests/test_config.py index 1184f02..4f8f552 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,80 +1,333 @@ +import os import unittest -from unittest.mock import Mock +from datetime import timedelta +from unittest.mock import Mock, patch + +import yaml from matrix_alertbot.config import Config -from matrix_alertbot.errors import ConfigError +from matrix_alertbot.errors import ( + ConfigError, + InvalidConfigError, + ParseConfigError, + RequiredConfigKeyError, +) + +WORKING_DIR = os.path.dirname(__file__) +CONFIG_RESOURCES_DIR = os.path.join(WORKING_DIR, "resources", "config") + + +class DummyConfig(Config): + def __init__(self, filepath: str): + with open(filepath) as file_stream: + self.config_dict = yaml.safe_load(file_stream.read()) + + +def mock_path_isdir(path: str) -> bool: + if path == "data/store": + return False + return True + + +def mock_path_exists(path: str) -> bool: + if path == "data/store": + return False + return True class ConfigTestCase(unittest.TestCase): - def test_get_cfg(self) -> None: - """Test that Config._get_cfg works correctly""" + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_read_minimal_config( + self, fake_mkdir: Mock, fake_path_exists: Mock, fake_path_isdir: Mock + ) -> None: + fake_path_isdir.return_value = False + fake_path_exists.return_value = False - # Here's our test dictionary. Pretend that this was parsed from a YAML config file. - test_config_dict = {"a_key": 5, "some_key": {"some_other_key": "some_value"}} + config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") + config = Config(config_path) - # We create a fake config using Mock. _get_cfg will attempt to pull from self.config_dict, - # so we use a Mock to quickly create a dummy class, and set the 'config_dict' attribute to - # our test dictionary. - fake_config = Mock() - fake_config.config_dict = test_config_dict + fake_path_isdir.assert_called_once_with("data/store") + fake_path_exists.assert_called_once_with("data/store") + fake_mkdir.assert_called_once_with("data/store") - # Now let's make some calls to Config._get_cfg. We provide 'fake_cfg' as the first argument - # as a substitute for 'self'. _get_cfg will then be pulling values from fake_cfg.config_dict. + self.assertEqual("@fakes_user:matrix.example.com", config.user_id) + self.assertEqual("password", config.user_password) + self.assertIsNone(config.user_token) + self.assertEqual("ABCDEFGHIJ", config.device_id) + self.assertEqual("matrix-alertbot", config.device_name) + self.assertEqual("https://matrix.example.com", config.homeserver_url) + self.assertEqual("!abcdefgh:matrix.example.com", config.room_id) - # Test that we can get the value of a top-level key - self.assertEqual( - Config._get_cfg(fake_config, ["a_key"]), - 5, - ) + self.assertEqual("0.0.0.0", config.address) + self.assertEqual(8080, config.port) + self.assertIsNone(config.socket) - # Test that we can get the value of a nested key - self.assertEqual( - Config._get_cfg(fake_config, ["some_key", "some_other_key"]), - "some_value", - ) + self.assertEqual("http://localhost:9093", config.alertmanager_url) - # Test that the value provided by the default option is used when a key does not exist - self.assertEqual( - Config._get_cfg( - fake_config, - ["a_made_up_key", "this_does_not_exist"], - default="The default", - ), - "The default", - ) + expected_expire_time = timedelta(days=7).total_seconds() + self.assertEqual(expected_expire_time, config.cache_expire_time) + self.assertEqual("data/cache", config.cache_dir) - # Test that the value provided by the default option is *not* used when a key *does* exist - self.assertEqual( - Config._get_cfg(fake_config, ["a_key"], default="The default"), - 5, - ) + self.assertEqual("data/store", config.store_dir) - # Test that keys that do not exist raise a ConfigError when the required argument is True - with self.assertRaises(ConfigError): - Config._get_cfg( - fake_config, ["a_made_up_key", "this_does_not_exist"], required=True - ) + self.assertEqual("!alert ", config.command_prefix) - # Test that a ConfigError is not returned when a non-existent key is provided and required is False - self.assertIsNone( - Config._get_cfg( - fake_config, ["a_made_up_key", "this_does_not_exist"], required=False - ) - ) + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_read_full_config( + self, fake_mkdir: Mock, fake_path_exists: Mock, fake_path_isdir: Mock + ) -> None: + fake_path_isdir.return_value = False + fake_path_exists.return_value = False - # Test that default is used for non-existent keys, even if required is True - # (Typically one shouldn't use a default with required=True anyways...) - self.assertEqual( - Config._get_cfg( - fake_config, - ["a_made_up_key", "this_does_not_exist"], - default="something", - required=True, - ), - "something", - ) + config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.full.yml") + config = Config(config_path) - # TODO: Test creating a test yaml file, passing the path to Config and _parse_config_values is called correctly + fake_path_isdir.assert_called_once_with("data/store") + fake_path_exists.assert_called_once_with("data/store") + fake_mkdir.assert_called_once_with("data/store") + + self.assertEqual("@fakes_user:matrix.example.com", config.user_id) + self.assertEqual("password", config.user_password) + self.assertIsNone(config.user_token) + self.assertEqual("ABCDEFGHIJ", config.device_id) + self.assertEqual("fake_device_name", config.device_name) + self.assertEqual("https://matrix.example.com", config.homeserver_url) + self.assertEqual("!abcdefgh:matrix.example.com", config.room_id) + + self.assertIsNone(config.address) + self.assertIsNone(config.port) + self.assertEqual("matrix-alertbot.socket", config.socket) + + self.assertEqual("http://localhost:9093", config.alertmanager_url) + + expected_expire_time = timedelta(days=7).total_seconds() + self.assertEqual(expected_expire_time, config.cache_expire_time) + self.assertEqual("data/cache", config.cache_dir) + + self.assertEqual("data/store", config.store_dir) + + self.assertEqual("!alert ", config.command_prefix) + + def test_read_config_raise_config_error(self) -> None: + with self.assertRaises(ParseConfigError): + Config("") + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_storage_path_error( + self, fake_mkdir: Mock, fake_path_exists: Mock, fake_path_isdir: Mock + ) -> None: + fake_path_isdir.return_value = False + fake_path_exists.return_value = True + + config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") + with self.assertRaises(ParseConfigError): + Config(config_path) + + fake_path_isdir.assert_called_once_with("data/store") + fake_path_exists.assert_called_once_with("data/store") + fake_mkdir.assert_not_called() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_matrix_user_id( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["matrix"]["user_id"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_matrix_user_password( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["matrix"]["user_password"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_matrix_device_id( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["matrix"]["device_id"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_matrix_url( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["matrix"]["url"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_matrix_room( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["matrix"]["room"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_webhook_address( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["webhook"]["address"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_alertmanager_url( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["alertmanager"]["url"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_cache_path( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["cache"]["path"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_missing_storage_path( + self, 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.minimal.yml") + config = DummyConfig(config_path) + del config.config_dict["storage"]["path"] + + with self.assertRaises(RequiredConfigKeyError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_invalid_matrix_user_id( + self, 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.minimal.yml") + config = DummyConfig(config_path) + + config.config_dict["matrix"]["user_id"] = "" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + config.config_dict["matrix"]["user_id"] = "@fake_user" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + config.config_dict["matrix"]["user_id"] = "@fake_user:" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + config.config_dict["matrix"]["user_id"] = ":matrix.example.com" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + config.config_dict["matrix"]["user_id"] = "@:matrix.example.com" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + config.config_dict["matrix"]["user_id"] = "@:" + with self.assertRaises(InvalidConfigError): + config._parse_config_values() + + @patch("os.path.isdir") + @patch("os.path.exists") + @patch("os.mkdir") + def test_parse_config_with_both_webhook_socket_and_address( + self, 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.minimal.yml") + config = DummyConfig(config_path) + config.config_dict["webhook"]["socket"] = "matrix-alertbot.socket" + + with self.assertRaises(InvalidConfigError): + config._parse_config_values() if __name__ == "__main__":