diff --git a/matrix_alertbot/alert.py b/matrix_alertbot/alert.py index 2b8748d..1374ae1 100644 --- a/matrix_alertbot/alert.py +++ b/matrix_alertbot/alert.py @@ -12,13 +12,13 @@ class Alert: def __init__( self, - id: str, + fingerprint: str, url: str, labels: Dict[str, str], annotations: Dict[str, str], firing: bool = True, ): - self.id = id + self.fingerprint = fingerprint self.url = url self.firing = firing @@ -33,7 +33,7 @@ class Alert: @staticmethod def from_dict(data: Dict) -> Alert: return Alert( - id=data["fingerprint"], + fingerprint=data["fingerprint"], url=data["generatorURL"], firing=data["status"] == "firing", labels=data["labels"], diff --git a/matrix_alertbot/alertmanager.py b/matrix_alertbot/alertmanager.py index eb4cf2f..e51acd8 100644 --- a/matrix_alertbot/alertmanager.py +++ b/matrix_alertbot/alertmanager.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Dict, List +from typing import Dict, List, Optional import aiohttp from aiohttp import ClientError @@ -10,11 +10,11 @@ from diskcache import Cache from matrix_alertbot.errors import ( AlertmanagerServerError, - AlertMismatchError, AlertNotFoundError, + InvalidDurationError, + SilenceExpiredError, SilenceNotFoundError, ) -from matrix_alertbot.matcher import AlertMatcher class AlertmanagerClient: @@ -40,28 +40,48 @@ class AlertmanagerClient: alerts = await self.get_alerts() return self._find_alert(fingerprint, alerts) + async def get_silences(self) -> List[Dict]: + try: + async with self.session.get(f"{self.api_url}/silences") as response: + response.raise_for_status() + return await response.json() + except ClientError as e: + raise AlertmanagerServerError( + "Cannot fetch silences from Alertmanager" + ) from e + + async def get_silence(self, silence_id: str) -> Dict: + silences = await self.get_silences() + return self._find_silence(silence_id, silences) + async def create_silence( self, fingerprint: str, - seconds: int, user: str, - matchers: List[AlertMatcher], + duration_seconds: Optional[int] = None, + silence_id: Optional[str] = None, ) -> str: alert = await self.get_alert(fingerprint) - self._match_alert(alert, matchers) - silence_matchers = [ {"name": label, "value": value, "isRegex": False, "isEqual": True} for label, value in alert["labels"].items() ] start_time = datetime.now() - - duration_delta = timedelta(seconds=seconds) - end_time = start_time + duration_delta + if duration_seconds is None: + end_time = datetime.max + elif duration_seconds > 0: + try: + duration_delta = timedelta(seconds=duration_seconds) + end_time = start_time + duration_delta + except OverflowError: + end_time = datetime.max + else: + raise InvalidDurationError(f"Duration must be positive: {duration_seconds}") silence = { + "id": silence_id, "matchers": silence_matchers, "startsAt": start_time.isoformat(), "endsAt": end_time.isoformat(), @@ -82,33 +102,23 @@ class AlertmanagerClient: return data["silenceID"] - async def delete_silences( - self, fingerprint: str, matchers: List[AlertMatcher] - ) -> List[str]: - alert = await self.get_alert(fingerprint) + async def delete_silence(self, silence_id: str) -> None: + silence = await self.get_silence(silence_id) - alert_state = alert["status"]["state"] - if alert_state != "suppressed": - raise SilenceNotFoundError( - f"Cannot find silences for alert fingerprint {fingerprint} in state {alert_state}" + silence_state = silence["state"] + if silence_state == "expired": + raise SilenceExpiredError( + f"Cannot delete already expired silence with ID {silence_id}" ) - self._match_alert(alert, matchers) - - silences = alert["status"]["silencedBy"] - for silence in silences: - await self._delete_silence(silence) - return silences - - async def _delete_silence(self, silence: str) -> None: try: async with self.session.delete( - f"{self.api_url}/silence/{silence}" + f"{self.api_url}/silence/{silence_id}" ) as response: response.raise_for_status() except ClientError as e: raise AlertmanagerServerError( - f"Cannot delete silence with ID {silence}" + f"Cannot delete silence with ID {silence_id}" ) from e @staticmethod @@ -119,16 +129,8 @@ class AlertmanagerClient: raise AlertNotFoundError(f"Cannot find alert with fingerprint {fingerprint}") @staticmethod - def _match_alert(alert: Dict, matchers: List[AlertMatcher]) -> None: - labels = alert["labels"] - for matcher in matchers: - if matcher.label not in labels: - labels_text = ", ".join(labels) - raise AlertMismatchError( - f"Cannot find label {matcher.label} in alert labels: {labels_text}" - ) - - if not matcher.match(labels): - raise AlertMismatchError( - f"Alert with label {matcher.label}={labels[matcher.label]} does not match {matcher}" - ) + def _find_silence(silence_id: str, silences: List[Dict]) -> Dict: + for silence in silences: + if silence["id"] == silence_id: + return silence + raise SilenceNotFoundError(f"Cannot find silence with ID {silence_id}") diff --git a/matrix_alertbot/callback.py b/matrix_alertbot/callback.py index 72dace2..fe689b0 100644 --- a/matrix_alertbot/callback.py +++ b/matrix_alertbot/callback.py @@ -15,13 +15,13 @@ from nio import ( from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.chat_functions import strip_fallback -from matrix_alertbot.command import CommandFactory +from matrix_alertbot.command import AckAlertCommand, CommandFactory, UnackAlertCommand from matrix_alertbot.config import Config logger = logging.getLogger(__name__) -REACTION_DURATIONS = {"🤫": "12h", "😶": "1d", "🤐": "3d", "🙊": "5d", "🔇": "1w", "🔕": "3w"} +REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"} class Callbacks: @@ -56,9 +56,6 @@ class Callbacks: event: The event defining the message. """ - # Extract the message text - msg = strip_fallback(event.body) - # Ignore messages from ourselves if event.sender == self.client.user: return @@ -67,6 +64,9 @@ class Callbacks: if room.room_id != self.config.room_id: return + # Extract the message text + msg = strip_fallback(event.body) + logger.debug( f"Bot message received for room {room.display_name} | " f"{room.user_name(event.sender)}: {msg}" @@ -75,19 +75,19 @@ class Callbacks: has_command_prefix = msg.startswith(self.command_prefix) if not has_command_prefix: logger.debug( - f"Message received without command prefix {self.command_prefix}: Aborting." + f"Cannot process message: Command prefix {self.command_prefix} not provided." ) return source_content = event.source["content"] - alert_event_id = ( + reacted_to_event_id = ( source_content.get("m.relates_to", {}) .get("m.in_reply_to", {}) .get("event_id") ) - if alert_event_id is None: - logger.warning("Unable to find the event ID of the alert") + if reacted_to_event_id is not None: + logger.debug(f"Command in reply to event ID {reacted_to_event_id}") # Remove the command prefix cmd = msg[len(self.command_prefix) :] @@ -101,10 +101,10 @@ class Callbacks: room, event.sender, event.event_id, - alert_event_id, + reacted_to_event_id, ) except TypeError as e: - logging.error(f"Unable to create the command '{cmd}': {e}") + logging.error(f"Cannot process command '{cmd}': {e}") return await command.process() @@ -176,10 +176,9 @@ class Callbacks: reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key") logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.") - if reaction not in REACTION_DURATIONS: + if reaction not in REACTIONS: logger.warning(f"Uknown duration reaction {reaction}") return - duration = REACTION_DURATIONS[reaction] # Get the original event that was reacted to event_response = await self.client.room_get_event(room.room_id, alert_event_id) @@ -194,29 +193,17 @@ class Callbacks: if reacted_to_event.sender != self.config.user_id: return - self.cache.set( - event.event_id, - reacted_to_event.event_id, - expire=self.config.cache_expire_time, - ) - # Send a message acknowledging the reaction - cmd = f"ack {duration}" - try: - command = CommandFactory.create( - cmd, - self.client, - self.cache, - self.alertmanager, - self.config, - room, - event.sender, - event.event_id, - alert_event_id, - ) - except TypeError as e: - logging.error(f"Unable to create the command '{cmd}': {e}") - return + command = AckAlertCommand( + self.client, + self.cache, + self.alertmanager, + self.config, + room, + event.sender, + event.event_id, + alert_event_id, + ) await command.process() @@ -226,36 +213,29 @@ class Callbacks: return # Ignore redactions from ourselves - if event.sender == self.config.user_id: + if event.sender == self.client.user: return logger.debug( f"Read alert event ID for redacted event {event.redacts} from cache" ) - if event.redacts not in self.cache: - logger.warning( - f"Unable to remove silences from event {event.redacts}: Redacted event is not in cache" - ) - return - alert_event_id: str = self.cache[event.redacts] - try: - command = CommandFactory.create( - "unack", - self.client, - self.cache, - self.alertmanager, - self.config, - room, - event.sender, - event.redacts, - alert_event_id, - ) - except TypeError as e: - logging.error(f"Unable to create the command 'unack': {e}") + reacted_to_event_id: str = self.cache[event.redacts] + except KeyError: + logger.warning(f"Unable to find silence from event {event.redacts}") return + command = UnackAlertCommand( + self.client, + self.cache, + self.alertmanager, + self.config, + room, + event.sender, + event.redacts, + reacted_to_event_id, + ) await command.process() async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None: diff --git a/matrix_alertbot/command.py b/matrix_alertbot/command.py index b382928..33acf96 100644 --- a/matrix_alertbot/command.py +++ b/matrix_alertbot/command.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple +from typing import Optional, Tuple import pytimeparse2 from diskcache import Cache @@ -13,7 +13,6 @@ from matrix_alertbot.errors import ( AlertNotFoundError, SilenceNotFoundError, ) -from matrix_alertbot.matcher import AlertMatcher, AlertRegexMatcher logger = logging.getLogger(__name__) @@ -25,10 +24,10 @@ class BaseCommand: cache: Cache, alertmanager: AlertmanagerClient, config: Config, - cmd: str, room: MatrixRoom, sender: str, event_id: str, + args: Tuple[str, ...] = None, ) -> None: """A command made by a user. @@ -53,12 +52,15 @@ class BaseCommand: self.cache = cache self.alertmanager = alertmanager self.config = config - self.cmd = cmd - self.args = cmd.split()[1:] self.room = room self.sender = sender self.event_id = event_id + if args is not None: + self.args = args + else: + self.args = () + async def process(self) -> None: raise NotImplementedError @@ -70,166 +72,158 @@ class BaseAlertCommand(BaseCommand): cache: Cache, alertmanager: AlertmanagerClient, config: Config, - cmd: str, room: MatrixRoom, sender: str, event_id: str, - alert_event_id: str, + reacted_to_event_id: str, + args: Tuple[str, ...] = None, ) -> None: super().__init__( - client, cache, alertmanager, config, cmd, room, sender, event_id + client, cache, alertmanager, config, room, sender, event_id, args ) - self.alert_event_id = alert_event_id + self.reacted_to_event_id = reacted_to_event_id class AckAlertCommand(BaseAlertCommand): async def process(self) -> None: """Acknowledge an alert and silence it for a certain duration in Alertmanager""" - matchers: List[AlertMatcher] = [] - durations = [] - for arg in self.args: - if "=~" in arg: - label, regex = arg.split("=~") - regex_matcher = AlertRegexMatcher(label, regex) - matchers.append(regex_matcher) - elif "=" in arg: - label, value = arg.split("=") - matcher = AlertMatcher(label, value) - matchers.append(matcher) - else: - durations.append(arg) - + durations = self.args if len(durations) > 0: duration = " ".join(durations) - else: - duration = "1d" + logger.debug(f"Receiving a command to create a silence for {duration}.") - logger.debug( - f"Receiving a command to create a silence for a duration of {duration}" - ) - - duration_seconds = pytimeparse2.parse(duration) - if duration_seconds is None: - logger.error(f"Unable to create silence: Invalid duration '{duration}'") - await send_text_to_room( - self.client, - self.room.room_id, - f"I tried really hard, but I can't convert the duration '{duration}' to a number of seconds.", - ) - return - - logger.debug( - f"Read alert fingerprints for alert event {self.alert_event_id} from cache" - ) - - if self.alert_event_id not in self.cache: - logger.error( - f"Cannot find fingerprints for alert event {self.alert_event_id} in cache" - ) - return - - alert_fingerprints: Tuple[str] = self.cache[self.alert_event_id] - logger.debug(f"Found {len(alert_fingerprints)} in cache") - - count_alert_not_found = 0 - created_silences = [] - for alert_fingerprint in alert_fingerprints: - logger.debug( - f"Create silence for alert with fingerprint {alert_fingerprint} for a duration of {duration}" - ) - - try: - silence_id = await self.alertmanager.create_silence( - alert_fingerprint, - duration_seconds, - self.room.user_name(self.sender), - matchers, + duration_seconds = pytimeparse2.parse(duration) + if duration_seconds is None: + logger.error(f"Unable to create silence: Invalid duration '{duration}'") + await send_text_to_room( + self.client, + self.room.room_id, + f"I tried really hard, but I can't convert the duration '{duration}' to a number of seconds.", ) - created_silences.append(silence_id) - except AlertNotFoundError as e: - logger.warning(f"Unable to create silence: {e}") - count_alert_not_found += 1 - except AlertmanagerError as e: - logger.exception(f"Unable to create silence: {e}", exc_info=e) + return + else: + duration_seconds = None + logger.debug( + "Receiving a command to create a silence for an indefinite period" + ) - matchers_id = "".join(sorted(str(matcher) for matcher in matchers)) - ack_id = "".join(alert_fingerprints) + str(duration_seconds) + matchers_id - self.cache.set(ack_id, tuple(created_silences), expire=duration_seconds) + logger.debug( + f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache" + ) + try: + alert_fingerprint: str = self.cache[self.reacted_to_event_id] + except KeyError: + logger.error( + f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache" + ) + return - if count_alert_not_found > 0: + cached_silence_id = self.cache.get(alert_fingerprint) + if cached_silence_id is None: + logger.debug( + f"Creating silence for alert with fingerprint {alert_fingerprint}." + ) + else: + logger.debug( + f"Updating silence with ID {cached_silence_id} for alert with fingerprint {alert_fingerprint}." + ) + + try: + silence_id = await self.alertmanager.create_silence( + alert_fingerprint, + self.room.user_name(self.sender), + duration_seconds, + cached_silence_id, + ) + except AlertNotFoundError as e: + logger.warning(f"Unable to create silence: {e}") await send_text_to_room( self.client, self.room.room_id, - f"Sorry, I couldn't find {count_alert_not_found} alerts, therefore I couldn't create their silence.", + f"Sorry, I couldn't find alert with fingerprint {alert_fingerprint}, therefore " + "I couldn't create the silence.", ) - - if len(created_silences) > 0: + return + except AlertmanagerError as e: + logger.exception(f"Unable to create silence: {e}", exc_info=e) await send_text_to_room( self.client, self.room.room_id, - f"Created {len(created_silences)} silences with a duration of {duration}.", + "Something went wrong with Alertmanager, therefore " + f"I couldn't create silence for alert fingerprint {alert_fingerprint}.", ) + return + + self.cache.set(self.event_id, alert_fingerprint, expire=duration_seconds) + self.cache.set(alert_fingerprint, silence_id, expire=duration_seconds) + + await send_text_to_room( + self.client, + self.room.room_id, + f"Created silence with ID {silence_id}.", + ) class UnackAlertCommand(BaseAlertCommand): async def process(self) -> None: """Delete an alert's acknowledgement of an alert and remove corresponding silence in Alertmanager""" - matchers: List[AlertMatcher] = [] - for arg in self.args: - if "=~" in arg: - label, regex = arg.split("=~") - regex_matcher = AlertRegexMatcher(label, regex) - matchers.append(regex_matcher) - elif "=" in arg: - label, value = arg.split("=") - matcher = AlertMatcher(label, value) - matchers.append(matcher) - logger.debug("Receiving a command to delete a silence") + logger.debug( - f"Read alert fingerprints for alert event {self.alert_event_id} from cache" + f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache." + ) + try: + alert_fingerprint: str = self.cache[self.reacted_to_event_id] + except KeyError: + logger.error( + f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache." + ) + return + logger.debug(f"Found alert fingerprint {alert_fingerprint} in cache.") + + logger.debug( + f"Reading silence ID for alert fingerprint {alert_fingerprint} from cache." + ) + try: + silence_id: str = self.cache[alert_fingerprint] + except KeyError: + logger.error( + f"Cannot find silence for alert fingerprint {alert_fingerprint} in cache" + ) + return + logger.debug(f"Found silence ID {silence_id} in cache.") + + logger.debug( + f"Deleting silence with ID {silence_id} for alert with fingerprint {alert_fingerprint}" ) - if self.alert_event_id not in self.cache: - logger.error( - f"Cannot find fingerprints for event {self.alert_event_id} in cache" + try: + await self.alertmanager.delete_silence(silence_id) + except (AlertNotFoundError, SilenceNotFoundError) as e: + logger.error(f"Unable to delete silence: {e}") + await send_text_to_room( + self.client, + self.room.room_id, + f"Sorry, I couldn't find alert with fingerprint {alert_fingerprint}, therefore " + "I couldn't remove its silence.", + ) + return + except AlertmanagerError as e: + logger.exception(f"Unable to delete silence: {e}", exc_info=e) + await send_text_to_room( + self.client, + self.room.room_id, + "Something went wrong with Alertmanager, therefore " + f"I couldn't delete silence for alert fingerprint {alert_fingerprint}.", ) return - alert_fingerprints: Tuple[str] = self.cache[self.alert_event_id] - logger.debug(f"Found {len(alert_fingerprints)} in cache") - - count_alert_not_found = 0 - count_removed_silences = 0 - for alert_fingerprint in alert_fingerprints: - logger.debug( - f"Delete silence for alert with fingerprint {alert_fingerprint}" - ) - try: - removed_silences = await self.alertmanager.delete_silences( - alert_fingerprint, matchers - ) - count_removed_silences += len(removed_silences) - except (AlertNotFoundError, SilenceNotFoundError) as e: - logger.error(f"Unable to delete silence: {e}") - count_alert_not_found += 1 - except AlertmanagerError as e: - logger.exception(f"Unable to delete silence: {e}", exc_info=e) - - if count_alert_not_found > 0: - await send_text_to_room( - self.client, - self.room.room_id, - f"Sorry, I couldn't find {count_alert_not_found} alerts, therefore I couldn't remove their silences.", - ) - - if count_removed_silences > 0: - await send_text_to_room( - self.client, - self.room.room_id, - f"Removed {count_removed_silences} silences.", - ) + await send_text_to_room( + self.client, + self.room.room_id, + f"Removed silence with ID {silence_id}.", + ) class HelpCommand(BaseCommand): @@ -262,7 +256,7 @@ class UnknownCommand(BaseCommand): await send_text_to_room( self.client, self.room.room_id, - f"Unknown command '{self.cmd}'. Try the 'help' command for more information.", + "Unknown command. Try the 'help' command for more information.", ) @@ -279,6 +273,8 @@ class CommandFactory: event_id: str, reacted_to_event_id: Optional[str] = None, ) -> BaseCommand: + args = tuple(cmd.split()[1:]) + if cmd.startswith("ack"): if reacted_to_event_id is None: raise TypeError("Alert command must be in reply to an alert event.") @@ -288,11 +284,11 @@ class CommandFactory: cache, alertmanager, config, - cmd, room, sender, event_id, reacted_to_event_id, + args, ) elif cmd.startswith("unack") or cmd.startswith("nack"): if reacted_to_event_id is None: @@ -303,17 +299,17 @@ class CommandFactory: cache, alertmanager, config, - cmd, room, sender, event_id, reacted_to_event_id, + args, ) elif cmd.startswith("help"): return HelpCommand( - client, cache, alertmanager, config, cmd, room, sender, event_id + client, cache, alertmanager, config, room, sender, event_id, args ) else: return UnknownCommand( - client, cache, alertmanager, config, cmd, room, sender, event_id + client, cache, alertmanager, config, room, sender, event_id, args ) diff --git a/matrix_alertbot/errors.py b/matrix_alertbot/errors.py index c7f0ed9..fc65fdb 100644 --- a/matrix_alertbot/errors.py +++ b/matrix_alertbot/errors.py @@ -37,14 +37,20 @@ class AlertNotFoundError(AlertmanagerError): pass -class AlertMismatchError(AlertmanagerError): - """An error encountered when alert's labels don't match.""" +class SilenceNotFoundError(AlertmanagerError): + """An error encountered when a silence cannot be found in Alertmanager.""" pass -class SilenceNotFoundError(AlertmanagerError): - """An error encountered when a silence cannot be found in Alertmanager.""" +class SilenceExpiredError(AlertmanagerError): + """An error encountered when a silence is already expired in Alertmanager.""" + + pass + + +class InvalidDurationError(AlertmanagerError): + """An error encountered when an alert has an invalid duration.""" pass diff --git a/matrix_alertbot/matcher.py b/matrix_alertbot/matcher.py deleted file mode 100644 index e386c02..0000000 --- a/matrix_alertbot/matcher.py +++ /dev/null @@ -1,35 +0,0 @@ -import re -from typing import Any, Dict - - -class AlertMatcher: - def __init__(self, label: str, value: str) -> None: - self.label = label - self.value = value - - def match(self, labels: Dict[str, str]) -> bool: - return self.label in labels and self.value == labels[self.label] - - def __str__(self) -> str: - return f"{self.label}={self.value}" - - def __repr__(self) -> str: - return f"AlertMatcher({self})" - - def __eq__(self, matcher: Any) -> bool: - return str(self) == str(matcher) - - -class AlertRegexMatcher(AlertMatcher): - def __init__(self, label: str, regex: str) -> None: - super().__init__(label, regex) - self.regex = re.compile(regex) - - def __str__(self) -> str: - return f"{self.label}=~{self.value}" - - def __repr__(self) -> str: - return f"AlertRegexMatcher({self})" - - def match(self, labels: Dict[str, str]) -> bool: - return self.label in labels and self.regex.match(labels[self.label]) is not None diff --git a/matrix_alertbot/webhook.py b/matrix_alertbot/webhook.py index 8962047..bc2c99a 100644 --- a/matrix_alertbot/webhook.py +++ b/matrix_alertbot/webhook.py @@ -24,9 +24,9 @@ async def get_health(request: web_request.Request) -> web.Response: @routes.post("/alerts") -async def create_alert(request: web_request.Request) -> web.Response: +async def create_alerts(request: web_request.Request) -> web.Response: data = await request.json() - logger.info(f"Received alert: {data}") + logger.info(f"Received alerts: {data}") client: AsyncClient = request.app["client"] config: Config = request.app["config"] cache: Cache = request.app["cache"] @@ -40,34 +40,30 @@ async def create_alert(request: web_request.Request) -> web.Response: if len(data["alerts"]) == 0: return web.Response(status=400, body="Alerts cannot be empty.") - plaintext = "" - html = "" - for i, alert in enumerate(data["alerts"]): + for alert in data["alerts"]: try: alert = Alert.from_dict(alert) except KeyError: return web.Response(status=400, body=f"Invalid alert: {alert}.") - if i != 0: - plaintext += "\n" - html += "
\n" - plaintext += alert.plaintext() - html += alert.html() + plaintext = alert.plaintext() + html = alert.html() - try: - event = await send_text_to_room( - client, config.room_id, plaintext, html, notice=False - ) - except (LocalProtocolError, ClientError) as e: - logger.error(e) - return web.Response( - status=500, body="An error occured when sending alerts to Matrix room." - ) + try: + event = await send_text_to_room( + client, config.room_id, plaintext, html, notice=False + ) + except (LocalProtocolError, ClientError) as e: + logger.error( + f"Unable to send alert {alert.fingerprint} to Matrix room: {e}" + ) + return web.Response( + status=500, + body=f"An error occured when sending alert with fingerprint '{alert.fingerprint}' to Matrix room.", + ) + + cache.set(event.event_id, alert.fingerprint, expire=config.cache_expire_time) - fingerprints = tuple(sorted(alert["fingerprint"] for alert in data["alerts"])) - cache.set( - event.event_id, fingerprints, expire=config.cache_expire_time, tag="event" - ) return web.Response(status=200) diff --git a/tests/test_alert.py b/tests/test_alert.py index 0c10adc..dd1476c 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -18,7 +18,7 @@ class AlertTestCase(unittest.TestCase): self.alert_dict["status"] = "firing" alert = Alert.from_dict(self.alert_dict) - self.assertEqual("fingerprint1", alert.id) + self.assertEqual("fingerprint1", alert.fingerprint) self.assertEqual("http://example.com", alert.url) self.assertTrue(alert.firing) self.assertEqual("critical", alert.status) diff --git a/tests/test_alertmanager.py b/tests/test_alertmanager.py index bca89e1..5afa104 100644 --- a/tests/test_alertmanager.py +++ b/tests/test_alertmanager.py @@ -2,24 +2,25 @@ from __future__ import annotations import json import unittest -from datetime import datetime -from typing import Any, List -from unittest.mock import MagicMock, Mock, patch +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import MagicMock, Mock import aiohttp import aiohttp.test_utils import aiotools from aiohttp import web, web_request from diskcache import Cache +from freezegun import freeze_time from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.errors import ( AlertmanagerServerError, - AlertMismatchError, AlertNotFoundError, + InvalidDurationError, + SilenceExpiredError, SilenceNotFoundError, ) -from matrix_alertbot.matcher import AlertMatcher, AlertRegexMatcher class FakeTimeDelta: @@ -36,10 +37,15 @@ class AbstractFakeAlertmanagerServer: self.app.router.add_routes( [ web.get("/api/v2/alerts", self.get_alerts), + web.get("/api/v2/silences", self.get_silences), web.post("/api/v2/silences", self.create_silence), web.delete("/api/v2/silence/{silence}", self.delete_silence), ] ) + self.app["silences"] = [ + {"id": "silence1", "state": "active"}, + {"id": "silence2", "state": "expired"}, + ] self.runner = web.AppRunner(self.app) @@ -64,6 +70,9 @@ class AbstractFakeAlertmanagerServer: async def get_alerts(self, request: web_request.Request) -> web.Response: raise NotImplementedError + async def get_silences(self, request: web_request.Request) -> web.Response: + raise NotImplementedError + async def create_silence(self, request: web_request.Request) -> web.Response: raise NotImplementedError @@ -94,25 +103,56 @@ class FakeAlertmanagerServer(AbstractFakeAlertmanagerServer): content_type="application/json", ) - async def create_silence(self, request: web_request.Request) -> web.Response: + async def get_silences(self, request: web_request.Request) -> web.Response: return web.Response( - body=json.dumps({"silenceID": "silence1"}), content_type="application/json" + body=json.dumps(self.app["silences"]), content_type="application/json" + ) + + async def create_silence(self, request: web_request.Request) -> web.Response: + silences = self.app["silences"] + + silence = await request.json() + if silence["id"] is None: + silence["id"] = "silence1" + silence["state"] = "active" + silences.append(silence) + + return web.Response( + body=json.dumps({"silenceID": silence["id"]}), + content_type="application/json", ) async def delete_silence(self, request: web_request.Request) -> web.Response: + silence_id = request.match_info["silence"] + for i, silence in enumerate(self.app["silences"]): + if silence["id"] == silence_id: + del self.app["silences"][i] + break + return web.Response(status=200, content_type="application/json") -class FakeAlertmanagerServerWithoutAlert(AbstractFakeAlertmanagerServer): +class FakeAlertmanagerServerWithoutAlert(FakeAlertmanagerServer): async def get_alerts(self, request: web_request.Request) -> web.Response: return web.Response(body=json.dumps([]), content_type="application/json") -class FakeAlertmanagerServerWithErrorAlerts(AbstractFakeAlertmanagerServer): +class FakeAlertmanagerServerWithErrorAlerts(FakeAlertmanagerServer): async def get_alerts(self, request: web_request.Request) -> web.Response: return web.Response(status=500) +class FakeAlertmanagerServerWithoutSilence(FakeAlertmanagerServer): + def __init__(self) -> None: + super().__init__() + self.app["silences"] = [] + + +class FakeAlertmanagerServerWithErrorSilences(FakeAlertmanagerServer): + async def get_silences(self, request: web_request.Request) -> web.Response: + return web.Response(status=500) + + class FakeAlertmanagerServerWithErrorCreateSilence(FakeAlertmanagerServer): async def create_silence(self, request: web_request.Request) -> web.Response: return web.Response(status=500) @@ -137,24 +177,25 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) async with aiotools.closing_async(alertmanager): alerts = await alertmanager.get_alerts() - self.assertEqual( - [ - { - "fingerprint": "fingerprint1", - "labels": {"alertname": "alert1"}, - "status": {"state": "active"}, - }, - { - "fingerprint": "fingerprint2", - "labels": {"alertname": "alert2"}, - "status": { - "state": "suppressed", - "silencedBy": ["silence1", "silence2"], - }, - }, - ], - alerts, - ) + + self.assertEqual( + [ + { + "fingerprint": "fingerprint1", + "labels": {"alertname": "alert1"}, + "status": {"state": "active"}, + }, + { + "fingerprint": "fingerprint2", + "labels": {"alertname": "alert2"}, + "status": { + "state": "suppressed", + "silencedBy": ["silence1", "silence2"], + }, + }, + ], + alerts, + ) async def test_get_alerts_empty(self) -> None: async with FakeAlertmanagerServerWithoutAlert() as fake_alertmanager_server: @@ -164,7 +205,8 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) async with aiotools.closing_async(alertmanager): alerts = await alertmanager.get_alerts() - self.assertEqual([], alerts) + + self.assertEqual([], alerts) async def test_get_alerts_raise_alertmanager_error(self) -> None: async with FakeAlertmanagerServerWithErrorAlerts() as fake_alertmanager_server: @@ -176,6 +218,44 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): with self.assertRaises(AlertmanagerServerError): await alertmanager.get_alerts() + async def test_get_silences_happy(self) -> None: + async with FakeAlertmanagerServer() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + silences = await alertmanager.get_silences() + + self.assertEqual( + [ + {"id": "silence1", "state": "active"}, + {"id": "silence2", "state": "expired"}, + ], + silences, + ) + + async def test_get_silences_empty(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + silences = await alertmanager.get_silences() + + self.assertEqual([], silences) + + async def test_get_silences_raise_alertmanager_error(self) -> None: + async with FakeAlertmanagerServerWithErrorSilences() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + with self.assertRaises(AlertmanagerServerError): + await alertmanager.get_silences() + async def test_get_alert_happy(self) -> None: async with FakeAlertmanagerServer() as fake_alertmanager_server: port = fake_alertmanager_server.port @@ -184,14 +264,15 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) async with aiotools.closing_async(alertmanager): alert = await alertmanager.get_alert("fingerprint1") - self.assertEqual( - { - "fingerprint": "fingerprint1", - "labels": {"alertname": "alert1"}, - "status": {"state": "active"}, - }, - alert, - ) + + self.assertEqual( + { + "fingerprint": "fingerprint1", + "labels": {"alertname": "alert1"}, + "status": {"state": "active"}, + }, + alert, + ) async def test_get_alert_raise_alert_not_found(self) -> None: async with FakeAlertmanagerServerWithoutAlert() as fake_alertmanager_server: @@ -213,120 +294,189 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): with self.assertRaises(AlertmanagerServerError): await alertmanager.get_alert("fingerprint1") - @patch("matrix_alertbot.alertmanager.timedelta", side_effect=FakeTimeDelta) - async def test_create_silence_without_matchers(self, fake_timedelta: Mock) -> None: + async def test_get_silence_happy(self) -> None: async with FakeAlertmanagerServer() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - silence = await alertmanager.create_silence( - "fingerprint1", 86400, "user", [] + silence1 = await alertmanager.get_silence("silence1") + silence2 = await alertmanager.get_silence("silence2") + + self.assertEqual( + {"id": "silence1", "state": "active"}, + silence1, + ) + self.assertEqual( + {"id": "silence2", "state": "expired"}, + silence2, + ) + + async def test_get_silence_raise_silence_not_found(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + with self.assertRaises(SilenceNotFoundError): + await alertmanager.get_silence("silence1") + + async def test_get_silence_raise_alertmanager_error(self) -> None: + async with FakeAlertmanagerServerWithErrorSilences() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + with self.assertRaises(AlertmanagerServerError): + await alertmanager.get_silence("silence1") + + @freeze_time(datetime.utcfromtimestamp(0)) + async def test_create_silence(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + silence_id = await alertmanager.create_silence( + "fingerprint1", "user", 86400 ) + silence = await alertmanager.get_silence("silence1") - self.assertEqual("silence1", silence) - fake_timedelta.assert_called_once_with(seconds=86400) + self.assertEqual("silence1", silence_id) + self.assertEqual( + { + "id": "silence1", + "state": "active", + "matchers": [ + { + "name": "alertname", + "value": "alert1", + "isRegex": False, + "isEqual": True, + } + ], + "createdBy": "user", + "startsAt": "1970-01-01T00:00:00", + "endsAt": "1970-01-02T00:00:00", + "comment": "Acknowledge alert from Matrix", + }, + silence, + ) - @patch("matrix_alertbot.alertmanager.timedelta", side_effect=FakeTimeDelta) - async def test_create_silence_with_matchers(self, fake_timedelta: Mock) -> None: - matchers = [AlertMatcher(label="alertname", value="alert1")] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: + @freeze_time(datetime.utcfromtimestamp(0)) + async def test_create_silence_with_id(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - silence = await alertmanager.create_silence( - "fingerprint1", - 86400, - "user", - matchers, + silence_id = await alertmanager.create_silence( + "fingerprint1", "user", 86400, "silence2" ) + silence = await alertmanager.get_silence("silence2") - self.assertEqual("silence1", silence) - fake_timedelta.assert_called_once_with(seconds=86400) + self.assertEqual("silence2", silence_id) + self.assertEqual( + { + "id": "silence2", + "state": "active", + "matchers": [ + { + "name": "alertname", + "value": "alert1", + "isRegex": False, + "isEqual": True, + } + ], + "createdBy": "user", + "startsAt": "1970-01-01T00:00:00", + "endsAt": "1970-01-02T00:00:00", + "comment": "Acknowledge alert from Matrix", + }, + silence, + ) - @patch("matrix_alertbot.alertmanager.timedelta", side_effect=FakeTimeDelta) - async def test_create_silence_with_regex_matchers( - self, fake_timedelta: Mock - ) -> None: - matchers: List[AlertMatcher] = [ - AlertRegexMatcher(label="alertname", regex=r"alert\d+") - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: + @freeze_time(datetime.utcfromtimestamp(0)) + async def test_create_silence_with_indefinite_duration(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - silence = await alertmanager.create_silence( - "fingerprint1", - 86400, - "user", - matchers, + silence_id = await alertmanager.create_silence("fingerprint1", "user") + silence = await alertmanager.get_silence("silence1") + + self.assertEqual("silence1", silence_id) + self.assertEqual( + { + "id": "silence1", + "state": "active", + "matchers": [ + { + "name": "alertname", + "value": "alert1", + "isRegex": False, + "isEqual": True, + } + ], + "createdBy": "user", + "startsAt": "1970-01-01T00:00:00", + "endsAt": "9999-12-31T23:59:59.999999", + "comment": "Acknowledge alert from Matrix", + }, + silence, + ) + + @freeze_time(datetime.utcfromtimestamp(0)) + async def test_create_silence_with_max_duration(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: + port = fake_alertmanager_server.port + alertmanager = AlertmanagerClient( + f"http://localhost:{port}", self.fake_cache + ) + async with aiotools.closing_async(alertmanager): + silence_id = await alertmanager.create_silence( + "fingerprint1", "user", int(timedelta.max.total_seconds()) ) + silence = await alertmanager.get_silence("silence1") - self.assertEqual("silence1", silence) - fake_timedelta.assert_called_once_with(seconds=86400) + self.assertEqual("silence1", silence_id) + self.assertEqual( + { + "id": "silence1", + "state": "active", + "matchers": [ + { + "name": "alertname", + "value": "alert1", + "isRegex": False, + "isEqual": True, + } + ], + "createdBy": "user", + "startsAt": "1970-01-01T00:00:00", + "endsAt": "9999-12-31T23:59:59.999999", + "comment": "Acknowledge alert from Matrix", + }, + silence, + ) - async def test_create_silence_raise_missing_label(self) -> None: - matchers = [ - AlertMatcher(label="alertname", value="alert1"), - AlertMatcher(label="severity", value="critical"), - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: + @freeze_time(datetime.utcfromtimestamp(0)) + async def test_create_silence_raise_duration_error(self) -> None: + async with FakeAlertmanagerServerWithoutSilence() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.create_silence( - "fingerprint1", - 86400, - "user", - matchers, - ) - - async def test_create_silence_raise_mismatch_label(self) -> None: - matchers = [AlertMatcher(label="alertname", value="alert2")] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.create_silence( - "fingerprint1", - 86400, - "user", - matchers, - ) - - async def test_create_silence_raise_mismatch_regex_label(self) -> None: - matchers: List[AlertMatcher] = [ - AlertRegexMatcher(label="alertname", regex=r"alert[^\d]+") - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.create_silence( - "fingerprint1", - 86400, - "user", - matchers, - ) + with self.assertRaises(InvalidDurationError): + await alertmanager.create_silence("fingerprint1", "user", -1) async def test_create_silence_raise_alert_not_found(self) -> None: async with FakeAlertmanagerServerWithoutAlert() as fake_alertmanager_server: @@ -336,7 +486,7 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): ) async with aiotools.closing_async(alertmanager): with self.assertRaises(AlertNotFoundError): - await alertmanager.create_silence("fingerprint1", 86400, "user", []) + await alertmanager.create_silence("fingerprint1", "user") async def test_create_silence_raise_alertmanager_error(self) -> None: async with FakeAlertmanagerServerWithErrorCreateSilence() as fake_alertmanager_server: @@ -348,111 +498,40 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): await alertmanager.get_alert("fingerprint1") with self.assertRaises(AlertmanagerServerError): - await alertmanager.create_silence("fingerprint1", 86400, "user", []) + await alertmanager.create_silence("fingerprint1", "user") - async def test_delete_silences_without_matchers(self) -> None: + async def test_delete_silence(self) -> None: async with FakeAlertmanagerServer() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - silences = await alertmanager.delete_silences("fingerprint2", []) + await alertmanager.delete_silence("silence1") + silences = await alertmanager.get_silences() - self.assertEqual(["silence1", "silence2"], silences) - - async def test_delete_silences_with_matchers(self) -> None: - matchers = [AlertMatcher(label="alertname", value="alert2")] + self.assertEqual([{"id": "silence2", "state": "expired"}], silences) + async def test_delete_silence_raise_silence_expired(self) -> None: async with FakeAlertmanagerServer() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( f"http://localhost:{port}", self.fake_cache ) async with aiotools.closing_async(alertmanager): - silences = await alertmanager.delete_silences("fingerprint2", matchers) + with self.assertRaises(SilenceExpiredError): + await alertmanager.delete_silence("silence2") + silences = await alertmanager.get_silences() - self.assertEqual(["silence1", "silence2"], silences) + self.assertEqual( + [ + {"id": "silence1", "state": "active"}, + {"id": "silence2", "state": "expired"}, + ], + silences, + ) - async def test_delete_silences_with_regex_matchers(self) -> None: - matchers: List[AlertMatcher] = [ - AlertRegexMatcher(label="alertname", regex=r"alert\d+") - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - silences = await alertmanager.delete_silences("fingerprint2", matchers) - - self.assertEqual(["silence1", "silence2"], silences) - - async def test_delete_silences_raise_missing_label(self) -> None: - matchers = [ - AlertMatcher(label="alertname", value="alert2"), - AlertMatcher(label="severity", value="critical"), - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.delete_silences("fingerprint2", matchers) - - async def test_delete_silences_raise_mismatch_label(self) -> None: - matchers = [ - AlertMatcher(label="alertname", value="alert1"), - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.delete_silences("fingerprint2", matchers) - - async def test_delete_silences_raise_mismatch_regex_label(self) -> None: - matchers: List[AlertMatcher] = [ - AlertRegexMatcher(label="alertname", regex=r"alert[^\d]+"), - ] - - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertMismatchError): - await alertmanager.delete_silences("fingerprint2", matchers) - - async def test_delete_silences_raise_silence_not_found(self) -> None: - async with FakeAlertmanagerServer() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(SilenceNotFoundError): - await alertmanager.delete_silences("fingerprint1", []) - - async def test_delete_silences_raise_alert_not_found(self) -> None: - async with FakeAlertmanagerServerWithoutAlert() as fake_alertmanager_server: - port = fake_alertmanager_server.port - alertmanager = AlertmanagerClient( - f"http://localhost:{port}", self.fake_cache - ) - async with aiotools.closing_async(alertmanager): - with self.assertRaises(AlertNotFoundError): - await alertmanager.delete_silences("fingerprint2", []) - - async def test_delete_silences_raise_alertmanager_error(self) -> None: + async def test_delete_silence_raise_alertmanager_error(self) -> None: async with FakeAlertmanagerServerWithErrorDeleteSilence() as fake_alertmanager_server: port = fake_alertmanager_server.port alertmanager = AlertmanagerClient( @@ -462,7 +541,7 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): await alertmanager.get_alert("fingerprint1") with self.assertRaises(AlertmanagerServerError): - await alertmanager.delete_silences("fingerprint2", []) + await alertmanager.delete_silence("silence1") async def test_find_alert_happy(self) -> None: alertmanager = AlertmanagerClient("http://localhost", self.fake_cache) @@ -474,9 +553,26 @@ class AlertmanagerClientTestCase(unittest.IsolatedAsyncioTestCase): async def test_find_alert_raise_alert_not_found(self) -> None: alertmanager = AlertmanagerClient("http://localhost", self.fake_cache) + with self.assertRaises(AlertNotFoundError): + alertmanager._find_alert("fingerprint1", []) + with self.assertRaises(AlertNotFoundError): alertmanager._find_alert("fingerprint2", [{"fingerprint": "fingerprint1"}]) + async def test_find_silence_happy(self) -> None: + alertmanager = AlertmanagerClient("http://localhost", self.fake_cache) + silence = alertmanager._find_silence("silence1", [{"id": "silence1"}]) + self.assertEqual({"id": "silence1"}, silence) + + async def test_find_silence_raise_silence_not_found(self) -> None: + alertmanager = AlertmanagerClient("http://localhost", self.fake_cache) + + with self.assertRaises(SilenceNotFoundError): + alertmanager._find_silence("silence1", []) + + with self.assertRaises(SilenceNotFoundError): + alertmanager._find_silence("silence2", [{"id": "silence1"}]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_callback.py b/tests/test_callback.py index e129c51..4311ef3 100644 --- a/tests/test_callback.py +++ b/tests/test_callback.py @@ -1,11 +1,12 @@ import unittest +from typing import Dict from unittest.mock import MagicMock, Mock, patch import nio from diskcache import Cache -import matrix_alertbot.command import matrix_alertbot.callback +import matrix_alertbot.command from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.callback import Callbacks from matrix_alertbot.command import BaseCommand @@ -86,10 +87,10 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help", self.fake_room, fake_message_event.sender, fake_message_event.event_id, + (), ) fake_command.return_value.process.assert_called_once() @@ -117,13 +118,47 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help", self.fake_room, fake_message_event.sender, fake_message_event.event_id, + (), ) fake_command.return_value.process.assert_called_once() + @patch.object(matrix_alertbot.command.CommandFactory, "create", autospec=True) + async def test_ignore_message_sent_by_bot(self, fake_create_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_message_event = Mock(spec=nio.RoomMessageText) + fake_message_event.sender = self.fake_client.user + + # Pretend that we received a text message event + await self.callbacks.message(self.fake_room, fake_message_event) + + # Check that we attempted to execute the command + fake_create_command.assert_not_called() + + @patch.object(matrix_alertbot.command.CommandFactory, "create", autospec=True) + async def test_ignore_message_sent_on_unauthorized_room( + self, fake_create_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 + + self.fake_room.room_id = "!unauthorizedroom@example.com" + + fake_message_event = Mock(spec=nio.RoomMessageText) + fake_message_event.sender = "@some_other_fake_user:example.com" + + self.assertNotEqual(self.fake_config.room_id, self.fake_room.room_id) + + # Pretend that we received a text message event + await self.callbacks.message(self.fake_room, fake_message_event) + + # Check that we attempted to execute the command + fake_create_command.assert_not_called() + @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) async def test_message_ack_not_in_reply_with_prefix( self, fake_command: Mock @@ -165,15 +200,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack", self.fake_room, fake_message_event.sender, fake_message_event.event_id, "some alert event id", + (), ) fake_command.return_value.process.assert_called_once() - @patch.object(matrix_alertbot.command, "UnackAlertCommand", autospec=True) + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) async def test_message_unack_not_in_reply_with_prefix( self, fake_command: Mock ) -> None: @@ -214,15 +249,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, fake_message_event.sender, fake_message_event.event_id, "some alert event id", + (), ) fake_command.return_value.process.assert_called_once() - @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) + @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""" # Tests that the bot process messages in the room that contain a command @@ -259,29 +294,21 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack 12h", 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_cache.set.assert_called_once_with( - fake_reaction_event.event_id, - fake_alert_event.event_id, - expire=self.fake_config.cache_expire_time, - ) self.fake_client.room_get_event.assert_called_once_with( self.fake_room.room_id, fake_alert_event.event_id ) - @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) - async def test_reaction_to_unknown_event(self, fake_command: Mock) -> None: + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_reaction_to_inexistent_event(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_config.user_id + fake_alert_event_id = "some alert event id" fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event.type = "m.reaction" @@ -290,7 +317,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_reaction_event.source = { "content": { "m.relates_to": { - "event_id": fake_alert_event.event_id, + "event_id": fake_alert_event_id, "key": "🤫", "rel_type": "m.annotation", } @@ -309,11 +336,11 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_command.assert_not_called() self.fake_cache.set.assert_not_called() self.fake_client.room_get_event.assert_called_once_with( - self.fake_room.room_id, fake_alert_event.event_id + self.fake_room.room_id, fake_alert_event_id ) - @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) - async def test_reaction_to_event_with_incorrect_sender( + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_reaction_to_event_not_from_bot_user( self, fake_command: Mock ) -> None: """Tests the callback for RoomMessageText with the command prefix""" @@ -352,12 +379,11 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_room.room_id, fake_alert_event.event_id ) - @patch.object(matrix_alertbot.command, "AckAlertCommand", autospec=True) + @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""" # 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_id = "some alert event id" fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event.type = "m.reaction" @@ -366,7 +392,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_reaction_event.source = { "content": { "m.relates_to": { - "event_id": fake_alert_event.event_id, + "event_id": fake_alert_event_id, "key": "unknown", "rel_type": "m.annotation", } @@ -380,17 +406,83 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_command.assert_not_called() self.fake_client.room_get_event.assert_not_called() - @patch.object(matrix_alertbot.command, "UnackAlertCommand", autospec=True) + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_ignore_reaction_sent_by_bot_user(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_reaction_event = Mock(spec=nio.UnknownEvent) + fake_reaction_event.type = "m.reaction" + fake_reaction_event.event_id = "some event id" + fake_reaction_event.sender = self.fake_client.user + fake_reaction_event.source = { + "content": { + "m.relates_to": { + "event_id": fake_alert_event_id, + "key": "unknown", + "rel_type": "m.annotation", + } + } + } + + # Pretend that we received a text message event + await self.callbacks.unknown(self.fake_room, fake_reaction_event) + await self.callbacks._reaction( + self.fake_room, fake_reaction_event, fake_alert_event_id + ) + + # Check that we attempted to execute the command + fake_command.assert_not_called() + self.fake_client.room_get_event.assert_not_called() + + @patch.object(matrix_alertbot.callback, "AckAlertCommand", autospec=True) + async def test_ignore_reaction_in_unauthorized_room( + 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 + self.fake_room.room_id = "!unauthorizedroom@example.com" + + fake_alert_event_id = "some alert event id" + + fake_reaction_event = Mock(spec=nio.UnknownEvent) + fake_reaction_event.type = "m.reaction" + fake_reaction_event.event_id = "some event id" + fake_reaction_event.sender = "@some_other_fake_user:example.com" + fake_reaction_event.source = { + "content": { + "m.relates_to": { + "event_id": fake_alert_event_id, + "key": "unknown", + "rel_type": "m.annotation", + } + } + } + + # Pretend that we received a text message event + await self.callbacks.unknown(self.fake_room, fake_reaction_event) + await self.callbacks._reaction( + self.fake_room, fake_reaction_event, fake_alert_event_id + ) + + # Check that we attempted to execute the command + fake_command.assert_not_called() + self.fake_client.room_get_event.assert_not_called() + + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) async def test_redaction_in_cache(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" - self.fake_cache.__getitem__.return_value = "some alert event id" - self.fake_cache.__contains__.return_value = True + fake_cache_dict = {fake_redaction_event.redacts: fake_alert_event_id} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ # Pretend that we received a text message event await self.callbacks.redaction(self.fake_room, fake_redaction_event) @@ -401,18 +493,17 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, fake_redaction_event.sender, fake_redaction_event.redacts, - "some alert event id", + fake_alert_event_id, ) fake_command.return_value.process.assert_called_once() self.fake_cache.__getitem__.assert_called_once_with( fake_redaction_event.redacts ) - @patch.object(matrix_alertbot.command, "UnackAlertCommand", autospec=True) + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) async def test_redaction_not_in_cache(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 @@ -421,13 +512,55 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase): fake_redaction_event.event_id = "some event id" fake_redaction_event.sender = "@some_other_fake_user:example.com" - self.fake_cache.__contains__.return_value = False + fake_cache_dict: Dict = {} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ # 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() + self.fake_cache.__getitem__.assert_called_once_with( + fake_redaction_event.redacts + ) + + @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""" + # Tests that the bot process messages in the room that contain a command + fake_redaction_event = Mock(spec=nio.RedactionEvent) + fake_redaction_event.sender = self.fake_client.user + + fake_cache_dict: Dict = {} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + + # 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() + self.fake_cache.__getitem__.assert_not_called() + + @patch.object(matrix_alertbot.callback, "UnackAlertCommand", autospec=True) + async def test_ignore_redaction_in_unauthorized_room( + 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 + self.fake_room.room_id = "!unauthorizedroom@example.com" + + fake_redaction_event = Mock(spec=nio.RedactionEvent) + fake_redaction_event.sender = "@some_other_fake_user:example.com" + + fake_cache_dict: Dict = {} + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + + # 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() + self.fake_cache.__getitem__.assert_not_called() @patch.object(matrix_alertbot.callback.CommandFactory, "create", autospec=True) async def test_unknown(self, fake_command_create: Mock) -> None: diff --git a/tests/test_command.py b/tests/test_command.py index 7e82b09..f7bc8fa 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,5 +1,5 @@ import unittest -from typing import List +from typing import Dict, Optional from unittest.mock import MagicMock, Mock, call, patch import nio @@ -20,13 +20,22 @@ from matrix_alertbot.errors import ( AlertNotFoundError, SilenceNotFoundError, ) -from matrix_alertbot.matcher import AlertMatcher, AlertRegexMatcher from tests.utils import make_awaitable +def cache_get_item(key: str) -> str: + return { + "some alert event id": "fingerprint1", + "fingerprint1": "silence1", + }[key] + + async def create_silence( - fingerprint: str, seconds: int, user: str, matchers: List[AlertMatcher] + fingerprint: str, + user: str, + seconds: Optional[int] = None, + silence_id: Optional[str] = None, ) -> str: if fingerprint == "fingerprint1": return "silence1" @@ -36,7 +45,10 @@ async def create_silence( async def create_silence_raise_alertmanager_error( - fingerprint: str, seconds: int, user: str, matchers: List[AlertMatcher] + fingerprint: str, + user: str, + seconds: Optional[int] = None, + silence_id: Optional[str] = None, ) -> str: if fingerprint == "fingerprint1": raise AlertmanagerError @@ -44,27 +56,24 @@ async def create_silence_raise_alertmanager_error( async def create_silence_raise_alert_not_found_error( - fingerprint: str, seconds: int, user: str, matchers: List[AlertMatcher] + fingerprint: str, + user: str, + seconds: Optional[int] = None, + silence_id: Optional[str] = None, ) -> str: if fingerprint == "fingerprint1": raise AlertNotFoundError return "silence1" -async def delete_silence_raise_alertmanager_error( - fingerprint: str, matchers: List[AlertMatcher] -) -> List[str]: - if fingerprint == "fingerprint1": +async def delete_silence_raise_alertmanager_error(silence_id: str) -> None: + if silence_id == "silence1": raise AlertmanagerError - return ["silence1"] -async def delete_silence_raise_silence_not_found_error( - fingerprint: str, matchers: List[AlertMatcher] -) -> List[str]: - if fingerprint == "fingerprint1": +async def delete_silence_raise_silence_not_found_error(silence_id: str) -> None: + if silence_id == "silence1": raise SilenceNotFoundError - return ["silence1"] class CommandTestCase(unittest.IsolatedAsyncioTestCase): @@ -75,15 +84,11 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): # Pretend that attempting to send a message is always successful self.fake_client.room_send.return_value = make_awaitable(None) - self.fake_fingerprints = ["fingerprint1", "fingerprint2"] - self.fake_silences = ["silence1", "silence2"] - self.fake_cache = MagicMock(spec=Cache) - self.fake_cache.__getitem__.return_value = self.fake_fingerprints + self.fake_cache.__getitem__.side_effect = cache_get_item self.fake_cache.__contains__.return_value = True self.fake_alertmanager = Mock(spec=AlertmanagerClient) - self.fake_alertmanager.delete_silences.return_value = self.fake_silences self.fake_alertmanager.create_silence.side_effect = create_silence # Create a fake room to play with @@ -122,6 +127,27 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): # Check that we attempted to process the command fake_ack.assert_called_once() + @patch.object(matrix_alertbot.command.AckAlertCommand, "process") + async def test_process_ack_with_duration_command(self, fake_ack: Mock) -> None: + """Tests the callback for InviteMemberEvents""" + # Tests that the bot attempts to join a room after being invited to it + + command = CommandFactory.create( + "ack 1w 3d", + self.fake_client, + self.fake_cache, + self.fake_alertmanager, + self.fake_config, + self.fake_room, + self.fake_sender, + self.fake_event_id, + self.fake_alert_event_id, + ) + await command.process() + + # Check that we attempted to process the command + fake_ack.assert_called_once() + @patch.object(matrix_alertbot.command.UnackAlertCommand, "process") async def test_process_unack_command(self, fake_unack: Mock) -> None: """Tests the callback for InviteMemberEvents""" @@ -185,18 +211,21 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): fake_unknown.assert_called_once() @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_ack_without_duration_nor_matchers( - self, fake_send_text_to_room: Mock - ) -> None: + async def test_ack_without_duration(self, fake_send_text_to_room: Mock) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack", self.fake_room, self.fake_sender, self.fake_event_id, @@ -205,162 +234,64 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): await command.process() # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( - [ - call(fingerprint, 86400, self.fake_sender, []) - for fingerprint in self.fake_fingerprints - ] + self.fake_alertmanager.create_silence.assert_called_once_with( + "fingerprint1", self.fake_sender, None, None ) fake_send_text_to_room.assert_called_once_with( self.fake_client, self.fake_room.room_id, - "Created 2 silences with a duration of 1d.", + "Created silence with ID silence1.", ) self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) + "86400", - tuple(self.fake_silences), - expire=86400, + self.fake_cache.get.assert_called_once_with("fingerprint1") + self.fake_cache.set.assert_has_calls( + [ + call("some event id", "fingerprint1", expire=None), + call("fingerprint1", "silence1", expire=None), + ] ) @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_ack_without_duration_and_with_matchers( - self, fake_send_text_to_room: Mock - ) -> None: + async def test_ack_with_duration(self, fake_send_text_to_room: Mock) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it - matchers = [ - AlertMatcher(label="alertname", value="alert1"), - AlertRegexMatcher(label="severity", regex="critical"), - ] + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack alertname=alert1 severity=~critical", self.fake_room, self.fake_sender, self.fake_event_id, self.fake_alert_event_id, + ("1w", "3d"), ) await command.process() # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( - [ - call( - fingerprint, - 86400, - self.fake_sender, - matchers, - ) - for fingerprint in self.fake_fingerprints - ] + self.fake_alertmanager.create_silence.assert_called_once_with( + "fingerprint1", self.fake_sender, 864000, None ) fake_send_text_to_room.assert_called_once_with( self.fake_client, self.fake_room.room_id, - "Created 2 silences with a duration of 1d.", + "Created silence with ID silence1.", ) self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) - + "86400" - + "".join(str(matcher) for matcher in matchers), - tuple(self.fake_silences), - expire=86400, - ) - - @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_ack_with_duration_and_without_matchers( - self, fake_send_text_to_room: Mock - ) -> None: - """Tests the callback for InviteMemberEvents""" - # Tests that the bot attempts to join a room after being invited to it - - command = AckAlertCommand( - self.fake_client, - self.fake_cache, - self.fake_alertmanager, - self.fake_config, - "ack 1w 3d", - self.fake_room, - self.fake_sender, - self.fake_event_id, - self.fake_alert_event_id, - ) - await command.process() - - # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( + self.fake_cache.get.assert_called_once_with("fingerprint1") + self.fake_cache.set.assert_has_calls( [ - call(fingerprint, 864000, self.fake_sender, []) - for fingerprint in self.fake_fingerprints + call("some event id", "fingerprint1", expire=864000), + call("fingerprint1", "silence1", expire=864000), ] ) - fake_send_text_to_room.assert_called_once_with( - self.fake_client, - self.fake_room.room_id, - "Created 2 silences with a duration of 1w 3d.", - ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) + "864000", - tuple(self.fake_silences), - expire=864000, - ) - - @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_ack_with_duration_and_matchers( - self, fake_send_text_to_room: Mock - ) -> None: - """Tests the callback for InviteMemberEvents""" - # Tests that the bot attempts to join a room after being invited to it - matchers = [ - AlertMatcher(label="alertname", value="alert1"), - AlertMatcher(label="severity", value="critical"), - ] - - command = AckAlertCommand( - self.fake_client, - self.fake_cache, - self.fake_alertmanager, - self.fake_config, - "ack 1w 3d alertname=alert1 severity=critical", - self.fake_room, - self.fake_sender, - self.fake_event_id, - self.fake_alert_event_id, - ) - await command.process() - - # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( - [ - call( - fingerprint, - 864000, - self.fake_sender, - matchers, - ) - for fingerprint in self.fake_fingerprints - ] - ) - fake_send_text_to_room.assert_called_once_with( - self.fake_client, - self.fake_room.room_id, - "Created 2 silences with a duration of 1w 3d.", - ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) - + "864000" - + "".join(str(matcher) for matcher in matchers), - tuple(self.fake_silences), - expire=864000, - ) @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_ack_raise_alertmanager_error( @@ -368,13 +299,18 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack", self.fake_room, self.fake_sender, self.fake_event_id, @@ -387,23 +323,17 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): await command.process() # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( - [ - call(fingerprint, 86400, self.fake_sender, []) - for fingerprint in self.fake_fingerprints - ] + self.fake_alertmanager.create_silence.assert_called_once_with( + "fingerprint1", self.fake_sender, None, None ) fake_send_text_to_room.assert_called_once_with( self.fake_client, self.fake_room.room_id, - "Created 1 silences with a duration of 1d.", + "Something went wrong with Alertmanager, therefore I couldn't create silence for alert fingerprint fingerprint1.", ) self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) + "86400", - ("silence1",), - expire=86400, - ) + self.fake_cache.get.assert_called_once_with("fingerprint1") + self.fake_cache.set.assert_not_called() @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_ack_raise_alert_not_found_error( @@ -411,13 +341,18 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack", self.fake_room, self.fake_sender, self.fake_event_id, @@ -430,32 +365,17 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): await command.process() # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_has_calls( - [ - call(fingerprint, 86400, self.fake_sender, []) - for fingerprint in self.fake_fingerprints - ] + self.fake_alertmanager.create_silence.assert_called_once_with( + "fingerprint1", self.fake_sender, None, None ) - fake_send_text_to_room.assert_has_calls( - [ - call( - self.fake_client, - self.fake_room.room_id, - "Sorry, I couldn't find 1 alerts, therefore I couldn't create their silence.", - ), - call( - self.fake_client, - self.fake_room.room_id, - "Created 1 silences with a duration of 1d.", - ), - ] + fake_send_text_to_room.assert_called_once_with( + self.fake_client, + self.fake_room.room_id, + "Sorry, I couldn't find alert with fingerprint fingerprint1, therefore I couldn't create the silence.", ) self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) - self.fake_cache.set.assert_called_once_with( - "".join(self.fake_fingerprints) + "86400", - ("silence1",), - expire=86400, - ) + self.fake_cache.get.assert_called_once_with("fingerprint1") + self.fake_cache.set.assert_not_called() @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_ack_with_invalid_duration( @@ -463,17 +383,16 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it - command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack invalid duration", self.fake_room, self.fake_sender, self.fake_event_id, self.fake_alert_event_id, + ("invalid duration",), ) await command.process() @@ -486,23 +405,25 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): "I tried really hard, but I can't convert the duration 'invalid duration' to a number of seconds.", ) self.fake_cache.__getitem__.assert_not_called() + self.fake_cache.get.assert_not_called() self.fake_cache.set.assert_not_called() @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_ack_with_event_not_found_in_cache( + async def test_ack_with_alert_event_not_found_in_cache( self, fake_send_text_to_room: Mock ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict: Dict = {} - self.fake_cache.__contains__.return_value = False + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "ack", self.fake_room, self.fake_sender, self.fake_event_id, @@ -514,52 +435,71 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): # Check that we attempted to create silences self.fake_alertmanager.create_silence.assert_not_called() fake_send_text_to_room.assert_not_called() - self.fake_cache.__getitem__.assert_not_called() + self.fake_cache.__getitem__.assert_called_once_with("some alert event id") + self.fake_cache.get.assert_not_called() self.fake_cache.set.assert_not_called() @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_unack_without_matchers(self, fake_send_text_to_room: Mock) -> None: + async def test_ack_with_silence_in_cache( + self, fake_send_text_to_room: Mock + ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + "fingerprint1": "silence2", + } - command = UnackAlertCommand( + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + self.fake_cache.get.side_effect = fake_cache_dict.get + + command = AckAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, self.fake_sender, self.fake_event_id, self.fake_alert_event_id, ) + await command.process() # Check that we attempted to create silences - self.fake_alertmanager.delete_silences.assert_has_calls( - [call(fingerprint, []) for fingerprint in self.fake_fingerprints] + self.fake_alertmanager.create_silence.assert_called_once_with( + "fingerprint1", self.fake_sender, None, "silence2" ) - fake_send_text_to_room.assert_called_with( - self.fake_client, self.fake_room.room_id, "Removed 4 silences." + fake_send_text_to_room.assert_called_once_with( + self.fake_client, + self.fake_room.room_id, + "Created silence with ID silence1.", + ) + self.fake_cache.__getitem__.assert_called_once_with("some alert event id") + self.fake_cache.get.assert_called_once_with("fingerprint1") + self.fake_cache.set.assert_has_calls( + [ + call(self.fake_event_id, "fingerprint1", expire=None), + call("fingerprint1", "silence1", expire=None), + ] ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) @patch.object(matrix_alertbot.command, "send_text_to_room") - async def test_unack_with_matchers(self, fake_send_text_to_room: Mock) -> None: + async def test_unack(self, fake_send_text_to_room: Mock) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + "fingerprint1": "silence1", + } - matchers = [ - AlertMatcher(label="alertname", value="alert1"), - AlertRegexMatcher(label="severity", regex="critical"), - ] + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ command = UnackAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack alertname=alert1 severity=~critical", self.fake_room, self.fake_sender, self.fake_event_id, @@ -568,13 +508,15 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): await command.process() # Check that we attempted to create silences - self.fake_alertmanager.delete_silences.assert_has_calls( - [call(fingerprint, matchers) for fingerprint in self.fake_fingerprints] + self.fake_alertmanager.delete_silence.assert_called_once_with("silence1") + fake_send_text_to_room.assert_called_once_with( + self.fake_client, + self.fake_room.room_id, + "Removed silence with ID silence1.", ) - fake_send_text_to_room.assert_called_with( - self.fake_client, self.fake_room.room_id, "Removed 4 silences." + self.fake_cache.__getitem__.assert_has_calls( + [call(self.fake_alert_event_id), call("fingerprint1")] ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_unack_silence_raise_alertmanager_error( @@ -582,32 +524,39 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + "fingerprint1": "silence1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ command = UnackAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, self.fake_sender, self.fake_event_id, self.fake_alert_event_id, ) - self.fake_alertmanager.delete_silences.side_effect = ( + self.fake_alertmanager.delete_silence.side_effect = ( delete_silence_raise_alertmanager_error ) await command.process() # Check that we attempted to create silences - self.fake_alertmanager.delete_silences.assert_has_calls( - [call(fingerprint, []) for fingerprint in self.fake_fingerprints] + self.fake_alertmanager.delete_silence.assert_called_once_with("silence1") + fake_send_text_to_room.assert_called_once_with( + self.fake_client, + self.fake_room.room_id, + "Something went wrong with Alertmanager, therefore I couldn't delete silence for alert fingerprint fingerprint1.", ) - fake_send_text_to_room.assert_called_with( - self.fake_client, self.fake_room.room_id, "Removed 1 silences." + self.fake_cache.__getitem__.assert_has_calls( + [call(self.fake_alert_event_id), call("fingerprint1")] ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_unack_raise_silence_not_found_error( @@ -615,43 +564,39 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = { + self.fake_alert_event_id: "fingerprint1", + "fingerprint1": "silence1", + } + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ command = UnackAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, self.fake_sender, self.fake_event_id, self.fake_alert_event_id, ) - self.fake_alertmanager.delete_silences.side_effect = ( + self.fake_alertmanager.delete_silence.side_effect = ( delete_silence_raise_silence_not_found_error ) await command.process() # Check that we attempted to create silences - self.fake_alertmanager.delete_silences.assert_has_calls( - [call(fingerprint, []) for fingerprint in self.fake_fingerprints] + self.fake_alertmanager.delete_silence.assert_called_once_with("silence1") + fake_send_text_to_room.assert_called_once_with( + self.fake_client, + self.fake_room.room_id, + "Sorry, I couldn't find alert with fingerprint fingerprint1, therefore I couldn't remove its silence.", ) - fake_send_text_to_room.assert_has_calls( - [ - call( - self.fake_client, - self.fake_room.room_id, - "Sorry, I couldn't find 1 alerts, therefore I couldn't remove their silences.", - ), - call( - self.fake_client, - self.fake_room.room_id, - "Removed 1 silences.", - ), - ] + self.fake_cache.__getitem__.assert_has_calls( + [call(self.fake_alert_event_id), call("fingerprint1")] ) - self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_unack_with_event_not_found_in_cache( @@ -659,15 +604,15 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): ) -> None: """Tests the callback for InviteMemberEvents""" # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict: Dict = {} - self.fake_cache.__contains__.return_value = False + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ command = UnackAlertCommand( self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config, - "unack", self.fake_room, self.fake_sender, self.fake_event_id, @@ -677,9 +622,39 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): await command.process() # Check that we attempted to create silences - self.fake_alertmanager.create_silence.assert_not_called() + self.fake_alertmanager.delete_silence.assert_not_called() fake_send_text_to_room.assert_not_called() - self.fake_cache.__getitem__.assert_not_called() + self.fake_cache.__getitem__.assert_called_once_with(self.fake_alert_event_id) + + @patch.object(matrix_alertbot.command, "send_text_to_room") + async def test_unack_with_silence_not_found_in_cache( + self, fake_send_text_to_room: Mock + ) -> None: + """Tests the callback for InviteMemberEvents""" + # Tests that the bot attempts to join a room after being invited to it + fake_cache_dict = {self.fake_alert_event_id: "fingerprint1"} + + self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ + + command = UnackAlertCommand( + self.fake_client, + self.fake_cache, + self.fake_alertmanager, + self.fake_config, + self.fake_room, + self.fake_sender, + self.fake_event_id, + self.fake_alert_event_id, + ) + + await command.process() + + # Check that we attempted to create silences + self.fake_alertmanager.delete_silence.assert_not_called() + fake_send_text_to_room.assert_not_called() + self.fake_cache.__getitem__.assert_has_calls( + [call(self.fake_alert_event_id), call("fingerprint1")] + ) @patch.object(matrix_alertbot.command, "send_text_to_room") async def test_help_without_topic(self, fake_send_text_to_room: Mock) -> None: @@ -691,7 +666,6 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help", self.fake_room, self.fake_sender, self.fake_event_id, @@ -714,10 +688,10 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help rules", self.fake_room, self.fake_sender, self.fake_event_id, + ("rules",), ) await command.process() @@ -737,10 +711,10 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help commands", self.fake_room, self.fake_sender, self.fake_event_id, + ("commands",), ) await command.process() @@ -760,10 +734,10 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "help unknown", self.fake_room, self.fake_sender, self.fake_event_id, + ("unknown",), ) await command.process() @@ -783,7 +757,6 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): self.fake_cache, self.fake_alertmanager, self.fake_config, - "", self.fake_room, self.fake_sender, self.fake_event_id, @@ -795,7 +768,7 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): fake_send_text_to_room.assert_called_once_with( self.fake_client, self.fake_room.room_id, - "Unknown command ''. Try the 'help' command for more information.", + "Unknown command. Try the 'help' command for more information.", ) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 01d34f7..fc17d3a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,6 +1,6 @@ import unittest from typing import Dict -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch import aiohttp.test_utils import nio @@ -62,26 +62,35 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): return webhook.app @patch.object(matrix_alertbot.webhook, "send_text_to_room") - async def test_post_alert(self, fake_send_text_to_room: Mock) -> None: + async def test_post_alerts(self, fake_send_text_to_room: Mock) -> None: data = self.fake_alerts async with self.client.request("POST", "/alerts", json=data) as response: self.assertEqual(200, response.status) - fake_send_text_to_room.assert_called_once_with( - self.fake_client, - self.fake_config.room_id, - "[🔥 CRITICAL] alert1: some description1\n" - "[🥦 RESOLVED] alert2: some description2", - "[🔥 CRITICAL] " - "alert1 (job1)
" - "some description1
\n" - "[🥦 RESOLVED] " - "alert2 (job2)
" - "some description2", - notice=False, + fake_send_text_to_room.assert_has_calls( + [ + call( + self.fake_client, + self.fake_config.room_id, + "[🔥 CRITICAL] alert1: some description1", + "[🔥 CRITICAL] " + "alert1 (job1)
" + "some description1", + notice=False, + ), + call( + self.fake_client, + self.fake_config.room_id, + "[🥦 RESOLVED] alert2: some description2", + "[🥦 RESOLVED] " + "alert2 (job2)
" + "some description2", + notice=False, + ), + ] ) @patch.object(matrix_alertbot.webhook, "send_text_to_room") - async def test_post_alert_with_empty_data( + async def test_post_alerts_with_empty_data( self, fake_send_text_to_room: Mock ) -> None: async with self.client.request("POST", "/alerts", json={}) as response: @@ -91,9 +100,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): fake_send_text_to_room.assert_not_called() @patch.object(matrix_alertbot.webhook, "send_text_to_room") - async def test_post_alert_with_empty_alerts( - self, fake_send_text_to_room: Mock - ) -> None: + async def test_post_empty_alerts(self, fake_send_text_to_room: Mock) -> None: data: Dict = {"alerts": []} async with self.client.request("POST", "/alerts", json=data) as response: self.assertEqual(400, response.status) @@ -102,9 +109,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): fake_send_text_to_room.assert_not_called() @patch.object(matrix_alertbot.webhook, "send_text_to_room") - async def test_post_alert_with_invalid_alerts( - self, fake_send_text_to_room: Mock - ) -> None: + async def test_post_invalid_alerts(self, fake_send_text_to_room: Mock) -> None: data = {"alerts": "invalid"} async with self.client.request("POST", "/alerts", json=data) as response: self.assertEqual(400, response.status) @@ -113,7 +118,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): fake_send_text_to_room.assert_not_called() @patch.object(matrix_alertbot.webhook, "send_text_to_room") - async def test_post_alert_with_empty_items( + async def test_post_alerts_with_empty_items( self, fake_send_text_to_room: Mock ) -> None: data: Dict = {"alerts": [{}]} @@ -128,7 +133,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): "send_text_to_room", side_effect=send_text_to_room_raise_error, ) - async def test_post_alert_with_send_error( + async def test_post_alerts_raise_send_error( self, fake_send_text_to_room: Mock ) -> None: data = self.fake_alerts @@ -136,7 +141,8 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): self.assertEqual(500, response.status) error_msg = await response.text() self.assertEqual( - "An error occured when sending alerts to Matrix room.", error_msg + "An error occured when sending alert with fingerprint 'fingerprint1' to Matrix room.", + error_msg, ) fake_send_text_to_room.assert_called_once()