Event-tracking webhooks

Closes #3
This commit is contained in:
medmunds
2016-04-20 17:13:55 -07:00
parent 36461e57b9
commit d3f914be12
31 changed files with 2451 additions and 428 deletions

View File

@@ -11,8 +11,8 @@ from .test_postmark_backend import *
from .test_postmark_integration import *
from .test_sendgrid_backend import *
from .test_sendgrid_webhooks import *
from .test_sendgrid_integration import *
# Djrill leftovers:
from .test_mandrill_djrill_features import *
from .test_mandrill_webhook import *

View File

@@ -0,0 +1,250 @@
import json
from datetime import datetime
import hashlib
import hmac
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailgun import MailgunTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_API_KEY = 'TEST_API_KEY'
def mailgun_sign(data, api_key=TEST_API_KEY):
"""Add a Mailgun webhook signature to data dict"""
# Modifies the dict in place
data.setdefault('timestamp', '1234567890')
data.setdefault('token', '1234567890abcdef1234567890abcdef')
data['signature'] = hmac.new(key=api_key.encode('ascii'),
msg='{timestamp}{token}'.format(**data).encode('ascii'),
digestmod=hashlib.sha256).hexdigest()
return data
def querydict_to_postdict(qd):
"""Converts a Django QueryDict to a TestClient.post(data)-style dict
Single-value fields appear as normal
Multi-value fields appear as a list (differs from QueryDict.dict)
"""
return {
key: values if len(values) > 1 else values[0]
for key, values in qd.lists()
}
class MailgunWebhookSettingsTestCase(WebhookTestCase):
def test_requires_api_key(self):
webhook = reverse('mailgun_tracking_webhook')
with self.assertRaises(ImproperlyConfigured):
self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
webhook = reverse('mailgun_tracking_webhook')
return self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
# Additional tests are in WebhookBasicAuthTestsMixin
def test_verifies_correct_signature(self):
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data={'event': 'delivered'})
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
webhook = reverse('mailgun_tracking_webhook')
data = mailgun_sign({'event': 'delivered'}, api_key="wrong API key")
response = self.client.post(webhook, data=data)
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
class MailgunDeliveryTestCase(WebhookTestCase):
def test_delivered_event(self):
raw_event = mailgun_sign({
'domain': 'example.com',
'message-headers': json.dumps([
["Sender", "from=example.com"],
["Date", "Thu, 21 Apr 2016 17:55:29 +0000"],
["X-Mailgun-Sid", "WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0="],
["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 17:55:29 +0000"],
["Message-Id", "<20160421175529.19495.89030.B3AE3728@example.com>"],
["To", "recipient@example.com"],
["From", "from@example.com"],
["Subject", "Webhook testing"],
["Mime-Version", "1.0"],
["Content-Type", ["multipart/alternative", {"boundary": "74fb561763da440d8e6a034054974251"}]]
]),
'X-Mailgun-Sid': 'WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0=',
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
'timestamp': '1461261330',
'Message-Id': '<20160421175529.19495.89030.B3AE3728@example.com>',
'recipient': 'recipient@example.com',
'event': 'delivered',
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "delivered")
self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=utc))
self.assertEqual(event.message_id, "<20160421175529.19495.89030.B3AE3728@example.com>")
self.assertEqual(event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
def test_dropped_bounce(self):
raw_event = mailgun_sign({
'code': '605',
'domain': 'example.com',
'description': 'Not delivering to previously bounced address',
'attachment-count': '1',
'Message-Id': '<20160421180324.70521.79375.96884DDB@example.com>',
'reason': 'hardfail',
'event': 'dropped',
'message-headers': json.dumps([
["X-Mailgun-Sid", "WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0="],
["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 18:03:24 +0000"],
["Message-Id", "<20160421180324.70521.79375.96884DDB@example.com>"],
["To", "bounce@example.com"],
["From", "from@example.com"],
["Subject", "Webhook testing"],
["Mime-Version", "1.0"],
["Content-Type", ["multipart/alternative", {"boundary": "a5b51388a4e3455d8feb8510bb8c9fa2"}]]
]),
'recipient': 'bounce@example.com',
'timestamp': '1461261330',
'X-Mailgun-Sid': 'WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0=',
'token': 'a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc',
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=utc))
self.assertEqual(event.message_id, "<20160421180324.70521.79375.96884DDB@example.com>")
self.assertEqual(event.event_id, "a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description, 'Not delivering to previously bounced address')
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
def test_dropped_spam(self):
raw_event = mailgun_sign({
'code': '607',
'description': 'Not delivering to a user who marked your messages as spam',
'reason': 'hardfail',
'event': 'dropped',
'recipient': 'complaint@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.reject_reason, "spam")
self.assertEqual(event.description, 'Not delivering to a user who marked your messages as spam')
def test_dropped_timed_out(self):
raw_event = mailgun_sign({
'code': '499',
'description': 'Unable to connect to MX servers: [example.com]',
'reason': 'old',
'event': 'dropped',
'recipient': 'complaint@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.reject_reason, "timed_out")
self.assertEqual(event.description, 'Unable to connect to MX servers: [example.com]')
def test_invalid_mailbox(self):
raw_event = mailgun_sign({
'code': '550',
'error': "550 5.1.1 The email account that you tried to reach does not exist. Please try "
" 5.1.1 double-checking the recipient's email address for typos or "
" 5.1.1 unnecessary spaces.",
'event': 'bounced',
'recipient': 'noreply@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.reject_reason, "bounced")
self.assertIn("The email account that you tried to reach does not exist", event.mta_response)
def test_metadata(self):
# Metadata fields are interspersed with other data, but also in message-headers
raw_event = mailgun_sign({
'event': 'delivered',
'message-headers': json.dumps([
["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"],
]),
'custom1': 'value',
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'})
def test_tags(self):
# Most events include multiple 'tag' fields for message's tags
raw_event = mailgun_sign({
'tag': ['tag1', 'tag2'], # Django TestClient encodes list as multiple field values
'event': 'opened',
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.tags, ["tag1", "tag2"])
def test_x_tags(self):
# Delivery events don't include 'tag', but do include 'X-Mailgun-Tag' fields
raw_event = mailgun_sign({
'X-Mailgun-Tag': ['tag1', 'tag2'],
'event': 'delivered',
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.tags, ["tag1", "tag2"])

View File

@@ -1,128 +0,0 @@
import hashlib
import hmac
import json
from base64 import b64encode
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test.utils import override_settings
from anymail.compat import b
from anymail.signals import webhook_event
class DjrillWebhookSecretMixinTests(TestCase):
"""
Test mixin used in optional Mandrill webhook support
"""
def test_missing_secret(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get('/webhook/')
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
def test_incorrect_secret(self):
response = self.client.head('/webhook/?secret=wrong')
self.assertEqual(response.status_code, 403)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
def test_default_secret_name(self):
response = self.client.head('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123', DJRILL_WEBHOOK_SECRET_NAME='verysecret')
def test_custom_secret_name(self):
response = self.client.head('/webhook/?verysecret=abc123')
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123',
DJRILL_WEBHOOK_SIGNATURE_KEY="signature")
class DjrillWebhookSignatureMixinTests(TestCase):
"""
Test mixin used in optional Mandrill webhook signature support
"""
def test_incorrect_settings(self):
with self.assertRaises(ImproperlyConfigured):
self.client.post('/webhook/?secret=abc123')
@override_settings(DJRILL_WEBHOOK_URL="/webhook/?secret=abc123",
DJRILL_WEBHOOK_SIGNATURE_KEY = "anothersignature")
def test_unauthorized(self):
response = self.client.post(settings.DJRILL_WEBHOOK_URL)
self.assertEqual(response.status_code, 403)
@override_settings(DJRILL_WEBHOOK_URL="/webhook/?secret=abc123")
def test_signature(self):
signature = hmac.new(key=b(settings.DJRILL_WEBHOOK_SIGNATURE_KEY),
msg=b(settings.DJRILL_WEBHOOK_URL+"mandrill_events[]"),
digestmod=hashlib.sha1)
hash_string = b64encode(signature.digest())
response = self.client.post('/webhook/?secret=abc123', data={"mandrill_events":"[]"},
**{"HTTP_X_MANDRILL_SIGNATURE": hash_string})
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
class DjrillWebhookViewTests(TestCase):
"""
Test optional Mandrill webhook view
"""
def test_head_request(self):
response = self.client.head('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 200)
def test_post_request_invalid_json(self):
response = self.client.post('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 400)
def test_post_request_valid_json(self):
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([{"event": "send", "msg": {}}])
})
self.assertEqual(response.status_code, 200)
def test_webhook_send_signal(self):
self.signal_received_count = 0
test_event = {"event": "send", "msg": {}}
def my_callback(sender, event_type, data, **kwargs):
self.signal_received_count += 1
self.assertEqual(event_type, 'send')
self.assertEqual(data, test_event)
try:
webhook_event.connect(my_callback, weak=False) # local callback func, so don't use weak ref
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([test_event])
})
finally:
webhook_event.disconnect(my_callback)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.signal_received_count, 1)
def test_webhook_sync_event(self):
# Mandrill sync events use a different format from other events
# https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format
self.signal_received_count = 0
test_event = {"type": "whitelist", "action": "add"}
def my_callback(sender, event_type, data, **kwargs):
self.signal_received_count += 1
self.assertEqual(event_type, 'whitelist_add') # synthesized event_type
self.assertEqual(data, test_event)
try:
webhook_event.connect(my_callback, weak=False) # local callback func, so don't use weak ref
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([test_event])
})
finally:
webhook_event.disconnect(my_callback)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.signal_received_count, 1)

View File

@@ -0,0 +1,195 @@
import json
from datetime import datetime
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urljoin
import hashlib
import hmac
from base64 import b64encode
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mandrill import MandrillTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
def mandrill_args(events=None, urlname='mandrill_tracking_webhook', key=TEST_WEBHOOK_KEY):
"""Returns TestClient.post kwargs for Mandrill webhook call with events
Computes correct signature.
"""
if events is None:
events = []
url = urljoin('http://testserver/', reverse(urlname))
mandrill_events = json.dumps(events)
signed_data = url + 'mandrill_events' + mandrill_events
signature = b64encode(hmac.new(key=key.encode('ascii'),
msg=signed_data.encode('utf-8'),
digestmod=hashlib.sha1).digest())
return {
'path': url,
'data': {'mandrill_events': mandrill_events},
'HTTP_X_MANDRILL_SIGNATURE': signature,
}
class MandrillWebhookSettingsTestCase(WebhookTestCase):
def test_requires_webhook_key(self):
webhook = reverse('mandrill_tracking_webhook')
with self.assertRaises(ImproperlyConfigured):
self.client.post(webhook, data={'mandrill_events': '[]'})
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
kwargs = mandrill_args([{'event': 'send'}])
return self.client.post(**kwargs)
# Additional tests are in WebhookBasicAuthTestsMixin
def test_verifies_correct_signature(self):
kwargs = mandrill_args([{'event': 'send'}])
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
webhook = reverse('mandrill_tracking_webhook')
response = self.client.post(webhook, data={'mandrill_events': '[{"event":"send"}]'})
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
kwargs = mandrill_args([{'event': 'send'}], key="wrong API key")
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
class MandrillTrackingTestCase(WebhookTestCase):
def test_head_request(self):
# Mandrill verifies webhooks at config time with a HEAD request
webhook = reverse('mandrill_tracking_webhook')
response = self.client.head(webhook)
self.assertEqual(response.status_code, 200)
def test_post_request_invalid_json(self):
kwargs = mandrill_args()
kwargs['data'] = {'mandrill_events': "GARBAGE DATA"}
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 400)
def test_send_event(self):
raw_events = [{
"event": "send",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "recipient@example.com",
"sender": "sender@example.com",
"tags": ["tag1", "tag2"],
"metadata": {"custom1": "value1", "custom2": "value2"},
"_id": "abcdef012345789abcdef012345789"
},
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "sent")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "abcdef012345789abcdef012345789")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.tags, ["tag1", "tag2"])
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"})
def test_hard_bounce_event(self):
raw_events = [{
"event": "hard_bounce",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "bounce@example.com",
"sender": "sender@example.com",
"bounce_description": "bad_mailbox",
"bgtools_code": 10,
"diag": "smtp;550 5.1.1 The email account that you tried to reach does not exist.",
"_id": "abcdef012345789abcdef012345789"
},
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "abcdef012345789abcdef012345789")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.mta_response,
"smtp;550 5.1.1 The email account that you tried to reach does not exist.")
def test_click_event(self):
raw_events = [{
"event": "click",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "recipient@example.com",
"sender": "sender@example.com",
"opens": [{"ts": 1461095242}],
"clicks": [{"ts": 1461095246, "url": "http://example.com"}],
"_id": "abcdef012345789abcdef012345789"
},
"user_agent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"url": "http://example.com",
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.click_url, "http://example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")
def test_sync_event(self):
# Mandrill sync events use a different format from other events
# https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format
raw_events = [{
"type": "blacklist",
"action": "add",
"reject": {
"email": "recipient@example.com",
"reason": "manual edit"
}
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertEqual(event.event_type, "unknown")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.description, "manual edit")

View File

@@ -0,0 +1,86 @@
import json
from datetime import datetime
from django.core.urlresolvers import reverse
from django.utils.timezone import get_fixed_timezone
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.postmark import PostmarkTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
def call_webhook(self):
webhook = reverse('postmark_tracking_webhook')
return self.client.post(webhook, content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin
class PostmarkDeliveryTestCase(WebhookTestCase):
def test_bounce_event(self):
raw_event = {
"ID": 901542550,
"Type": "HardBounce",
"TypeCode": 1,
"Name": "Hard bounce",
"MessageID": "2706ee8a-737c-4285-b032-ccd317af53ed",
"Description": "The server was unable to deliver your message (ex: unknown user, mailbox not found).",
"Details": "smtp;550 5.1.1 The email account that you tried to reach does not exist.",
"Email": "bounce@example.com",
"BouncedAt": "2016-04-27T16:28:50.3963933-04:00",
"DumpAvailable": True,
"Inactive": True,
"CanActivate": True,
"Subject": "Postmark event test",
"Content": "..."
}
webhook = reverse('postmark_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_event))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView,
event=ANY, esp_name='Postmark')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_event)
self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 28, 50, microsecond=396393,
tzinfo=get_fixed_timezone(-4*60)))
self.assertEqual(event.message_id, "2706ee8a-737c-4285-b032-ccd317af53ed")
self.assertEqual(event.event_id, "901542550")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description,
"The server was unable to deliver your message (ex: unknown user, mailbox not found).")
self.assertEqual(event.mta_response,
"smtp;550 5.1.1 The email account that you tried to reach does not exist.")
def test_open_event(self):
raw_event = {
"FirstOpen": True,
"Client": {"Name": "Gmail", "Company": "Google", "Family": "Gmail"},
"OS": {"Name": "unknown", "Company": "unknown", "Family": "unknown"},
"Platform": "Unknown",
"UserAgent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"ReadSeconds": 0,
"Geo": {},
"MessageID": "f4830d10-9c35-4f0c-bca3-3d9b459821f8",
"ReceivedAt": "2016-04-27T16:21:41.2493688-04:00",
"Recipient": "recipient@example.com"
}
webhook = reverse('postmark_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_event))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView,
event=ANY, esp_name='Postmark')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "opened")
self.assertEqual(event.esp_event, raw_event)
self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 21, 41, microsecond=249368,
tzinfo=get_fixed_timezone(-4*60)))
self.assertEqual(event.message_id, "f4830d10-9c35-4f0c-bca3-3d9b459821f8")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")

