Fixing up notification testing (#575)

This commit is contained in:
kevgliss 2016-12-08 11:33:40 -08:00 committed by GitHub
parent be1415fbd4
commit a4b32b0d31
16 changed files with 173 additions and 99 deletions

View File

@ -207,7 +207,8 @@ class CertificateNotificationOutputSchema(LemurOutputSchema):
name = fields.String() name = fields.String()
owner = fields.Email() owner = fields.Email()
user = fields.Nested(UserNestedOutputSchema) user = fields.Nested(UserNestedOutputSchema)
replaces = fields.Nested(CertificateNestedOutputSchema, many=True) validity_end = ArrowDateTime(attribute='not_after')
replaced = fields.Nested(CertificateNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])

View File

@ -24,6 +24,7 @@ def send_expiration_notifications():
This function will check for upcoming certificate expiration, This function will check for upcoming certificate expiration,
and send out notification emails at given intervals. and send out notification emails at given intervals.
""" """
sent = 0
for plugin in plugins.all(plugin_type='notification'): for plugin in plugins.all(plugin_type='notification'):
notifications = database.db.session.query(Notification)\ notifications = database.db.session.query(Notification)\
.filter(Notification.plugin_name == plugin.slug)\ .filter(Notification.plugin_name == plugin.slug)\
@ -36,16 +37,18 @@ def send_expiration_notifications():
data = certificate_notification_output_schema.dump(certificate).data data = certificate_notification_output_schema.dump(certificate).data
messages.append((data, n.options)) messages.append((data, n.options))
for data, targets, options in messages: for data, options in messages:
try: try:
plugin.send('expiration', data, targets, options) plugin.send('expiration', data, [data['owner']], options)
metrics.send('expiration_notification_sent', 'counter', 1) metrics.send('expiration_notification_sent', 'counter', 1)
sent += 1
except Exception as e: except Exception as e:
metrics.send('expiration_notification_failure', 'counter', 1) metrics.send('expiration_notification_failure', 'counter', 1)
current_app.logger.exception(e) current_app.logger.exception(e)
return sent
def send_rotation_notification(certificate): def send_rotation_notification(certificate, notification_plugin=None):
""" """
Sends a report to certificate owners when their certificate as been Sends a report to certificate owners when their certificate as been
rotated. rotated.
@ -53,12 +56,13 @@ def send_rotation_notification(certificate):
:param certificate: :param certificate:
:return: :return:
""" """
plugin = plugins.get(current_app.config.get('LEMUR_DEFAULT_NOTIFICATION_PLUGIN')) if not notification_plugin:
notification_plugin = plugins.get(current_app.config.get('LEMUR_DEFAULT_NOTIFICATION_PLUGIN'))
data = certificate_notification_output_schema.dump(certificate).data data = certificate_notification_output_schema.dump(certificate).data
try: try:
plugin.send('rotation', data, [data.owner]) notification_plugin.send('rotation', data, [data['owner']])
metrics.send('rotation_notification_sent', 'counter', 1) metrics.send('rotation_notification_sent', 'counter', 1)
except Exception as e: except Exception as e:
metrics.send('rotation_notification_failure', 'counter', 1) metrics.send('rotation_notification_failure', 'counter', 1)

View File

@ -16,7 +16,7 @@ class NotificationPlugin(Plugin):
""" """
type = 'notification' type = 'notification'
def send(self): def send(self, notification_type, message, targets, options, **kwargs):
raise NotImplementedError raise NotImplementedError
@ -48,5 +48,5 @@ class ExpirationNotificationPlugin(NotificationPlugin):
def options(self): def options(self):
return list(self.default_options) + self.additional_options return list(self.default_options) + self.additional_options
def send(self): def send(self, notification_type, message, targets, options, **kwargs):
raise NotImplementedError raise NotImplementedError

View File

@ -85,10 +85,10 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
raise InvalidConfiguration('Email sender type {0} is not recognized.') raise InvalidConfiguration('Email sender type {0} is not recognized.')
@staticmethod @staticmethod
def send(template_name, message, targets, **kwargs): def send(notification_type, message, targets, options, **kwargs):
subject = 'Lemur: Expiration Notification' subject = 'Lemur: {0} Notification'.format(notification_type.capitalize())
body = render_html(template_name, message) body = render_html(notification_type, message)
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", 'ses').lower() s_type = current_app.config.get("LEMUR_EMAIL_SENDER", 'ses').lower()

View File

