create tests for config

This commit is contained in:
HgO 2022-07-09 12:22:05 +02:00
parent 687afe32ef
commit bc3f6ac452
6 changed files with 471 additions and 96 deletions

View file

@ -7,7 +7,11 @@ from typing import Any, List, Optional
import pytimeparse import pytimeparse
import yaml import yaml
from matrix_alertbot.errors import ConfigError from matrix_alertbot.errors import (
InvalidConfigError,
ParseConfigError,
RequiredConfigKeyError,
)
logger = logging.getLogger() logger = logging.getLogger()
logging.getLogger("peewee").setLevel( logging.getLogger("peewee").setLevel(
@ -21,7 +25,7 @@ class Config:
def __init__(self, filepath: str): def __init__(self, filepath: str):
self.filepath = filepath self.filepath = filepath
if not os.path.isfile(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 # Load in the config file at the given filepath
with open(filepath) as file_stream: with open(filepath) as file_stream:
@ -44,7 +48,7 @@ class Config:
["logging", "file_logging", "enabled"], default=False ["logging", "file_logging", "enabled"], default=False
) )
file_logging_filepath = self._get_cfg( 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: if file_logging_enabled:
file_handler = logging.FileHandler(file_logging_filepath) file_handler = logging.FileHandler(file_logging_filepath)
@ -60,67 +64,77 @@ class Config:
logger.addHandler(console_handler) logger.addHandler(console_handler)
# Storage setup # 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 # Create the store folder if it doesn't exist
if not os.path.isdir(self.store_path): if not os.path.isdir(self.store_dir):
if not os.path.exists(self.store_path): if not os.path.exists(self.store_dir):
os.mkdir(self.store_path) os.mkdir(self.store_dir)
else: else:
raise ConfigError( raise InvalidConfigError(
f"storage.store_path '{self.store_path}' is not a directory" f"storage.path '{self.store_dir}' is not a directory"
) )
# Cache setup # Cache setup
self.cache_dir = self._get_cfg(["cache", "directory"], required=True) self.cache_dir: str = self._get_cfg(["cache", "path"], required=True)
expire_time = self._get_cfg(["cache", "expire_time"], default="1w") expire_time: str = self._get_cfg(["cache", "expire_time"], default="1w")
self.cache_expire_time = pytimeparse.parse(expire_time) self.cache_expire_time = pytimeparse.parse(expire_time)
# Alertmanager client setup # 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 # Matrix bot account setup
self.user_id = self._get_cfg(["matrix", "user_id"], required=True) self.user_id: str = self._get_cfg(["matrix", "user_id"], required=True)
if not re.match("@.*:.*", self.user_id): if not re.match("@.+:.+", self.user_id):
raise ConfigError("matrix.user_id must be in the form @name:domain") 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_password: str = self._get_cfg(
self.user_token = self._get_cfg(["matrix", "user_token"], required=False) ["matrix", "user_password"], 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.homeserver_url = self._get_cfg(["matrix", "url"], required=True) self.user_token: str = self._get_cfg(["matrix", "user_token"], required=False)
self.room_id = self._get_cfg(["matrix", "room"], required=True) 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.device_id: str = self._get_cfg(["matrix", "device_id"], required=True)
self.port = self._get_cfg(["webhook", "port"], required=False) self.device_name: str = self._get_cfg(
self.socket = self._get_cfg(["webhook", "socket"], required=False) ["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 ( if (
not (self.address or self.port or self.socket) 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 (self.address and not self.port)
or (not self.address and 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" "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( def _get_cfg(
self, self,
path: List[str], path: List[str],
default: Optional[Any] = None, default: Optional[Any] = None,
required: Optional[bool] = True, required: bool = True,
) -> Any: ) -> Any:
"""Get a config option from a path and option name, specifying whether it is """Get a config option from a path and option name, specifying whether it is
required. required.
Raises: 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. no default value provided), a ConfigError will be raised.
""" """
# Sift through the the config until we reach our option # 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 at any point we don't get our expected option...
if config is None: if config is None:
# Raise an error if it was required # Raise an error if it was required
if required and not default: if required and default is None:
raise ConfigError(f"Config option {'.'.join(path)} is required") raise RequiredConfigKeyError(
f"Config option {'.'.join(path)} is required"
)
# or return the default value # or return the default value
return default return default

View file

@ -7,6 +7,24 @@ class ConfigError(Exception):
pass 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): class AlertmanagerError(Exception):
"""An error encountered with Alertmanager.""" """An error encountered with Alertmanager."""

View file

@ -40,7 +40,7 @@ def create_matrix_client(config: Config) -> AsyncClient:
config.homeserver_url, config.homeserver_url,
config.user_id, config.user_id,
device_id=config.device_id, device_id=config.device_id,
store_path=config.store_path, store_path=config.store_dir,
config=client_config, config=client_config,
) )

View file

@ -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

View file

@ -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"

View file

@ -1,80 +1,333 @@
import os
import unittest 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.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): class ConfigTestCase(unittest.TestCase):
def test_get_cfg(self) -> None: @patch("os.path.isdir")
"""Test that Config._get_cfg works correctly""" @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. config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
test_config_dict = {"a_key": 5, "some_key": {"some_other_key": "some_value"}} config = Config(config_path)
# We create a fake config using Mock. _get_cfg will attempt to pull from self.config_dict, fake_path_isdir.assert_called_once_with("data/store")
# so we use a Mock to quickly create a dummy class, and set the 'config_dict' attribute to fake_path_exists.assert_called_once_with("data/store")
# our test dictionary. fake_mkdir.assert_called_once_with("data/store")
fake_config = Mock()
fake_config.config_dict = test_config_dict
# Now let's make some calls to Config._get_cfg. We provide 'fake_cfg' as the first argument self.assertEqual("@fakes_user:matrix.example.com", config.user_id)
# as a substitute for 'self'. _get_cfg will then be pulling values from fake_cfg.config_dict. 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("0.0.0.0", config.address)
self.assertEqual( self.assertEqual(8080, config.port)
Config._get_cfg(fake_config, ["a_key"]), self.assertIsNone(config.socket)
5,
)
# Test that we can get the value of a nested key self.assertEqual("http://localhost:9093", config.alertmanager_url)
self.assertEqual(
Config._get_cfg(fake_config, ["some_key", "some_other_key"]),
"some_value",
)
# Test that the value provided by the default option is used when a key does not exist expected_expire_time = timedelta(days=7).total_seconds()
self.assertEqual( self.assertEqual(expected_expire_time, config.cache_expire_time)
Config._get_cfg( self.assertEqual("data/cache", config.cache_dir)
fake_config,
["a_made_up_key", "this_does_not_exist"],
default="The default",
),
"The default",
)
# Test that the value provided by the default option is *not* used when a key *does* exist self.assertEqual("data/store", config.store_dir)
self.assertEqual(
Config._get_cfg(fake_config, ["a_key"], default="The default"),
5,
)
# Test that keys that do not exist raise a ConfigError when the required argument is True self.assertEqual("!alert ", config.command_prefix)
with self.assertRaises(ConfigError):
Config._get_cfg(
fake_config, ["a_made_up_key", "this_does_not_exist"], required=True
)
# Test that a ConfigError is not returned when a non-existent key is provided and required is False @patch("os.path.isdir")
self.assertIsNone( @patch("os.path.exists")
Config._get_cfg( @patch("os.mkdir")
fake_config, ["a_made_up_key", "this_does_not_exist"], required=False 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 config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.full.yml")
# (Typically one shouldn't use a default with required=True anyways...) config = Config(config_path)
self.assertEqual(
Config._get_cfg(
fake_config,
["a_made_up_key", "this_does_not_exist"],
default="something",
required=True,
),
"something",
)
# 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__": if __name__ == "__main__":