View File

@@ -0,0 +1,235 @@
import json
from datetime import datetime
from django.core.urlresolvers import reverse
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sendgrid import SendGridTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
def call_webhook(self):
webhook = reverse('sendgrid_tracking_webhook')
return self.client.post(webhook, content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin
class SendGridDeliveryTestCase(WebhookTestCase):
def test_processed_event(self):
raw_events = [{
"email": "recipient@example.com",
"timestamp": 1461095246,
"smtp-id": "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>",
"sg_event_id": "ZyjAM5rnQmuI1KFInHQ3Nw",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"event": "processed",
"category": ["tag1", "tag2"],
"custom1": "value1",
"custom2": "value2",
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "queued")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>")
self.assertEqual(event.event_id, "ZyjAM5rnQmuI1KFInHQ3Nw")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.tags, ["tag1", "tag2"])
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"})
def test_delivered_event(self):
raw_events = [{
"ip": "167.89.17.173",
"response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ",
"sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"tls": 1,
"event": "delivered",
"email": "recipient@example.com",
"timestamp": 1461095250,
"smtp-id": "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "delivered")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 30, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>")
self.assertEqual(event.event_id, "nOSv8m0eTQ-vxvwNwt3fZQ")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.mta_response, "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ")
self.assertEqual(event.tags, None)
self.assertEqual(event.metadata, None)
def test_dropped_invalid_event(self):
raw_events = [{
"email": "invalid@invalid",
"smtp-id": "<YZkwwo_vQUidhSh7sCzkvQ@example.com>",
"timestamp": 1461095250,
"sg_event_id": "3NPOePGOTkeM_U3fgWApfg",
"sg_message_id": "filter0093p1las1.9128.5717FB8127.0",
"reason": "Invalid",
"event": "dropped"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<YZkwwo_vQUidhSh7sCzkvQ@example.com>")
self.assertEqual(event.event_id, "3NPOePGOTkeM_U3fgWApfg")
self.assertEqual(event.recipient, "invalid@invalid")
self.assertEqual(event.reject_reason, "invalid")
self.assertEqual(event.mta_response, None)
def test_dropped_unsubscribed_event(self):
raw_events = [{
"email": "unsubscribe@example.com",
"smtp-id": "<Kwx3gAIKQOG7Nd5XEO7guQ@example.com>",
"timestamp": 1461095250,
"sg_event_id": "oxy9OLwMTAy5EsuZn1qhIg",
"sg_message_id": "filter0199p1las1.4745.5717FB6F5.0",
"reason": "Unsubscribed Address",
"event": "dropped"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<Kwx3gAIKQOG7Nd5XEO7guQ@example.com>")
self.assertEqual(event.event_id, "oxy9OLwMTAy5EsuZn1qhIg")
self.assertEqual(event.recipient, "unsubscribe@example.com")
self.assertEqual(event.reject_reason, "unsubscribed")
self.assertEqual(event.mta_response, None)
def test_bounce_event(self):
raw_events = [{
"ip": "167.89.17.173",
"status": "5.1.1",
"sg_event_id": "lC0Rc-FuQmKbnxCWxX1jRQ",
"reason": "550 5.1.1 The email account that you tried to reach does not exist.",
"sg_message_id": "Lli-03HcQ5-JLybO9fXsJg.filter0077p1las1.21536.5717FC482.0",
"tls": 1,
"event": "bounce",
"email": "noreply@example.com",
"timestamp": 1461095250,
"smtp-id": "<Lli-03HcQ5-JLybO9fXsJg@example.com>",
"type": "bounce"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<Lli-03HcQ5-JLybO9fXsJg@example.com>")
self.assertEqual(event.event_id, "lC0Rc-FuQmKbnxCWxX1jRQ")
self.assertEqual(event.recipient, "noreply@example.com")
self.assertEqual(event.mta_response, "550 5.1.1 The email account that you tried to reach does not exist.")
def test_deferred_event(self):
raw_events = [{
"response": "Email was deferred due to the following reason(s): [IPs were throttled by recipient server]",
"sg_event_id": "b_syL5UiTvWC_Ky5L6Bs5Q",
"sg_message_id": "u9Gvi3mzT6iC2poAb58_qQ.filter0465p1mdw1.8054.5718271B40.0",
"event": "deferred",
"email": "recipient@example.com",
"attempt": "1",
"timestamp": 1461200990,
"smtp-id": "<20160421010427.2847.6797@example.com>",
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "deferred")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "b_syL5UiTvWC_Ky5L6Bs5Q")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.mta_response,
"Email was deferred due to the following reason(s): [IPs were throttled by recipient server]")
def test_open_event(self):
raw_events = [{
"email": "recipient@example.com",
"timestamp": 1461095250,
"ip": "66.102.6.229",
"sg_event_id": "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"smtp-id": "<20160421010427.2847.6797@example.com>",
"useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"event": "open"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "opened")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")
def test_click_event(self):
raw_events = [{
"ip": "24.130.34.103",
"sg_event_id": "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi",
"sg_message_id": "_fjPjuJfRW-IPs5SuvYotg.filter0590p1mdw1.2098.57168CFC4B.0",
"useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36",
"smtp-id": "<20160421010427.2847.6797@example.com>",
"event": "click",
"url_offset": {"index": 0, "type": "html"},
"email": "recipient@example.com",
"timestamp": 1461095250,
"url": "http://www.example.com"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36")
self.assertEqual(event.click_url, "http://www.example.com")

View File

@@ -1,9 +1,13 @@
# Anymail test utils
import os
import sys
import unittest
from base64 import b64decode
import os
import re
import six
import warnings
from base64 import b64decode
from contextlib import contextmanager
def decode_att(att):
@@ -28,10 +32,34 @@ def sample_image_content(filename=SAMPLE_IMAGE_FILENAME):
return f.read()
# noinspection PyUnresolvedReferences
class AnymailTestMixin:
"""Helpful additional methods for Anymail tests"""
pass
def assertWarns(self, expected_warning, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarns(expected_warning, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, msg=msg)
def assertWarnsRegex(self, expected_warning, expected_regex, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarnsRegex(expected_warning, expected_regex, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, expected_regex=expected_regex, msg=msg)
@contextmanager
def assertDoesNotWarn(self):
try:
warnings.simplefilter("error")
yield
finally:
warnings.resetwarnings()
# Plus these methods added below:
# assertCountEqual
# assertRaisesRegex
@@ -45,3 +73,64 @@ for method in ('assertCountEqual', 'assertRaisesRegex', 'assertRegex'):
getattr(unittest.TestCase, method)
except AttributeError:
setattr(AnymailTestMixin, method, getattr(six, method))
# Backported from python 3.5
class _AssertWarnsContext(object):
"""A context manager used to implement TestCase.assertWarns* methods."""
def __init__(self, expected, test_case, expected_regex=None, msg=None):
self.test_case = test_case
self.expected = expected
self.test_case = test_case
if expected_regex is not None:
expected_regex = re.compile(expected_regex)
self.expected_regex = expected_regex
self.msg = msg
def _raiseFailure(self, standardMsg):
# msg = self.test_case._formatMessage(self.msg, standardMsg)
msg = self.msg or standardMsg
raise self.test_case.failureException(msg)
def __enter__(self):
# The __warningregistry__'s need to be in a pristine state for tests
# to work properly.
for v in sys.modules.values():
if getattr(v, '__warningregistry__', None):
v.__warningregistry__ = {}
self.warnings_manager = warnings.catch_warnings(record=True)
self.warnings = self.warnings_manager.__enter__()
warnings.simplefilter("always", self.expected)
return self
def __exit__(self, exc_type, exc_value, tb):
self.warnings_manager.__exit__(exc_type, exc_value, tb)
if exc_type is not None:
# let unexpected exceptions pass through
return
try:
exc_name = self.expected.__name__
except AttributeError:
exc_name = str(self.expected)
first_matching = None
for m in self.warnings:
w = m.message
if not isinstance(w, self.expected):
continue
if first_matching is None:
first_matching = w
if (self.expected_regex is not None and
not self.expected_regex.search(str(w))):
continue
# store warning for later retrieval
self.warning = w
self.filename = m.filename
self.lineno = m.lineno
return
# Now we simply try to choose a helpful failure message
if first_matching is not None:
self._raiseFailure('"{}" does not match "{}"'.format(
self.expected_regex.pattern, str(first_matching)))
self._raiseFailure("{} not triggered".format(exc_name))

116
tests/webhook_cases.py Normal file
View File

@@ -0,0 +1,116 @@
import base64
from django.test import override_settings, SimpleTestCase
from mock import create_autospec, ANY
from anymail.exceptions import AnymailInsecureWebhookWarning
from anymail.signals import tracking, inbound
from .utils import AnymailTestMixin
def event_handler(sender, event, esp_name, **kwargs):
"""Prototypical webhook signal handler"""
pass
@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': 'username:password'})
class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
"""Base for testing webhooks
- connects webhook signal handlers
- sets up basic auth by default (since most ESP webhooks warn if it's not enabled)
"""
def setUp(self):
super(WebhookTestCase, self).setUp()
# Use correct basic auth by default (individual tests can override):
self.set_basic_auth()
# Install mocked signal handlers
self.tracking_handler = create_autospec(event_handler)
tracking.connect(self.tracking_handler)
self.addCleanup(tracking.disconnect, self.tracking_handler)
self.inbound_handler = create_autospec(event_handler)
inbound.connect(self.inbound_handler)
self.addCleanup(inbound.disconnect, self.inbound_handler)
def set_basic_auth(self, username='username', password='password'):
"""Set basic auth for all subsequent test client requests"""
credentials = base64.b64encode("{}:{}".format(username, password).encode('utf-8')).decode('utf-8')
self.client.defaults['HTTP_AUTHORIZATION'] = "Basic {}".format(credentials)
def assert_handler_called_once_with(self, mockfn, *expected_args, **expected_kwargs):
"""Verifies mockfn was called with expected_args and at least expected_kwargs.
Ignores *additional* actual kwargs (which might be added by Django signal dispatch).
(This differs from mock.assert_called_once_with.)
Returns the actual kwargs.
"""
self.assertEqual(mockfn.call_count, 1)
actual_args, actual_kwargs = mockfn.call_args
self.assertEqual(actual_args, expected_args)
for key, expected_value in expected_kwargs.items():
if expected_value is ANY:
self.assertIn(key, actual_kwargs)
else:
self.assertEqual(actual_kwargs[key], expected_value)
return actual_kwargs
# noinspection PyUnresolvedReferences
class WebhookBasicAuthTestsMixin(object):
"""Common test cases for webhook basic authentication.
Instantiate for each ESP's webhooks by:
- mixing into WebhookTestCase
- defining call_webhook to invoke the ESP's webhook
"""
should_warn_if_no_auth = True # subclass set False if other webhook verification used
def call_webhook(self):
# Concrete test cases should call a webhook via self.client.post,
# and return the response
raise NotImplementedError()
@override_settings(ANYMAIL={}) # Clear the WEBHOOK_AUTH settings from superclass
def test_warns_if_no_auth(self):
if self.should_warn_if_no_auth:
with self.assertWarns(AnymailInsecureWebhookWarning):
response = self.call_webhook()
else:
with self.assertDoesNotWarn():
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_basic_auth(self):
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_bad_auth(self):
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
def test_verifies_missing_auth(self):
del self.client.defaults['HTTP_AUTHORIZATION']
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': ['cred1:pass1', 'cred2:pass2']})
def test_supports_credential_rotation(self):
"""You can supply a list of basic auth credentials, and any is allowed"""
self.set_basic_auth('cred1', 'pass1')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('cred2', 'pass2')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)