mirror of
https://github.com/GSA/notifications-api.git
synced 2026-05-27 17:38:17 -04:00
Endpoint to allow SES updates to occur
- update notification with delivery state
This commit is contained in:
@@ -71,7 +71,12 @@ def create_app():
|
||||
def init_app(app):
|
||||
@app.before_request
|
||||
def required_authentication():
|
||||
if request.path not in [url_for('status.show_status'), url_for('notifications.process_firetext_response')]:
|
||||
no_auth_req = [
|
||||
url_for('status.show_status'),
|
||||
url_for('notifications.process_ses_response'),
|
||||
url_for('notifications.process_firetext_response')
|
||||
]
|
||||
if request.path not in no_auth_req:
|
||||
from app.authentication import auth
|
||||
error = auth.requires_auth()
|
||||
if error:
|
||||
|
||||
@@ -3,6 +3,21 @@ from flask import current_app
|
||||
from monotonic import monotonic
|
||||
from app.clients.email import (EmailClientException, EmailClient)
|
||||
|
||||
ses_response_status = {
|
||||
'Bounce': {
|
||||
"success": False,
|
||||
"notify_status": 'bounce'
|
||||
},
|
||||
'Delivery': {
|
||||
"success": True,
|
||||
"notify_status": 'delivered'
|
||||
},
|
||||
'Complaint': {
|
||||
"success": False,
|
||||
"notify_status": 'complaint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AwsSesClientException(EmailClientException):
|
||||
pass
|
||||
|
||||
@@ -68,6 +68,28 @@ def dao_update_notification(notification):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def update_notification_status_by_id(notification_id, status):
|
||||
count = db.session.query(Notification).filter_by(
|
||||
id=notification_id
|
||||
).update({
|
||||
Notification.status: status,
|
||||
Notification.updated_at: datetime.utcnow()
|
||||
})
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
def update_notification_status_by_to(to, status):
|
||||
count = db.session.query(Notification).filter_by(
|
||||
to=to
|
||||
).update({
|
||||
Notification.status: status,
|
||||
Notification.updated_at: datetime.utcnow()
|
||||
})
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
def get_notification_for_job(service_id, job_id, notification_id):
|
||||
return Notification.query.filter_by(service_id=service_id, job_id=job_id, id=notification_id).one()
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ class VerifyCode(db.Model):
|
||||
return check_hash(cde, self._code)
|
||||
|
||||
|
||||
NOTIFICATION_STATUS_TYPES = ['sent', 'delivered', 'failed']
|
||||
NOTIFICATION_STATUS_TYPES = ['sent', 'delivered', 'failed', 'complaint', 'bounce']
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
import uuid
|
||||
|
||||
from flask import (
|
||||
@@ -6,11 +7,13 @@ from flask import (
|
||||
jsonify,
|
||||
request,
|
||||
current_app,
|
||||
url_for
|
||||
url_for,
|
||||
json
|
||||
)
|
||||
|
||||
from utils.template import Template
|
||||
from app.clients.sms.firetext import firetext_response_status
|
||||
from app.clients.email.aws_ses import ses_response_status
|
||||
from app import api_user, encryption, create_uuid, DATETIME_FORMAT, DATE_FORMAT
|
||||
from app.authentication.auth import require_admin
|
||||
from app.dao import (
|
||||
@@ -33,6 +36,69 @@ from app.errors import register_errors
|
||||
register_errors(notifications)
|
||||
|
||||
|
||||
@notifications.route('/notifications/email/ses', methods=['POST'])
|
||||
def process_ses_response():
|
||||
try:
|
||||
ses_request = json.loads(request.data)
|
||||
|
||||
if 'Message' not in ses_request:
|
||||
current_app.logger.error(
|
||||
"SES callback failed: message missing"
|
||||
)
|
||||
return jsonify(
|
||||
result="error", message="SES callback failed: message missing"
|
||||
), 400
|
||||
|
||||
if 'notificationType' not in ses_request['Message']:
|
||||
current_app.logger.error(
|
||||
"SES callback failed: notificationType missing"
|
||||
)
|
||||
return jsonify(
|
||||
result="error", message="SES callback failed: notificationType missing"
|
||||
), 400
|
||||
|
||||
status = ses_response_status.get(ses_request['Message']['notificationType'], None)
|
||||
if not status:
|
||||
current_app.logger.info(
|
||||
"SES callback failed: status {} not found.".format(status)
|
||||
)
|
||||
return jsonify(
|
||||
result="error",
|
||||
message="SES callback failed: status {} not found".format(ses_request['Message']['notificationType'])
|
||||
), 400
|
||||
|
||||
try:
|
||||
recipients = ses_request['Message']['mail']['destination']
|
||||
|
||||
if notifications_dao.update_notification_status_by_to(recipients[0], status['notify_status']) == 0:
|
||||
current_app.logger.info(
|
||||
"SES callback failed: notification not found. Status {}".format(status['notify_status'])
|
||||
)
|
||||
return jsonify(
|
||||
result="error",
|
||||
message="SES callback failed: notification not found. Status {}".format(status['notify_status'])
|
||||
), 404
|
||||
return jsonify(
|
||||
result="success", message="SES callback succeeded"
|
||||
), 200
|
||||
|
||||
except KeyError:
|
||||
current_app.logger.error(
|
||||
"SES callback failed: destination missing"
|
||||
)
|
||||
return jsonify(
|
||||
result="error", message="SES callback failed: destination missing"
|
||||
), 400
|
||||
|
||||
except JSONDecodeError as ex:
|
||||
current_app.logger.error(
|
||||
"SES callback failed: invalid json"
|
||||
)
|
||||
return jsonify(
|
||||
result="error", message="SES callback failed: invalid json"
|
||||
), 400
|
||||
|
||||
|
||||
@notifications.route('/notifications/sms/firetext', methods=['POST'])
|
||||
def process_firetext_response():
|
||||
if 'status' not in request.form:
|
||||
@@ -70,8 +136,7 @@ def process_firetext_response():
|
||||
)
|
||||
return jsonify(result="error", message="Firetext callback failed: status {} not found.".format(status)), 400
|
||||
|
||||
notification = notifications_dao.get_notification_by_id(reference)
|
||||
if not notification:
|
||||
if notifications_dao.update_notification_status_by_id(reference, notification_status['notify_status']) == 0:
|
||||
current_app.logger.info(
|
||||
"Firetext callback failed: notification {} not found. Status {}".format(reference, status)
|
||||
)
|
||||
@@ -90,8 +155,6 @@ def process_firetext_response():
|
||||
firetext_response_status[status]['firetext_message']
|
||||
)
|
||||
)
|
||||
notification.status = notification_status['notify_status']
|
||||
notifications_dao.dao_update_notification(notification)
|
||||
return jsonify(
|
||||
result="success", message="Firetext callback succeeded. reference {} updated".format(reference)
|
||||
), 200
|
||||
|
||||
@@ -18,7 +18,7 @@ from sqlalchemy.dialects import postgresql
|
||||
def upgrade():
|
||||
op.drop_column('notifications', 'status')
|
||||
op.execute('DROP TYPE notification_status_types')
|
||||
notification_status_types = sa.Enum('sent', 'delivered', 'failed', name='notification_status_types')
|
||||
notification_status_types = sa.Enum('sent', 'delivered', 'failed', 'complaint', 'bounce', name='notification_status_types')
|
||||
notification_status_types.create(op.get_bind())
|
||||
op.add_column('notifications', sa.Column('status', notification_status_types, nullable=True))
|
||||
op.get_bind()
|
||||
|
||||
32
test_ses_responses/ses_response.json
Normal file
32
test_ses_responses/ses_response.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"MessageId": "35efe472-ba36-5808-89bb-ab2925158b13",
|
||||
"UnsubscribeURL": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:123456789012:preview-emails:12345678-1234-1234-1234-123456789012",
|
||||
"SignatureVersion": "1",
|
||||
"TopicArn": "arn:aws:sns:eu-west-1:123456789012:testing",
|
||||
"Timestamp": "2016-03-10T16:12:19.876Z",
|
||||
"Type": "Notification",
|
||||
"Signature": "sig",
|
||||
"Message": {
|
||||
"notificationType": "Delivery",
|
||||
"mail": {
|
||||
"timestamp": "2016-03-10T16:12:19.016Z",
|
||||
"source": "invites@testing-notify.com",
|
||||
"sourceArn": "arn:aws:ses:eu-west-1:123456789012:identity/testing-notify",
|
||||
"sendingAccountId": "123456789012",
|
||||
"messageId": "01020153614cd6c8-2ec4bd32-7ddc-4344-811e-65d05519251f-000000",
|
||||
"destination": [
|
||||
"testing@digital.cabinet-office.gov.uk"
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"timestamp": "2016-03-10T16:12:19.751Z",
|
||||
"processingTimeMillis": 735,
|
||||
"recipients": [
|
||||
"testing@digital.cabinet-office.gov.uk"
|
||||
],
|
||||
"smtpResponse": "250 2.0.0 OK 1457626339 u62si5491824wme.91 - gsmtp",
|
||||
"reportingMTA": "a6-15.smtp-out.eu-west-1.amazonses.com"
|
||||
}
|
||||
},
|
||||
"SigningCertURL": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-bb750dd426d95ee9390147a5624348ee.pem"
|
||||
}
|
||||
@@ -5,3 +5,9 @@ def load_example_csv(file):
|
||||
file_path = os.path.join("test_csv_files", "{}.csv".format(file))
|
||||
with open(file_path) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def load_example_ses(file):
|
||||
file_path = os.path.join("test_ses_responses", "{}.json".format(file))
|
||||
with open(file_path) as f:
|
||||
return f.read()
|
||||
|
||||
@@ -16,12 +16,36 @@ from app.dao.notifications_dao import (
|
||||
dao_get_notification_statistics_for_service,
|
||||
delete_successful_notifications_created_more_than_a_day_ago,
|
||||
delete_failed_notifications_created_more_than_a_week_ago,
|
||||
dao_get_notification_statistics_for_service_and_day
|
||||
dao_get_notification_statistics_for_service_and_day,
|
||||
update_notification_status_by_id,
|
||||
update_notification_status_by_to
|
||||
)
|
||||
from tests.app.conftest import sample_job
|
||||
from tests.app.conftest import sample_notification
|
||||
|
||||
|
||||
def test_should_by_able_to_update_status_by_id(sample_notification):
|
||||
assert Notification.query.get(sample_notification.id).status == 'sent'
|
||||
count = update_notification_status_by_id(sample_notification.id, 'delivered')
|
||||
assert count == 1
|
||||
assert Notification.query.get(sample_notification.id).status == 'delivered'
|
||||
|
||||
|
||||
def test_should_return_zero_count_if_no_notification_with_id():
|
||||
assert update_notification_status_by_id(str(uuid.uuid4()), 'delivered') == 0
|
||||
|
||||
|
||||
def test_should_return_zero_count_if_no_notification_with_to():
|
||||
assert update_notification_status_by_to('something', 'delivered') == 0
|
||||
|
||||
|
||||
def test_should_by_able_to_update_status_by_to(sample_notification):
|
||||
assert Notification.query.get(sample_notification.id).status == 'sent'
|
||||
count = update_notification_status_by_to(sample_notification.to, 'delivered')
|
||||
assert count == 1
|
||||
assert Notification.query.get(sample_notification.id).status == 'delivered'
|
||||
|
||||
|
||||
def test_should_be_able_to_get_statistics_for_a_service(sample_template):
|
||||
data = {
|
||||
'to': '+44709123456',
|
||||
@@ -568,7 +592,7 @@ def test_should_not_delete_sent_notifications_before_one_day(notify_db, notify_d
|
||||
|
||||
def test_should_not_delete_failed_notifications_before_seven_days(notify_db, notify_db_session):
|
||||
expired = datetime.utcnow() - timedelta(hours=24 * 7)
|
||||
valid = datetime.utcnow() - timedelta(hours=(24 * 6) + 23, minutes=59, seconds=59)
|
||||
valid = datetime.utcnow() - timedelta(hours=(24 * 6) + 23, minutes=59, seconds=59)
|
||||
sample_notification(notify_db, notify_db_session, created_at=expired, status="failed", to_field="expired")
|
||||
sample_notification(notify_db, notify_db_session, created_at=valid, status="failed", to_field="valid")
|
||||
assert len(Notification.query.all()) == 2
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.dao.templates_dao import dao_get_all_templates_for_service
|
||||
from app.dao.services_dao import dao_update_service
|
||||
from app.dao.notifications_dao import get_notification_by_id
|
||||
from freezegun import freeze_time
|
||||
from tests.app import load_example_ses
|
||||
|
||||
|
||||
def test_get_notification_by_id(notify_api, sample_notification):
|
||||
@@ -1044,3 +1045,100 @@ def test_firetext_callback_should_update_notification_status_sent(notify_api, no
|
||||
)
|
||||
updated = get_notification_by_id(notification.id)
|
||||
assert updated.status == 'sent'
|
||||
|
||||
|
||||
def test_ses_callback_should_not_need_auth(notify_api):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data=load_example_ses('ses_response'),
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_ses_callback_should_fail_if_invalid_json(notify_api):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data="nonsense",
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert response.status_code == 400
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'SES callback failed: invalid json'
|
||||
|
||||
|
||||
def test_ses_callback_should_fail_if_invalid_notification_type(notify_api):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
ses_response = json.loads(load_example_ses('ses_response'))
|
||||
ses_response['Message']['notificationType'] = 'Unknown'
|
||||
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data=json.dumps(ses_response),
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert response.status_code == 400
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'SES callback failed: status Unknown not found'
|
||||
|
||||
|
||||
def test_ses_callback_should_fail_if_missing_destination(notify_api):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
ses_response = json.loads(load_example_ses('ses_response'))
|
||||
del(ses_response['Message']['mail']['destination'])
|
||||
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data=json.dumps(ses_response),
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert response.status_code == 400
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'SES callback failed: destination missing'
|
||||
|
||||
|
||||
def test_ses_callback_should_fail_if_notification_cannot_be_found(notify_db, notify_db_session, notify_api):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
ses_response = json.loads(load_example_ses('ses_response'))
|
||||
ses_response['Message']['mail']['destination'] = ['wont find this']
|
||||
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data=json.dumps(ses_response),
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert response.status_code == 404
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'SES callback failed: notification not found. Status delivered'
|
||||
|
||||
|
||||
def test_ses_callback_should_update_notification_status(notify_api, sample_notification):
|
||||
with notify_api.test_request_context():
|
||||
with notify_api.test_client() as client:
|
||||
|
||||
assert get_notification_by_id(sample_notification.id).status == 'sent'
|
||||
|
||||
ses_response = json.loads(load_example_ses('ses_response'))
|
||||
ses_response['Message']['mail']['destination'] = [sample_notification.to]
|
||||
|
||||
response = client.post(
|
||||
path='/notifications/email/ses',
|
||||
data=json.dumps(ses_response),
|
||||
headers=[('Content-Type', 'text/plain; charset=UTF-8')]
|
||||
)
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert response.status_code == 200
|
||||
assert json_resp['result'] == 'success'
|
||||
assert json_resp['message'] == 'SES callback succeeded'
|
||||
assert get_notification_by_id(sample_notification.id).status == 'delivered'
|
||||
|
||||
Reference in New Issue
Block a user