@ -79,18 +79,18 @@
<table border="0" cellspacing="0" cellpadding="0" <table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px"> style="margin-top:48px;margin-bottom:48px">
<tbody> <tbody>
{% for message in messages %} {% for certificate in certificates %}
<tr valign="middle"> <tr valign="middle">
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
{{ message.endpoints | length }} Endpoints {{ certificate.endpoints | length }} Endpoints
<br>{{ message.owner }} <br>{{ certificate.owner }}
<br>{{ message.not_after | time }} <br>{{ certificate.validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ message.name }}" target="_blank">Details</a> <a href="https://{{ hostname }}/#/certificates/{{ certificate.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>

View File

@ -43,7 +43,7 @@
<tr> <tr>
<td width="32px"></td> <td width="32px"></td>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:24px;color:#ffffff;line-height:1.25"> <td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:24px;color:#ffffff;line-height:1.25">
Your certificate(s) have been rotated! Your certificate has been rotated!
</td> </td>
<td width="32px"></td> <td width="32px"></td>
</tr> </tr>
@ -74,44 +74,31 @@
<br>This is a Lemur certificate rotation notice. Your certificate has been re-issued and re-provisioned. <br>This is a Lemur certificate rotation notice. Your certificate has been re-issued and re-provisioned.
</td> </td>
</tr> </tr>
<tr>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020;line-height:1.5">
Impacted Certificate:
</td>
</tr>
<tr> <tr>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#202020;line-height:1.5"> <td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#202020;line-height:1.5">
<table border="0" cellspacing="0" cellpadding="0" <table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px"> style="margin-top:48px;margin-bottom:48px">
<tbody> <tbody>
{% for message in messages %}
<tr valign="middle"> <tr valign="middle">
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
{{ message.endpoints | length }} Endpoints <br>{{ certificate.owner }}
<br>{{ message.owner }} <br>{{ certificate.validityEnd | time }}
<br>{{ message.not_after | time }} <a href="https://{{ hostname }}/#/certificates/{{ certificate.name }}" target="_blank">Details</a>
<a href="https://{{ hostname }}/#/certificates/{{ message.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>
{% if not loop.last %}
<tr valign="middle">
<td width="32px" height="24px"></td>
</tr>
{% endif %}
{% endfor %}
</tbody> </tbody>
</table> </table>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020;line-height:1.5"> <td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:15px;color:#202020;line-height:1.5">
Impacted Endpoints: Replaced by:
</td> </td>
</tr> </tr>
<tr> <tr>
@ -119,18 +106,43 @@
<table border="0" cellspacing="0" cellpadding="0" <table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px"> style="margin-top:48px;margin-bottom:48px">
<tbody> <tbody>
{% for message in messages %} <tr valign="middle">
<td width="32px"></td>
<td width="16px"></td>
<td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.replaced.name }}</span>
<br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
<br>{{ certificate.replaced[0].owner }}
<br>{{ certificate.replaced[0].validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ certificate.replaced[0].name }}" target="_blank">Details</a>
</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:15px;color:#202020;line-height:1.5">
Endpoints Rotated:
</td>
</tr>
<tr>
<td style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#202020;line-height:1.5">
<table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px">
<tbody>
{% for endpoint in certificate.endpoints %}
<tr valign="middle"> <tr valign="middle">
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ endpoint.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
{{ message.endpoints | length }} Endpoints <br>{{ endpoint.dnsname }} | {{ endpoint.port }}
<br>{{ message.owner }} <a href="https://{{ hostname }}/#/endpoints/{{ endpoint.name }}" target="_blank">Details</a>
<br>{{ message.not_after | time }}
<a href="https://{{ hostname }}/#/certificates/{{ message.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>

View File

@ -0,0 +1 @@
from lemur.tests.conftest import * # noqa

View File

@ -1,12 +1,34 @@
import os
from lemur.plugins.lemur_email.templates.config import env from lemur.plugins.lemur_email.templates.config import env
from lemur.tests.factories import CertificateFactory
def test_render(): dir_path = os.path.dirname(os.path.realpath(__file__))
messages = [{
'name': 'a-really-really-long-certificate-name',
'owner': 'bob@example.com', def test_render(certificate, endpoint):
'not_after': '2015-12-14 23:59:59' from lemur.certificates.schemas import certificate_notification_output_schema
}] * 10
new_cert = CertificateFactory()
new_cert.replaces.append(certificate)
certificates = [certificate_notification_output_schema.dump(certificate).data]
template = env.get_template('{}.html'.format('expiration')) template = env.get_template('{}.html'.format('expiration'))
body = template.render(dict(messages=messages, hostname='lemur.test.example.com'))
with open(os.path.join(dir_path, 'expiration-rendered.html'), 'w') as f:
body = template.render(dict(certificates=certificates, hostname='lemur.test.example.com'))
f.write(body)
template = env.get_template('{}.html'.format('rotation'))
certificate.endpoints.append(endpoint)
with open(os.path.join(dir_path, 'rotation-rendered.html'), 'w') as f:
body = template.render(
dict(
certificate=certificate_notification_output_schema.dump(certificate).data,
hostname='lemur.test.example.com'
)
)
f.write(body)

