# coding=utf-8 # (C) Copyright 2014-2016 Hewlett Packard Enterprise Development LP # Copyright 2017 Fujitsu LIMITED # # 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. import base64 import datetime import email.header import smtplib import socket import time from unittest import mock from urllib import parse from urllib.parse import urlparse from monasca_notification.notification import Notification from monasca_notification.plugins import email_notifier from tests import base UNICODE_CHAR = chr(2344) UNICODE_CHAR_ENCODED = UNICODE_CHAR.encode("utf-8") def alarm(metrics): return {"tenantId": "0", "alarmId": "0", "alarmName": u"test Alarm " + UNICODE_CHAR, "oldState": "OK", "newState": "ALARM", "severity": "LOW", "link": "some-link", "lifecycleState": "OPEN", "stateChangeReason": u"I am alarming!" + UNICODE_CHAR, "timestamp": time.time(), "metrics": metrics} def _parse_email(email_msg): raw_mail = {"raw": email_msg} email_lines = email_msg.splitlines() from_addr, subject, to_addr = _decode_headers(email_lines) raw_mail['subject'] = subject[0].decode(subject[1]) raw_mail['from'] = from_addr[0] raw_mail['to'] = to_addr[0] raw_mail['body'] = (base64.b64decode('\n'.join(email_lines[8:])) .decode('utf-8')) return raw_mail def _decode_headers(email_lines): # message is encoded, so we need to carefully go through all the lines # to pick ranges for subject, from and to keys = ['Subject', 'From', 'To'] subject, from_addr, to_addr = None, None, None for key_idx, key in enumerate(keys): accummulated = [] for idx in range(3, len(email_lines) - 1): line = email_lines[idx] if not line: break if key in line: accummulated.append(line) try: if keys[key_idx + 1] not in email_lines[idx + 1]: accummulated.append(email_lines[idx + 1]) else: break except IndexError: pass if key == 'Subject': subject = email.header.decode_header(''.join(accummulated))[1] if key == 'From': from_addr = email.header.decode_header(''.join(accummulated))[0] if key == 'To': to_addr = email.header.decode_header(''.join(accummulated))[0] return from_addr, subject, to_addr class smtpStub(object): def __init__(self, trap): self.trap = trap def sendmail(self, from_addr, to_addr, msg): self.trap.append("{} {} {}".format(from_addr, to_addr, msg)) class smtpStubException(object): def __init__(self, queue): self.queue = queue def sendmail(self, from_addr, to_addr, msg): raise smtplib.SMTPServerDisconnected class TestEmail(base.PluginTestCase): def setUp(self): super(TestEmail, self).setUp(email_notifier.register_opts) self.trap = [] self.conf_override(group='email_notifier', server='my.smtp.server', port=25, user=None, password=None, timeout=60, from_addr='hpcs.mon@hp.com', grafana_url='http://127.0.0.1:3000') def _smtpStub(self, *arg, **kwargs): return smtpStub(self.trap) def _smtbStubException(self, *arg, **kwargs): return smtpStubException(self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def notify(self, smtp_stub, metric, mock_smtp): mock_smtp.SMTP = smtp_stub mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append email = email_notifier.EmailNotifier(mock_log) alarm_dict = alarm(metric) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) self.trap.append(email.send_notification(notification)) def test_email_notification_single_host(self): """Email with single host """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, u'service' + UNICODE_CHAR: 'bar1'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) email = _parse_email(self.trap.pop(0)) self.assertRegex(email['from'], 'hpcs.mon@hp.com') self.assertRegex(email['to'], 'me@here.com') self.assertRegex(email['raw'], 'Content-Type: text/plain') self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64') self.assertRegex(email['subject'], 'ALARM LOW "test Alarm .*" for Host: foo1.*') self.assertRegex(email['body'], 'Alarm .test Alarm.') self.assertRegex(email['body'], 'On host .foo1.') self.assertRegex(email['body'], UNICODE_CHAR) self.assertRegex(email['body'], 'Link: some-link') self.assertRegex(email['body'], 'Lifecycle state: OPEN') return_value = self.trap.pop(0) self.assertTrue(return_value) def test_email_notification_target_host(self): """Email with single host """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, u'service' + UNICODE_CHAR: 'bar1', u'target_host': u'some_where'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) email = _parse_email(self.trap.pop(0)) self.assertRegex(email['from'], 'hpcs.mon@hp.com') self.assertRegex(email['to'], 'me@here.com') self.assertRegex(email['raw'], 'Content-Type: text/plain') self.assertRegex(email['raw'], 'Content-Transfer-Encoding: base64') self.assertRegex(email['subject'], 'ALARM LOW .test Alarm.* Target: some_where') self.assertRegex(email['body'], "Alarm .test Alarm.") self.assertRegex(email['body'], "On host .foo1.") self.assertRegex(email['body'], UNICODE_CHAR) return_value = self.trap.pop(0) self.assertTrue(return_value) def worktest_email_notification_multiple_hosts(self): """Email with multiple hosts """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) email = _parse_email(self.trap.pop(0)) self.assertRegex(email['from'], "From: hpcs.mon@hp.com") self.assertRegex(email['to'], "To: me@here.com") self.assertRegex(email['raw'], "Content-Type: text/plain") self.assertRegex(email['subject'], "Subject: ALARM LOW .test Alarm.") self.assertRegex(email['body'], "Alarm .test Alarm.") self.assertRegex(email['body'], "foo1") self.assertRegex(email['body'], "foo2") self.assertRegex(email['body'], "bar1") self.assertRegex(email['body'], "bar2") return_value = self.trap.pop(0) self.assertTrue(return_value) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_smtp_sendmail_failed_connection_twice(self, mock_smtp): """Email that fails on smtp_connect twice """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append # mock_smtp.SMTP.return_value = mock_smtp mock_smtp.SMTP.side_effect = [mock_smtp, smtplib.SMTPServerDisconnected, socket.error] mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, smtplib.SMTPServerDisconnected] # There has to be a better way to preserve exception definitions when # we're mocking access to a library mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) email.config() alarm_dict = alarm(metrics) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) self.trap.append(email.send_notification(notification)) self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap) self.assertIn("Unable to connect to email server.", self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_smtp_sendmail_smtp_None(self, mock_smtp): """Email that fails on smtp_connect twice """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append mock_smtp.SMTP.return_value = None mock_smtp.SMTP.side_effect = [socket.error, socket.error, socket.error] mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, smtplib.SMTPServerDisconnected] # There has to be a better way to preserve exception definitions when # we're mocking access to a library mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) email.config() del self.trap[:] alarm_dict = alarm(metrics) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) email_result = email.send_notification(notification) self.assertFalse(email_result) self.assertIn("Connecting to Email Server my.smtp.server", self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_smtp_sendmail_failed_connection_once_then_email(self, mock_smtp): """Email that fails on smtp_connect once then email """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append mock_smtp.SMTP.return_value = mock_smtp mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, smtplib.SMTPException] # There has to be a better way to preserve exception definitions when # we're mocking access to a library mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) email.config() alarm_dict = alarm(metrics) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) self.trap.append(email.send_notification(notification)) self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap) self.assertIn("Error sending Email Notification", self.trap) self.assertNotIn("Unable to connect to email server.", self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_smtp_sendmail_failed_connection_once(self, mock_smtp): """Email that fails on smtp_connect once """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append mock_smtp.SMTP.return_value = mock_smtp mock_smtp.sendmail.side_effect = [smtplib.SMTPServerDisconnected, None] # There has to be a better way to preserve exception definitions when # we're mocking access to a library mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) email.config() alarm_dict = alarm(metrics) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) self.trap.append(email.send_notification(notification)) self.assertIn("SMTP server disconnected. Will reconnect and retry message.", self.trap) self.assertIn("Sent email to %s, notification %s" % (notification.address, notification.to_json()), self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_smtp_sendmail_failed_exception(self, mock_smtp): """Email that fails on exception """ metrics = [] metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append mock_smtp.SMTP.return_value = mock_smtp mock_smtp.sendmail.side_effect = smtplib.SMTPException # There has to be a better way to preserve exception definitions when # we're mocking access to a library mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) email.config() alarm_dict = alarm(metrics) notification = Notification(0, 'email', 'email notification', 'me@here.com', 0, 0, alarm_dict) self.trap.append(email.send_notification(notification)) self.assertNotIn("SMTP server disconnected. Will reconnect and retry message.", self.trap) self.assertIn("Error sending Email Notification", self.trap) @mock.patch('monasca_notification.plugins.email_notifier.smtplib') def test_get_link_url(self, mock_smtp): # Given one metric with name and dimensions metrics = [] metric = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric) mock_log = mock.MagicMock() mock_log.warn = self.trap.append mock_log.error = self.trap.append mock_log.debug = self.trap.append mock_log.info = self.trap.append mock_log.exception = self.trap.append mock_smtp.SMTP.return_value = mock_smtp mock_smtp.sendmail.side_effect = smtplib.SMTPException mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected mock_smtp.SMTPException = smtplib.SMTPException email = email_notifier.EmailNotifier(mock_log) # Create alarm timestamp and timestamp for 'from' and 'to' dates in milliseconds. alarm_date = datetime.datetime(2017, 6, 7, 18, 0) alarm_ms, expected_from_ms, expected_to_ms = self.create_time_data(alarm_date) # When retrieving the link to Grafana for the first metric and given timestamp result_url = email._get_link_url(metrics[0], alarm_ms) self.assertIsNotNone(result_url) # Then the following link to Grafana (including the metric info and timestamp) is expected. expected_url = "http://127.0.0.1:3000/dashboard/script/drilldown.js" \ "?metric=cpu.percent&hostname=foo1&service=bar1" \ "&from=%s&to=%s" % (expected_from_ms, expected_to_ms) self._assert_equal_urls(expected_url, result_url) def create_time_data(self, alarm_date): epoch = datetime.datetime.utcfromtimestamp(0) alarm_ms = int(round((alarm_date - epoch).total_seconds() * 1000)) # From and to dates are 10 minutes before and after the alarm occurred. from_date = alarm_date - datetime.timedelta(minutes=10) to_date = alarm_date + datetime.timedelta(minutes=10) expected_from_ms = int(round((from_date - epoch).total_seconds() * 1000)) expected_to_ms = int(round((to_date - epoch).total_seconds() * 1000)) return alarm_ms, expected_from_ms, expected_to_ms def _assert_equal_urls(self, expected_url, result_url): expected_parsed = urlparse(expected_url) result_parsed = urlparse(result_url) self.assertEqual(expected_parsed.netloc, result_parsed.netloc) self.assertEqual(expected_parsed.path, result_parsed.path) expected_parsed_query = parse.parse_qs(expected_parsed.query) result_parsed_query = parse.parse_qs(result_parsed.query) self.assertEqual(len(expected_parsed_query), len(result_parsed_query)) for key in result_parsed_query.keys(): self.assertEqual(expected_parsed_query[key], result_parsed_query[key])