diff --git a/conf/outputs.json b/conf/outputs.json index 2ad372258..d15fea71c 100644 --- a/conf/outputs.json +++ b/conf/outputs.json @@ -25,5 +25,8 @@ ], "slack": [ "sample-channel" + ], + "teams": [ + "sample-webhook" ] -} +} \ No newline at end of file diff --git a/docs/source/outputs.rst b/docs/source/outputs.rst index 9b2a93530..7617ffd3a 100644 --- a/docs/source/outputs.rst +++ b/docs/source/outputs.rst @@ -22,6 +22,7 @@ Out of the box, StreamAlert supports: * **PagerDuty** * **Phantom** * **Slack** +* **Microsoft Teams** StreamAlert can be extended to support any API. Creating a new output to send alerts to is easily accomplished through inheritance from the ``StreamOutputBase`` class. More on that in the `Adding Support for New Services`_ section below. @@ -41,7 +42,7 @@ Adding a new configuration for a currently supported service is handled using `` - ```` above should be one of the following supported service identifiers: ``aws-cloudwatch-log``, ``aws-firehose``, ``aws-lambda``, ``aws-s3``, ``aws-sns``, ``aws-sqs``, ``carbonblack``, ``github``, ``jira``, ``komand``, ``pagerduty``, ``pagerduty-incident``, - ``pagerduty-v2``, ``phantom``, ``slack`` + ``pagerduty-v2``, ``phantom``, ``slack``, ``teams`` For example: - ``python manage.py output slack`` diff --git a/requirements-top-level.txt b/requirements-top-level.txt index 8082d8970..7755e21d6 100644 --- a/requirements-top-level.txt +++ b/requirements-top-level.txt @@ -21,6 +21,7 @@ pathlib2 policyuniverse pyfakefs pylint==2.3.1 +pymsteams requests Sphinx sphinx-rtd-theme diff --git a/requirements.txt b/requirements.txt index 90824c2ec..7683ce1b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -83,6 +83,7 @@ pycparser==2.19 pyflakes==2.1.1 Pygments==2.4.2 PyJWT==1.7.1 +pymsteams==0.1.12 pyparsing==2.4.2 pyrsistent==0.15.5 pytest==5.0.0 diff --git a/streamalert/alert_processor/outputs/teams.py b/streamalert/alert_processor/outputs/teams.py new file mode 100644 index 000000000..88bca628a --- /dev/null +++ b/streamalert/alert_processor/outputs/teams.py @@ -0,0 +1,260 @@ +""" +Copyright 2017-present, Airbnb Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from collections import OrderedDict + +import pymsteams +from pymsteams import TeamsWebhookException + +from streamalert.alert_processor.helpers import compose_alert +from streamalert.alert_processor.outputs.output_base import ( + OutputDispatcher, + OutputProperty, + StreamAlertOutput, +) +from streamalert.shared.logger import get_logger + +LOGGER = get_logger(__name__) + + +@StreamAlertOutput +class TeamsOutput(OutputDispatcher): + """TeamsOutput handles all alert dispatching for Microsoft Teams""" + + __service__ = "teams" + + @classmethod + def get_user_defined_properties(cls): + """Get properties that must be assigned by the user when configuring a new Microsoft Teams + output. This should be sensitive or unique information for this use-case that needs + to come from the user. + + Every output should return a dict that contains a 'descriptor' with a description of the + integration being configured. + + Microsoft Teams also requires a user provided 'webhook' url that is composed of the Team's + api url and the unique integration key for this output. This value should be should be + masked during input and is a credential requirement. + + Returns: + OrderedDict: Contains various OutputProperty items + """ + return OrderedDict( + [ + ( + "descriptor", + OutputProperty( + description="a short and unique descriptor for this service configuration " + "(ie: name of Team the webhook relates too)" + ), + ), + ( + "url", + OutputProperty( + description="the full teams webhook url, including the secret", + mask_input=True, + cred_requirement=True, + ), + ), + ] + ) + + @classmethod + def _format_message(cls, alert, publication, webhook_url): + """Format the message to be sent to Teams + + Args: + alert (Alert): The alert + publication (dict): Alert relevant to the triggered rule + webhook_url (str): The webhook_url to send the card too + + Returns: + pymsteams.connectorcard: The message to be sent to Teams + The card will look like (by Default): + StreamAlert Rule Triggered: rule_name + Rule Description: + This will be the docstring from the rule, sent as the rule_description + + Record: + key value + key value + ... + """ + # Presentation defaults + default_title = "StreamAlert Rule Triggered: {}".format(alert.rule_name) + default_description = alert.rule_description + default_color = "E81123" # Red in Hexstring format + + # Special field that Publishers can use to customize the message + title = publication.get("@teams.title", default_title) + description = publication.get("@teams.description", default_description) + card_color = publication.get("@teams.card_color", default_color) + with_record = publication.get("@teams.with_record", True) + + # Instantiate the card with the url + teams_card = pymsteams.connectorcard(webhook_url) + + # Set the cards title, text and color + teams_card.title(title) + teams_card.text(description) + teams_card.color(card_color) + + # Add the Alert Section + teams_card.addSection(cls._generate_alert_section(alert)) + + if with_record: + # Add the record Section + teams_card.addSection(cls._generate_record_section(alert.record)) + + if "@teams.additional_card_sections" in publication: + teams_card = cls._add_additional_sections( + teams_card, publication["@teams.additional_card_sections"] + ) + + return teams_card + + @classmethod + def _generate_record_section(cls, record): + """Generate the record section + + This adds the entire record to a section as key/value pairs + + Args: + record (dict): asd + Returns: + record_section (pymsteams.cardsection): record section for the outgoing card + """ + # Instantiate the card section + record_section = pymsteams.cardsection() + + # Set the title + record_section.activityTitle("StreamAlert Alert Record") + + # Add the record as key/value pairs + for key, value in record.items(): + record_section.addFact(key, str(value)) + + return record_section + + @classmethod + def _generate_alert_section(cls, alert): + """Generate the alert section + + Args: + alert (Alert): The alert + Returns: + alert_section (pymsteams.cardsection): alert section for the outgoing card + """ + + # Instantiate the card + alert_section = pymsteams.cardsection() + + # Set the title + alert_section.activityTitle("Alert Info") + + # Add basic information to the alert section + alert_section.addFact("rule_name", alert.rule_name) + alert_section.addFact("alert_id", alert.alert_id) + + return alert_section + + @staticmethod + def _add_additional_sections(teams_card, additional_sections): + """Add additional card sections to the teams card + + Args: + teams_card (pymsteams.connectorcard): Teams connector card + additional_sections (list[pymsteams.cardsection]): + Additional sections to be added to the card. Each section should be of + type: pymsteams.cardsection and have their relevant fields filled out. + Please review the pymsteams documentation for additional information. + + Returns: + teams_card (pymsteams.connectorcard): teams_card with additional sections added + """ + if not isinstance(additional_sections, list): + LOGGER.debug("additional_sections is not a list, converting") + + additional_sections = [additional_sections] + + for additional_section in additional_sections: + if not isinstance(additional_section, pymsteams.cardsection): + LOGGER.error( + "additional_section: %s is not an instance of %s", + additional_section, + pymsteams.cardsection, + ) + continue + + teams_card.addSection(additional_section) + + return teams_card + + def _dispatch(self, alert, descriptor): + """Sends the Teams Card to Teams + + Publishing: + By default the teams output sends a teams card comprising some default intro text + and a section containing: + * title with rule name + * alert description + * alert record (as a section of key/value pairs) + + To override this behavior use the following fields: + + - @teams.title (str): + Replaces the title of the teams connector card. + + - @teams.description (str): + Replaces the text of the team connector card + + - @teams.card_color (str): + Replaces the default color of the connector card (red) + Note: colors are represented by hex string + + - @teams.with_record (bool): + Set to False, to remove the alert record section. Useful if you want to have a + more targeted approach for the alert + + - @teams.additional_card_sections (list[pymsteams.cardsection]): + Pass in additional sections you want to send on the message. + + @see cls._add_additional_sections() for more info + + Args: + alert (Alert): Alert instance which triggered a rule + descriptor (str): Output descriptor + + Returns: + bool: True if alert was sent successfully, False otherwise + """ + creds = self._load_creds(descriptor) + if not creds: + LOGGER.error("No credentials found for descriptor: %s", descriptor) + return False + + # Create the publication + publication = compose_alert(alert, self, descriptor) + + # Format the message + teams_card = self._format_message(alert, publication, creds["url"]) + + try: + teams_card.send() + except TeamsWebhookException as err: + LOGGER.error("Error Sending Alert to Teams: %s", err) + return False + + return True diff --git a/streamalert_cli/manage_lambda/package.py b/streamalert_cli/manage_lambda/package.py index f597ddc83..c66295ecd 100644 --- a/streamalert_cli/manage_lambda/package.py +++ b/streamalert_cli/manage_lambda/package.py @@ -54,6 +54,7 @@ class LambdaPackage: 'netaddr': 'netaddr==0.7.19', 'policyuniverse': 'policyuniverse==1.3.2.1', 'requests': 'requests==2.22.0', + 'pymsteams': 'pymsteams==0.1.12' } def __init__(self, config): @@ -226,7 +227,7 @@ class AlertProcessorPackage(LambdaPackage): 'streamalert/shared' } package_name = 'alert_processor' - package_libs = {'cbapi', 'netaddr', 'requests'} + package_libs = {'cbapi', 'netaddr', 'pymsteams', 'requests'} class AlertMergerPackage(LambdaPackage): diff --git a/tests/unit/streamalert/alert_processor/outputs/test_output_base.py b/tests/unit/streamalert/alert_processor/outputs/test_output_base.py index bd4de718d..d94352dde 100644 --- a/tests/unit/streamalert/alert_processor/outputs/test_output_base.py +++ b/tests/unit/streamalert/alert_processor/outputs/test_output_base.py @@ -106,7 +106,8 @@ def test_output_loading(): 'pagerduty-v2', 'pagerduty-incident', 'phantom', - 'slack' + 'slack', + 'teams' } assert_count_equal(loaded_outputs, expected_outputs) diff --git a/tests/unit/streamalert/alert_processor/outputs/test_teams.py b/tests/unit/streamalert/alert_processor/outputs/test_teams.py new file mode 100644 index 000000000..0b7e685b8 --- /dev/null +++ b/tests/unit/streamalert/alert_processor/outputs/test_teams.py @@ -0,0 +1,517 @@ +""" +Copyright 2017-present, Airbnb Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +# pylint: disable=protected-access,attribute-defined-outside-init,no-self-use,no-member +import pymsteams +from pymsteams import TeamsWebhookException +from mock import MagicMock, Mock, patch, call +from nose.tools import assert_equal, assert_false, assert_true + +from streamalert.alert_processor.helpers import compose_alert +from streamalert.alert_processor.outputs.teams import TeamsOutput +from tests.unit.streamalert.alert_processor.helpers import get_alert, get_random_alert + + +@patch( + "streamalert.alert_processor.outputs.output_base.OutputDispatcher.MAX_RETRY_ATTEMPTS", + 1, +) +class TestTeamsOutput: + """Test class for Teams""" + + DESCRIPTOR = "unit_test_channel" + SERVICE = "teams" + OUTPUT = ":".join([SERVICE, DESCRIPTOR]) + CREDS = { + "url": "https://outlook.office.com/webhook/GUID@GUID/IncomingWebhook/WEBHOOK-ID/KEY" + } + + @patch("streamalert.alert_processor.outputs.output_base.OutputCredentialsProvider") + def setup(self, provider_constructor): + """Setup before each method""" + provider = MagicMock() + provider_constructor.return_value = provider + provider.load_credentials = Mock( + side_effect=lambda x: self.CREDS if x == self.DESCRIPTOR else None + ) + + self._provider = provider + self._dispatcher = TeamsOutput(None) + + @patch("pymsteams.cardsection") + def test_generate_record_section(self, section_mock): + """TeamsOutput - _generate_record_section - Teams""" + section_mock.return_value = Mock( + activityTitle=Mock(), + addFact=Mock() + ) + + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + + section = self._dispatcher._generate_record_section(alert.record) + + # Tests + section_mock.assert_called() + section.activityTitle.assert_called() + section.activityTitle.assert_called_with("StreamAlert Alert Record") + section.addFact.assert_called() + section.addFact.assert_has_calls( + [ + call(key, value) for key, value in alert.record.items() + ], + any_order=True + ) + + @patch("pymsteams.cardsection") + def test_generate_alert_section(self, section_mock): + """TeamsOutput - _generate_alert_section - Teams""" + section_mock.return_value = Mock( + activityTitle=Mock(), + addFact=Mock() + ) + + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + + section = self._dispatcher._generate_alert_section(alert) + + # Tests + section_mock.assert_called() + section.activityTitle.assert_called() + section.activityTitle.assert_called_with("Alert Info") + section.addFact.assert_called() + section.addFact.assert_has_calls( + [ + call('rule_name', alert.rule_name), + call('alert_id', alert.alert_id) + ], + any_order=False + ) + + @patch("logging.Logger.error") + def test_add_additional_sections_single_section(self, log_mock): + """TeamsOutput - _add_additional_sections - single section""" + teams_card = Mock( + addSection=Mock() + ) + section = Mock(spec=pymsteams.cardsection) + + teams_card = self._dispatcher._add_additional_sections(teams_card, section) + + # Tests + teams_card.addSection.assert_called() + teams_card.addSection.assert_called_with(section) + log_mock.assert_not_called() + + @patch("logging.Logger.error") + def test_add_additional_sections_multiple_sections(self, log_mock): + """TeamsOutput - _add_additional_sections - multiple sections""" + teams_card = Mock( + addSection=Mock() + ) + section_1 = Mock(spec=pymsteams.cardsection) + section_2 = Mock(spec=pymsteams.cardsection) + + teams_card = self._dispatcher._add_additional_sections(teams_card, [section_1, section_2]) + + # Tests + teams_card.addSection.assert_called() + teams_card.addSection.assert_has_calls( + [ + call(section_1), + call(section_2) + ], + any_order=False + ) + log_mock.assert_not_called() + + @patch("logging.Logger.error") + def test_add_additional_sections_logs_error(self, log_mock): + """TeamsOutput - _add_additional_sections - logs error""" + teams_card = Mock( + addSection=Mock() + ) + invalid_section = "i am not a card section" + + teams_card = self._dispatcher._add_additional_sections(teams_card, [invalid_section]) + + # Tests + teams_card.addSection.assert_not_called() + log_mock.assert_called() + log_mock.assert_called_with( + "additional_section: %s is not an instance of %s", + invalid_section, + pymsteams.cardsection, + ) + + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_default(self, _, alert_section_mock, record_section_mock): + """TeamsOutput - Format Default Message - Teams""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with( + "StreamAlert Rule Triggered: {}".format(alert.rule_name) + ) + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with(alert.rule_description) + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("E81123") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_called() + record_section_mock.assert_called_with(alert.record) + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section'), + call('Record_Section') + ], + any_order=False + ) + + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_custom_title(self, _, alert_section_mock, record_section_mock): + """TeamsOutput - Format Message - Custom Title""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + alert_publication["@teams.title"] = "This is a test" + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with("This is a test") + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with(alert.rule_description) + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("E81123") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_called() + record_section_mock.assert_called_with(alert.record) + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section'), + call('Record_Section') + ], + any_order=False + ) + + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_custom_text(self, _, alert_section_mock, record_section_mock): + """TeamsOutput - Format Message - Custom description / text""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + alert_publication["@teams.description"] = "This is a test" + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with( + "StreamAlert Rule Triggered: {}".format(alert.rule_name) + ) + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with("This is a test") + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("E81123") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_called() + record_section_mock.assert_called_with(alert.record) + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section'), + call('Record_Section') + ], + any_order=False + ) + + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_custom_color(self, _, alert_section_mock, record_section_mock): + """TeamsOutput - Format Message - Custom color""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + alert_publication["@teams.card_color"] = "46eb34" + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with( + "StreamAlert Rule Triggered: {}".format(alert.rule_name) + ) + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with(alert.rule_description) + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("46eb34") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_called() + record_section_mock.assert_called_with(alert.record) + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section'), + call('Record_Section') + ], + any_order=False + ) + + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_no_record(self, _, alert_section_mock, record_section_mock): + """TeamsOutput - Format Message - No Record""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + alert_publication["@teams.with_record"] = False + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with( + "StreamAlert Rule Triggered: {}".format(alert.rule_name) + ) + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with(alert.rule_description) + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("E81123") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_not_called() + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section') + ], + any_order=False + ) + + @patch.object(TeamsOutput, "_add_additional_sections") + @patch.object(TeamsOutput, "_generate_record_section") + @patch.object(TeamsOutput, "_generate_alert_section") + @patch("pymsteams.connectorcard", spec=pymsteams.connectorcard) + def test_format_message_additional_sections( + self, + _, + alert_section_mock, + record_section_mock, + add_sections_mock + ): + """TeamsOutput - Format Message - Additional card sections""" + rule_name = "test_rule_default" + alert = get_random_alert(25, rule_name) + output = MagicMock(spec=TeamsOutput) + alert_publication = compose_alert(alert, output, "asdf") + alert_publication["@teams.with_record"] = True + + # Setup test_card_section + test_card_section = Mock() + + alert_section_mock.return_value = "Alert_Section" + record_section_mock.return_value = "Record_Section" + + add_sections_mock.side_effect = (lambda teams_card, _: teams_card) + + # Pass card section in via Publisher + alert_publication["@teams.additional_card_sections"] = [test_card_section] + + loaded_message = self._dispatcher._format_message( + alert, alert_publication, self.CREDS["url"] + ) + + # Tests + + # Verify title + loaded_message.title.assert_called() + loaded_message.title.assert_called_with( + "StreamAlert Rule Triggered: {}".format(alert.rule_name) + ) + + # Verify text/description + loaded_message.text.assert_called() + loaded_message.text.assert_called_with(alert.rule_description) + + # Verify card color + loaded_message.color.assert_called() + loaded_message.color.assert_called_with("E81123") + + # Verify Sections + alert_section_mock.assert_called() + alert_section_mock.assert_called_with(alert) + record_section_mock.assert_called() + record_section_mock.assert_called_with(alert.record) + add_sections_mock.assert_called() + assert_equal(add_sections_mock.call_count, 1) + loaded_message.addSection.assert_called() + loaded_message.addSection.assert_has_calls( + [ + call('Alert_Section'), + call('Record_Section') + ], + any_order=False + ) + + @patch("logging.Logger.info") + @patch.object(TeamsOutput, "_format_message") + def test_dispatch_success(self, message_mock, log_mock): + """TeamsOutput - Dispatch Success""" + + message_mock.return_value = Mock( + send=Mock(return_value="Worked") + ) + + assert_true(self._dispatcher.dispatch(get_alert(), self.OUTPUT)) + + # Tests + log_mock.assert_called() + log_mock.assert_called_with( + "Successfully sent alert to %s:%s", self.SERVICE, self.DESCRIPTOR + ) + + @patch("logging.Logger.error") + @patch.object(TeamsOutput, "_format_message") + def test_dispatch_failure(self, message_mock, log_mock): + """TeamsOutput - Dispatch Failure, Bad Request""" + exception = TeamsWebhookException("BOOM!") + + message_mock.return_value = Mock( + send=Mock(side_effect=exception) + ) + assert_false(self._dispatcher.dispatch(get_alert(), self.OUTPUT)) + + # Tests + log_mock.assert_called() + log_mock.assert_has_calls( + [ + call('Error Sending Alert to Teams: %s', exception), + call('Failed to send alert to %s:%s', 'teams', 'unit_test_channel') + ] + ) + + @patch("logging.Logger.error") + @patch.object(TeamsOutput, "_load_creds") + def test_dispatch_no_creds(self, creds_mock, log_mock): + """TeamsOutput - Dispatch Failure, No Creds""" + creds_mock.return_value = None + descriptor = "bad_descriptor" + + # Tests + assert_false( + self._dispatcher.dispatch( + get_alert(), ":".join([self.SERVICE, descriptor]) + ) + ) + log_mock.assert_called() + log_mock.assert_has_calls( + [ + call("No credentials found for descriptor: %s", descriptor), + call("Failed to send alert to %s:%s", self.SERVICE, descriptor) + ], + any_order=False + )