View File

@ -23,28 +23,28 @@ def create_certificate_url(name):
) )
def create_expiration_attachments(messages): def create_expiration_attachments(certificates):
attachments = [] attachments = []
for message in messages: for certificate in certificates:
attachments.append({ attachments.append({
'title': message['name'], 'title': certificate['name'],
'title_link': create_certificate_url(message['name']), 'title_link': create_certificate_url(certificate['name']),
'color': 'danger', 'color': 'danger',
'fallback': '', 'fallback': '',
'fields': [ 'fields': [
{ {
'title': 'Owner', 'title': 'Owner',
'value': message['owner'], 'value': certificate['owner'],
'short': True 'short': True
}, },
{ {
'title': 'Expires', 'title': 'Expires',
'value': arrow.get(message['not_after']).format('dddd, MMMM D, YYYY'), 'value': arrow.get(certificate['validityEnd']).format('dddd, MMMM D, YYYY'),
'short': True 'short': True
}, },
{ {
'title': 'Endpoints Detected', 'title': 'Endpoints Detected',
'value': len(message['endpoints']), 'value': len(certificate['endpoints']),
'short': True 'short': True
} }
], ],
@ -54,6 +54,37 @@ def create_expiration_attachments(messages):
return attachments return attachments
def create_rotation_attachments(certificate):
return {
'title': certificate['name'],
'title_link': create_certificate_url(certificate['name']),
'fields': [
{
{
'title': 'Owner',
'value': certificate['owner'],
'short': True
},
{
'title': 'Expires',
'value': arrow.get(certificate['validityEnd']).format('dddd, MMMM D, YYYY'),
'short': True
},
{
'title': 'Replaced By',
'value': len(certificate['replaced'][0]['name']),
'short': True
},
{
'title': 'Endpoints Rotated',
'value': len(certificate['endpoints']),
'short': True
}
}
]
}
class SlackNotificationPlugin(ExpirationNotificationPlugin): class SlackNotificationPlugin(ExpirationNotificationPlugin):
title = 'Slack' title = 'Slack'
slug = 'slack-notification' slug = 'slack-notification'
@ -85,25 +116,31 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
}, },
] ]
def send(self, event_type, message, targets, options, **kwargs): def send(self, notification_type, message, targets, options, **kwargs):
""" """
A typical check can be performed using the notify command: A typical check can be performed using the notify command:
`lemur notify` `lemur notify`
""" """
if event_type == 'expiration': attachments = None
if notification_type == 'expiration':
attachments = create_expiration_attachments(message) attachments = create_expiration_attachments(message)
elif notification_type == 'rotation':
attachments = create_rotation_attachments(message)
if not attachments: if not attachments:
raise Exception('Unable to create message attachments') raise Exception('Unable to create message attachments')
body = { body = {
'text': 'Lemur Expiration Notification', 'text': 'Lemur {0} Notification'.format(notification_type.capitalize()),
'attachments': attachments, 'attachments': attachments,
'channel': self.get_option('recipients', options), 'channel': self.get_option('recipients', options),
'username': self.get_option('username', options) 'username': self.get_option('username', options)
} }
r = requests.post(self.get_option('webhook', options), json.dumps(body)) r = requests.post(self.get_option('webhook', options), json.dumps(body))
if r.status_code not in [200]: if r.status_code not in [200]:
raise Exception('Failed to send message') raise Exception('Failed to send message')
current_app.logger.error("Slack response: {0} Message Body: {1}".format(r.status_code, body)) current_app.logger.error("Slack response: {0} Message Body: {1}".format(r.status_code, body))

View File

@ -2,8 +2,8 @@
def test_formatting(certificate): def test_formatting(certificate):
from lemur.plugins.lemur_slack.plugin import create_expiration_attachments from lemur.plugins.lemur_slack.plugin import create_expiration_attachments
from lemur.notifications.service import _get_message_data from lemur.certificates.schemas import certificate_notification_output_schema
data = [_get_message_data(certificate)] data = [certificate_notification_output_schema.dump(certificate).data]
attachment = { attachment = {
'title': certificate.name, 'title': certificate.name,

View File

@ -1,9 +0,0 @@
import unittest
class LemurTestCase(unittest.TestCase):
pass
class LemurPluginTestCase(LemurTestCase):
pass

View File

@ -1,16 +1,12 @@
import os import os
import pytest import pytest
from flask import current_app
from flask_principal import identity_changed, Identity
from lemur import create_app from lemur import create_app
from lemur.database import db as _db from lemur.database import db as _db
from lemur.auth.service import create_token from lemur.auth.service import create_token
from .factories import AuthorityFactory, NotificationFactory, DestinationFactory, \ from .factories import AuthorityFactory, NotificationFactory, DestinationFactory, \
CertificateFactory, UserFactory, RoleFactory, SourceFactory CertificateFactory, UserFactory, RoleFactory, SourceFactory, EndpointFactory
def pytest_runtest_setup(item): def pytest_runtest_setup(item):
@ -114,6 +110,14 @@ def certificate(session):
return c return c
@pytest.fixture
def endpoint(session):
s = SourceFactory()
e = EndpointFactory(source=s)
session.commit()
return e
@pytest.fixture @pytest.fixture
def role(session): def role(session):
r = RoleFactory() r = RoleFactory()
@ -171,17 +175,3 @@ def source_plugin():
from .plugins.source_plugin import TestSourcePlugin from .plugins.source_plugin import TestSourcePlugin
register(TestSourcePlugin) register(TestSourcePlugin)
return TestSourcePlugin return TestSourcePlugin
@pytest.yield_fixture(scope="function")
def logged_in_user(session, app):
with app.test_request_context():
identity_changed.send(current_app._get_current_object(), identity=Identity(1))
yield
@pytest.yield_fixture(scope="function")
def logged_in_admin(session, app):
with app.test_request_context():
identity_changed.send(current_app._get_current_object(), identity=Identity(2))
yield

View File

@ -240,6 +240,7 @@ class EndpointFactory(BaseFactory):
type = FuzzyChoice(['elb']) type = FuzzyChoice(['elb'])
active = True active = True
port = FuzzyInteger(0, high=65535) port = FuzzyInteger(0, high=65535)
dnsname = 'endpoint.example.com'
policy = SubFactory(PolicyFactory) policy = SubFactory(PolicyFactory)
certificate = SubFactory(CertificateFactory) certificate = SubFactory(CertificateFactory)
source = SubFactory(SourceFactory) source = SubFactory(SourceFactory)

View File

@ -13,5 +13,5 @@ class TestNotificationPlugin(NotificationPlugin):
super(TestNotificationPlugin, self).__init__(*args, **kwargs) super(TestNotificationPlugin, self).__init__(*args, **kwargs)
@staticmethod @staticmethod
def send(event_type, message, targets, options, **kwargs): def send(notification_type, message, targets, options, **kwargs):
return return

View File

@ -354,7 +354,7 @@ def test_get_account_number(client):
assert get_account_number(arn) == '11111111' assert get_account_number(arn) == '11111111'
def test_mint_certificate(issuer_plugin, authority, logged_in_admin): def test_mint_certificate(issuer_plugin, authority):
from lemur.certificates.service import mint from lemur.certificates.service import mint
cert_body, private_key, chain = mint(authority=authority, csr=CSR_STR) cert_body, private_key, chain = mint(authority=authority, csr=CSR_STR)
assert cert_body == INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR assert cert_body == INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR
@ -372,7 +372,7 @@ def test_create_certificate(issuer_plugin, authority, user):
assert cert.name == 'ACustomName1' assert cert.name == 'ACustomName1'
def test_reissue_certificate(issuer_plugin, authority, certificate, logged_in_admin): def test_reissue_certificate(issuer_plugin, authority, certificate):
from lemur.certificates.service import reissue_certificate from lemur.certificates.service import reissue_certificate
new_cert = reissue_certificate(certificate) new_cert = reissue_certificate(certificate)
assert new_cert assert new_cert

View File

@ -3,6 +3,8 @@ from freezegun import freeze_time
from datetime import timedelta from datetime import timedelta
from moto import mock_ses
def test_needs_notification(app, certificate, notification): def test_needs_notification(app, certificate, notification):
from lemur.notifications.messaging import needs_notification from lemur.notifications.messaging import needs_notification
@ -21,11 +23,24 @@ def test_needs_notification(app, certificate, notification):
assert needs_notification(certificate) assert needs_notification(certificate)
@pytest.skip @mock_ses
def test_send_expiration_notification(): def test_send_expiration_notification(certificate, notification, notification_plugin):
assert False from lemur.notifications.messaging import send_expiration_notifications
notification.options = [{'name': 'interval', 'value': 10}, {'name': 'unit', 'value': 'days'}]
certificate.notifications.append(notification)
delta = certificate.not_after - timedelta(days=10)
with freeze_time(delta.datetime):
sent = send_expiration_notifications()
assert sent == 1
certificate.notify = False
sent = send_expiration_notifications()
assert sent == 0
@pytest.skip @mock_ses
def test_send_rotation_notification(): def test_send_rotation_notification(notification_plugin, certificate):
assert False from lemur.notifications.messaging import send_rotation_notification
send_rotation_notification(certificate, notification_plugin=notification_plugin)