Merge pull request #721 from alphagov/post-sms-v2

Post sms v2
This commit is contained in:
Rebecca Law
2016-11-02 13:38:42 +00:00
committed by GitHub
23 changed files with 947 additions and 105 deletions

View File

@@ -1,7 +1,7 @@
import uuid
import os
from flask import request, url_for, g
from flask import request, url_for, g, jsonify
from flask import Flask, _request_ctx_stack
from flask.ext.sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
@@ -57,6 +57,13 @@ def create_app(app_name=None):
encryption.init_app(application)
clients.init_app(sms_clients=[firetext_client, mmg_client, loadtest_client], email_clients=[aws_ses_client])
register_blueprint(application)
register_v2_blueprints(application)
return application
def register_blueprint(application):
from app.service.rest import service_blueprint
from app.user.rest import user as user_blueprint
from app.template.rest import template as template_blueprint
@@ -81,14 +88,16 @@ def create_app(app_name=None):
application.register_blueprint(invite_blueprint)
application.register_blueprint(delivery_blueprint)
application.register_blueprint(accept_invite, url_prefix='/invite')
application.register_blueprint(template_statistics_blueprint)
application.register_blueprint(events_blueprint)
application.register_blueprint(provider_details_blueprint, url_prefix='/provider-details')
application.register_blueprint(spec_blueprint, url_prefix='/spec')
application.register_blueprint(organisation_blueprint, url_prefix='/organisation')
return application
def register_v2_blueprints(application):
from app.v2.notifications.post_notifications import notification_blueprint
application.register_blueprint(notification_blueprint)
def init_app(app):
@@ -120,6 +129,12 @@ def init_app(app):
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
return response
@app.errorhandler(404)
def page_not_found(e):
msg = e.description or "Not found"
app.logger.exception(msg)
return jsonify(result='error', message=msg), 404
def create_uuid():
return str(uuid.uuid4())

View File

@@ -12,8 +12,15 @@ from app.dao.services_dao import dao_fetch_service_by_id
class AuthError(Exception):
def __init__(self, message, code):
self.message = {"token": [message]}
self.short_message = message
self.code = code
def to_dict_v2(self):
return {'code': self.code,
'message': self.short_message,
'fields': self.message,
'link': 'link to docs'}
def get_auth_token(req):
auth_header = req.headers.get('Authorization', None)

View File

@@ -1,4 +1,3 @@
import uuid
import pytz
from datetime import (
datetime,
@@ -12,7 +11,7 @@ from werkzeug.datastructures import MultiDict
from sqlalchemy import (desc, func, or_, and_, asc, cast, Text)
from sqlalchemy.orm import joinedload
from app import db
from app import db, create_uuid
from app.dao import days_ago
from app.models import (
Service,
@@ -125,7 +124,7 @@ def dao_get_last_template_usage(template_id):
def dao_create_notification(notification):
if not notification.id:
# need to populate defaulted fields before we create the notification history object
notification.id = uuid.uuid4()
notification.id = create_uuid()
if not notification.status:
notification.status = 'created'

View File

@@ -9,6 +9,9 @@ from app.authentication.auth import AuthError
class InvalidRequest(Exception):
code = None
link = None
fields = []
def __init__(self, message, status_code):
super().__init__()
@@ -18,62 +21,67 @@ class InvalidRequest(Exception):
def to_dict(self):
return {'result': 'error', 'message': self.message}
def to_dict_v2(self):
'''
Version 2 of the public api error response.
'''
return {
"code": self.code,
"message": self.message,
"link": self.link,
"fields": self.fields
}
def __str__(self):
return str(self.to_dict())
def register_errors(blueprint):
@blueprint.app_errorhandler(AuthError)
@blueprint.errorhandler(AuthError)
def authentication_error(error):
return jsonify(result='error', message=error.message), error.code
@blueprint.app_errorhandler(ValidationError)
@blueprint.errorhandler(ValidationError)
def validation_error(error):
current_app.logger.error(error)
return jsonify(result='error', message=error.messages), 400
@blueprint.app_errorhandler(InvalidRequest)
@blueprint.errorhandler(InvalidRequest)
def invalid_data(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
current_app.logger.error(error)
return response
@blueprint.app_errorhandler(400)
@blueprint.errorhandler(400)
def bad_request(e):
msg = e.description or "Invalid request parameters"
current_app.logger.exception(msg)
return jsonify(result='error', message=str(msg)), 400
@blueprint.app_errorhandler(401)
@blueprint.errorhandler(401)
def unauthorized(e):
error_message = "Unauthorized, authentication token must be provided"
return jsonify(result='error', message=error_message), 401, [('WWW-Authenticate', 'Bearer')]
@blueprint.app_errorhandler(403)
@blueprint.errorhandler(403)
def forbidden(e):
error_message = "Forbidden, invalid authentication token provided"
return jsonify(result='error', message=error_message), 403
@blueprint.app_errorhandler(404)
def page_not_found(e):
msg = e.description or "Not found"
current_app.logger.exception(msg)
return jsonify(result='error', message=msg), 404
@blueprint.app_errorhandler(429)
@blueprint.errorhandler(429)
def limit_exceeded(e):
current_app.logger.exception(e)
return jsonify(result='error', message=str(e.description)), 429
@blueprint.app_errorhandler(NoResultFound)
@blueprint.app_errorhandler(DataError)
@blueprint.errorhandler(NoResultFound)
@blueprint.errorhandler(DataError)
def no_result_found(e):
current_app.logger.exception(e)
return jsonify(result='error', message="No result found"), 404
@blueprint.app_errorhandler(SQLAlchemyError)
@blueprint.errorhandler(SQLAlchemyError)
def db_error(e):
current_app.logger.exception(e)
if e.orig.pgerror and \
@@ -89,7 +97,7 @@ def register_errors(blueprint):
# this must be defined after all other error handlers since it catches the generic Exception object
@blueprint.app_errorhandler(500)
@blueprint.app_errorhandler(Exception)
@blueprint.errorhandler(Exception)
def internal_server_error(e):
current_app.logger.exception(e)
return jsonify(result='error', message="Internal server error"), 500

View File

@@ -563,6 +563,29 @@ class Notification(db.Model):
key_type=key_type
)
@classmethod
def from_v2_api_request(cls,
template_id,
template_version,
recipient,
service_id,
personalisation,
notification_type,
api_key_id,
key_type):
return cls(
template_id=template_id,
template_version=template_version,
to=recipient,
service_id=service_id,
status='created',
created_at=datetime.datetime.utcnow(),
personalisation=personalisation,
notification_type=notification_type,
api_key_id=api_key_id,
key_type=key_type
)
class NotificationHistory(db.Model):
__tablename__ = 'notification_history'

View File

@@ -0,0 +1,76 @@
from flask import current_app
from notifications_utils.renderers import PassThrough
from notifications_utils.template import Template
from app.celery import provider_tasks
from app.dao.notifications_dao import dao_create_notification, dao_delete_notifications_and_history_by_id
from app.models import SMS_TYPE, Notification, KEY_TYPE_TEST, EMAIL_TYPE
from app.notifications.validators import check_sms_content_char_count
from app.v2.errors import BadRequestError
def create_content_for_notification(template, personalisation):
template_object = Template(
template.__dict__,
personalisation,
renderer=PassThrough()
)
check_placeholders(template_object)
if template_object.template_type == SMS_TYPE:
check_sms_content_char_count(template_object.replaced_content_count)
return template_object
def check_placeholders(template_object):
if template_object.missing_data:
message = 'Template missing personalisation: {}'.format(", ".join(template_object.missing_data))
raise BadRequestError(fields=[{'template': message}], message=message)
if template_object.additional_data:
message = 'Template personalisation not needed for template: {}'.format(
", ".join(template_object.additional_data))
raise BadRequestError(fields=[{'template': message}], message=message)
def persist_notification(template_id,
template_version,
recipient,
service_id,
personalisation,
notification_type,
api_key_id,
key_type):
notification = Notification.from_v2_api_request(template_id=template_id,
template_version=template_version,
recipient=recipient,
service_id=service_id,
personalisation=personalisation,
notification_type=notification_type,
api_key_id=api_key_id,
key_type=key_type)
dao_create_notification(notification)
return notification
def send_notification_to_queue(notification, research_mode):
try:
research_mode = research_mode or notification.key_type == KEY_TYPE_TEST
if notification.notification_type == SMS_TYPE:
provider_tasks.deliver_sms.apply_async(
[str(notification.id)],
queue='send-sms' if not research_mode else 'research-mode'
)
if notification.notification_type == EMAIL_TYPE:
provider_tasks.deliver_email.apply_async(
[str(notification.id)],
queue='send-email' if not research_mode else 'research-mode'
)
except Exception as e:
current_app.logger.exception("Failed to send to SQS exception")
dao_delete_notifications_and_history_by_id(notification.id)
raise
current_app.logger.info(
"{} {} created at {}".format(notification.notification_type, notification.id, notification.created_at)
)

View File

@@ -7,33 +7,34 @@ from flask import (
current_app,
json
)
from notifications_utils.template import Template
from notifications_utils.renderers import PassThrough
from notifications_utils.template import Template
from app import api_user, create_uuid, statsd_client
from app.clients.email.aws_ses import get_aws_responses
from app import api_user, create_uuid, DATETIME_FORMAT, statsd_client
from app.dao.notifications_dao import dao_create_notification, dao_delete_notifications_and_history_by_id
from app.models import KEY_TYPE_TEAM, KEY_TYPE_TEST, Notification, KEY_TYPE_NORMAL, EMAIL_TYPE
from app.dao import (
templates_dao,
services_dao,
notifications_dao
)
from app.models import KEY_TYPE_TEAM
from app.models import SMS_TYPE
from app.notifications.process_client_response import (
validate_callback_data,
process_sms_client_response
)
from app.service.utils import service_allowed_to_send_to
from app.notifications.process_notifications import persist_notification, send_notification_to_queue
from app.notifications.validators import check_service_message_limit, check_template_is_for_notification_type, \
check_template_is_active
from app.schemas import (
email_notification_schema,
sms_template_notification_schema,
notification_with_personalisation_schema,
notifications_filter_schema,
notifications_statistics_schema,
day_schema,
unarchived_template_schema
day_schema
)
from app.service.utils import service_allowed_to_send_to
from app.utils import pagination_links
notifications = Blueprint('notifications', __name__)
@@ -44,7 +45,6 @@ from app.errors import (
)
register_errors(notifications)
from app.celery import provider_tasks
@notifications.route('/notifications/email/ses', methods=['POST'])
@@ -216,43 +216,32 @@ def send_notification(notification_type):
if errors:
raise InvalidRequest(errors, status_code=400)
if all((api_user.key_type != KEY_TYPE_TEST,
service.restricted)):
service_stats = services_dao.fetch_todays_total_message_count(service.id)
if service_stats >= service.message_limit:
error = 'Exceeded send limits ({}) for today'.format(service.message_limit)
raise InvalidRequest(error, status_code=429)
check_service_message_limit(api_user.key_type, service)
template = templates_dao.dao_get_template_by_id_and_service_id(
template_id=notification['template'],
service_id=service_id
)
template = templates_dao.dao_get_template_by_id_and_service_id(template_id=notification['template'],
service_id=service_id)
if notification_type != template.template_type:
raise InvalidRequest("{0} template is not suitable for {1} notification".format(template.template_type,
notification_type),
status_code=400)
errors = unarchived_template_schema.validate({'archived': template.archived})
if errors:
raise InvalidRequest(errors, status_code=400)
check_template_is_for_notification_type(notification_type, template.template_type)
check_template_is_active(template)
template_object = create_template_object_for_notification(template, notification.get('personalisation', {}))
_service_allowed_to_send_to(notification, service)
notification_id = create_uuid()
notification.update({"template_version": template.version})
saved_notification = None
if not _simulated_recipient(notification['to'], notification_type):
persist_notification(
service,
notification_id,
notification,
datetime.utcnow().strftime(DATETIME_FORMAT),
notification_type,
str(api_user.id),
api_user.key_type
)
saved_notification = persist_notification(template_id=template.id,
template_version=template.version,
recipient=notification['to'],
service_id=service.id,
personalisation=notification.get('personalisation', None),
notification_type=notification_type,
api_key_id=api_user.id,
key_type=api_user.key_type)
send_notification_to_queue(saved_notification, service.research_mode)
notification_id = create_uuid() if saved_notification is None else saved_notification.id
notification.update({"template_version": template.version})
return jsonify(
data=get_notification_return_data(
@@ -281,43 +270,6 @@ def _simulated_recipient(to_address, notification_type):
else to_address in current_app.config['SIMULATED_EMAIL_ADDRESSES'])
def persist_notification(
service,
notification_id,
notification,
created_at,
notification_type,
api_key_id=None,
key_type=KEY_TYPE_NORMAL,
):
dao_create_notification(
Notification.from_api_request(
created_at, notification, notification_id, service.id, notification_type, api_key_id, key_type
)
)
try:
research_mode = service.research_mode or key_type == KEY_TYPE_TEST
if notification_type == SMS_TYPE:
provider_tasks.deliver_sms.apply_async(
[str(notification_id)],
queue='send-sms' if not research_mode else 'research-mode'
)
if notification_type == EMAIL_TYPE:
provider_tasks.deliver_email.apply_async(
[str(notification_id)],
queue='send-email' if not research_mode else 'research-mode'
)
except Exception as e:
current_app.logger.exception("Failed to send to SQS exception")
dao_delete_notifications_and_history_by_id(notification_id)
raise InvalidRequest(message="Internal server error", status_code=500)
current_app.logger.info(
"{} {} created at {}".format(notification_type, notification_id, created_at)
)
def _service_allowed_to_send_to(notification, service):
if not service_allowed_to_send_to(notification['to'], service, api_user.key_type):
if api_user.key_type == KEY_TYPE_TEAM:

View File

@@ -0,0 +1,48 @@
from flask import current_app
from app.dao import services_dao
from app.models import KEY_TYPE_TEST, KEY_TYPE_TEAM
from app.service.utils import service_allowed_to_send_to
from app.v2.errors import TooManyRequestsError, BadRequestError
def check_service_message_limit(key_type, service):
if all((key_type != KEY_TYPE_TEST,
service.restricted)):
service_stats = services_dao.fetch_todays_total_message_count(service.id)
if service_stats >= service.message_limit:
raise TooManyRequestsError(service.message_limit)
def check_template_is_for_notification_type(notification_type, template_type):
if notification_type != template_type:
message = "{0} template is not suitable for {1} notification".format(template_type,
notification_type)
raise BadRequestError(fields=[{'template': message}], message=message)
def check_template_is_active(template):
if template.archived:
raise BadRequestError(fields=[{'template': 'Template has been deleted'}],
message="Template has been deleted")
def service_can_send_to_recipient(send_to, key_type, service):
if not service_allowed_to_send_to(send_to, service, key_type):
if key_type == KEY_TYPE_TEAM:
message = 'Cant send to this recipient using a team-only API key'
else:
message = (
'Cant send to this recipient when service is in trial mode '
' see https://www.notifications.service.gov.uk/trial-mode'
)
raise BadRequestError(message=message)
def check_sms_content_char_count(content_count):
char_count_limit = current_app.config.get('SMS_CHAR_COUNT_LIMIT')
if (
content_count > char_count_limit
):
message = 'Content for template has a character count greater than the limit of {}'.format(char_count_limit)
raise BadRequestError(message=message)

View File

@@ -0,0 +1,28 @@
import json
from jsonschema import Draft4Validator, ValidationError
def validate(json_to_validate, schema):
validator = Draft4Validator(schema)
errors = list(validator.iter_errors(json_to_validate))
if errors.__len__() > 0:
raise ValidationError(build_error_message(errors, schema))
return json_to_validate
def build_error_message(errors, schema):
fields = []
for e in errors:
field = "'{}' {}".format(e.path[0], e.schema.get('validationMessage')) if e.schema.get(
'validationMessage') else e.message
s = field.split("'")
field = {s[1]: s[2].strip()}
fields.append(field)
message = {
"code": "1001",
"message": "Validation error occurred - {}".format(schema['title']),
"link": "link to error documentation (not yet implemented)",
"fields": fields
}
return json.dumps(message)

View File

@@ -0,0 +1,20 @@
"""
Definitions are intended for schema definitions that are not likely to change from version to version.
If the definition is specific to a version put it in a definition file in the version package
"""
uuid = {
"type": "string",
"pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$",
"validationMessage": "not a valid UUID",
"code": "1001", # yet to be implemented
"link": "link to our error documentation not yet implemented"
}
personalisation = {
"type": "object",
"validationMessage": "should contain key value pairs",
"code": "1001", # yet to be implemented
"link": "link to our error documentation not yet implemented"
}

0
app/v2/__init__.py Normal file
View File

59
app/v2/errors.py Normal file
View File

@@ -0,0 +1,59 @@
import json
from flask import jsonify, current_app
from jsonschema import ValidationError
from sqlalchemy.exc import DataError
from sqlalchemy.orm.exc import NoResultFound
from app.authentication.auth import AuthError
from app.errors import InvalidRequest
class TooManyRequestsError(InvalidRequest):
status_code = 429
# code and link will be in a static file
code = "10429"
link = "link to docs"
message_template = 'Exceeded send limits ({}) for today'
def __init__(self, sending_limit):
self.message = self.message_template.format(sending_limit)
class BadRequestError(InvalidRequest):
status_code = 400
code = 10400
link = "link to documentation"
message = "An error occurred"
def __init__(self, fields=[], message=None):
self.fields = fields
self.message = message if message else self.message
def register_errors(blueprint):
@blueprint.errorhandler(InvalidRequest)
def invalid_data(error):
current_app.logger.error(error)
response = jsonify(error.to_dict_v2()), error.status_code
return response
@blueprint.errorhandler(ValidationError)
def validation_error(error):
current_app.logger.exception(error)
return jsonify(json.loads(error.message)), 400
@blueprint.errorhandler(NoResultFound)
@blueprint.errorhandler(DataError)
def no_result_found(e):
current_app.logger.exception(e)
return jsonify(message="No result found"), 404
@blueprint.errorhandler(AuthError)
def auth_error(error):
return jsonify(error.to_dict_v2()), error.code
@blueprint.errorhandler(Exception)
def internal_server_error(error):
current_app.logger.exception(error)
return jsonify(message='Internal server error'), 500

View File

@@ -0,0 +1,7 @@
from flask import Blueprint
from app.v2.errors import register_errors
notification_blueprint = Blueprint("v2_notifications", __name__, url_prefix='/v2/notifications')
register_errors(notification_blueprint)

View File

@@ -0,0 +1,14 @@
from app.v2.notifications import notification_blueprint
@notification_blueprint.route("/<uuid:id>", methods=['GET'])
def get_notification_by_id(id):
pass
@notification_blueprint.route("/", methods=['GET'])
def get_notifications():
# validate notifications request arguments
# fetch all notifications
# return notifications_response schema
pass

View File

@@ -0,0 +1,68 @@
from app.schema_validation.definitions import (uuid, personalisation)
post_sms_request = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST sms notification schema",
"type": "object",
"title": "POST v2/notifications/sms",
"properties": {
"reference": {"type": "string"},
"phone_number": {"type": "string", "format": "sms"},
"template_id": uuid,
"personalisation": personalisation
},
"required": ["phone_number", "template_id"]
}
content = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST sms notification response schema",
"type": "object",
"title": "notification content",
"properties": {
"body": {"type": "string"},
"from_number": {"type": "string"}
},
"required": ["body"]
}
# this may belong in a templates module
template = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST sms notification response schema",
"type": "object",
"title": "notification content",
"properties": {
"id": uuid,
"version": {"type": "integer"},
"uri": {"type": "string"}
},
"required": ["id", "version", "uri"]
}
post_sms_response = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST sms notification response schema",
"type": "object",
"title": "response v2/notifications/sms",
"properties": {
"id": uuid,
"reference": {"type": "string"},
"content": content,
"uri": {"type": "string"},
"template": template
},
"required": ["id", "content", "uri", "template"]
}
def create_post_sms_response_from_notification(notification, body, from_number, url_root):
return {"id": notification.id,
"reference": None, # not yet implemented
"content": {'body': body,
'from_number': from_number},
"uri": "{}/v2/notifications/{}".format(url_root, str(notification.id)),
"template": {"id": notification.template_id,
"version": notification.template_version,
"uri": "{}/v2/templates/{}".format(url_root, str(notification.template_id))}
}

View File

@@ -0,0 +1,71 @@
from flask import request, jsonify, current_app
from sqlalchemy.orm.exc import NoResultFound
from app import api_user
from app.dao import services_dao, templates_dao
from app.models import SMS_TYPE
from app.notifications.process_notifications import (create_content_for_notification,
persist_notification,
send_notification_to_queue)
from app.notifications.validators import (check_service_message_limit,
check_template_is_for_notification_type,
check_template_is_active,
service_can_send_to_recipient,
check_sms_content_char_count)
from app.schema_validation import validate
from app.v2.errors import BadRequestError
from app.v2.notifications import notification_blueprint
from app.v2.notifications.notification_schemas import (post_sms_request,
create_post_sms_response_from_notification)
@notification_blueprint.route('/sms', methods=['POST'])
def post_sms_notification():
form = validate(request.get_json(), post_sms_request)
service = services_dao.dao_fetch_service_by_id(api_user.service_id)
check_service_message_limit(api_user.key_type, service)
service_can_send_to_recipient(form['phone_number'], api_user.key_type, service)
template, content = __validate_template(form, service)
notification = persist_notification(template_id=template.id,
template_version=template.version,
recipient=form['phone_number'],
service_id=service.id,
personalisation=form.get('personalisation', None),
notification_type=SMS_TYPE,
api_key_id=api_user.id,
key_type=api_user.key_type)
send_notification_to_queue(notification, service.research_mode)
resp = create_post_sms_response_from_notification(notification, content, service.sms_sender, request.url_root)
return jsonify(resp), 201
@notification_blueprint.route('/email', methods=['POST'])
def post_email_notification():
# validate post form against post_email_request schema
# validate service
# validate template
# persist notification
# send notification to queue
# create content
# return post_email_response schema
pass
def __validate_template(form, service):
try:
template = templates_dao.dao_get_template_by_id_and_service_id(template_id=form['template_id'],
service_id=service.id)
except NoResultFound:
message = 'Template not found'
raise BadRequestError(message=message,
fields=[{'template': message}])
check_template_is_for_notification_type(SMS_TYPE, template.template_type)
check_template_is_active(template)
template_with_content = create_content_for_notification(template, form.get('personalisation', {}))
check_sms_content_char_count(template_with_content.replaced_content_count)
return template, template_with_content.content

View File

@@ -150,7 +150,7 @@ def test_should_not_send_notification_for_archived_template(notify_api, sample_t
headers=[('Content-Type', 'application/json'), auth_header])
assert resp.status_code == 400
json_resp = json.loads(resp.get_data(as_text=True))
assert 'Template has been deleted' in json_resp['message']['template']
assert 'Template has been deleted' in json_resp['message']
@pytest.mark.parametrize('template_type, to',
@@ -516,7 +516,7 @@ def test_should_not_send_sms_if_team_api_key_and_not_a_service_user(notify_api,
def test_should_send_email_if_team_api_key_and_a_service_user(notify_api, sample_email_template, fake_uuid, mocker):
with notify_api.test_request_context(), notify_api.test_client() as client:
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
data = {
'to': sample_email_template.service.created_by.email_address,
@@ -545,7 +545,7 @@ def test_should_send_sms_to_anyone_with_test_key(
):
with notify_api.test_request_context(), notify_api.test_client() as client:
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
data = {
'to': '07811111111',
@@ -578,7 +578,7 @@ def test_should_send_email_to_anyone_with_test_key(
):
with notify_api.test_request_context(), notify_api.test_client() as client:
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
data = {
'to': 'anyone123@example.com',
@@ -608,7 +608,7 @@ def test_should_send_email_to_anyone_with_test_key(
def test_should_send_sms_if_team_api_key_and_a_service_user(notify_api, sample_template, fake_uuid, mocker):
with notify_api.test_request_context(), notify_api.test_client() as client:
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
data = {
'to': sample_template.service.created_by.mobile_number,
@@ -638,7 +638,7 @@ def test_should_persist_notification(notify_api, sample_template,
fake_uuid, mocker):
with notify_api.test_request_context(), notify_api.test_client() as client:
mocked = mocker.patch('app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type))
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
template = sample_template if template_type == 'sms' else sample_email_template
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
else sample_email_template.service.created_by.email_address
@@ -682,7 +682,7 @@ def test_should_delete_notification_and_return_error_if_sqs_fails(
'app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type),
side_effect=Exception("failed to talk to SQS")
)
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
m1 = mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
template = sample_template if template_type == 'sms' else sample_email_template
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
else sample_email_template.service.created_by.email_address

View File

@@ -0,0 +1,91 @@
import pytest
from boto3.exceptions import Boto3Error
from sqlalchemy.exc import SQLAlchemyError
from app.models import Template, Notification, NotificationHistory
from app.notifications.process_notifications import (create_content_for_notification,
persist_notification, send_notification_to_queue)
from app.v2.errors import BadRequestError
from tests.app.conftest import sample_notification, sample_template, sample_email_template
def test_create_content_for_notification_passes(sample_email_template):
template = Template.query.get(sample_email_template.id)
content = create_content_for_notification(template, None)
assert content.replaced == template.content
def test_create_content_for_notification_with_placeholders_passes(sample_template_with_placeholders):
template = Template.query.get(sample_template_with_placeholders.id)
content = create_content_for_notification(template, {'name': 'Bobby'})
assert content.content == template.content
assert 'Bobby' in content.replaced
def test_create_content_for_notification_fails_with_missing_personalisation(sample_template_with_placeholders):
template = Template.query.get(sample_template_with_placeholders.id)
with pytest.raises(BadRequestError):
create_content_for_notification(template, None)
def test_create_content_for_notification_fails_with_additional_personalisation(sample_template_with_placeholders):
template = Template.query.get(sample_template_with_placeholders.id)
with pytest.raises(BadRequestError) as e:
create_content_for_notification(template, {'name': 'Bobby', 'Additional placeholder': 'Data'})
assert e.value.message == 'Template personalisation not needed for template: Additional placeholder'
def test_persist_notification_creates_and_save_to_db(sample_template, sample_api_key):
assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0
notification = persist_notification(sample_template.id, sample_template.version, '+447111111111',
sample_template.service.id, {}, 'sms', sample_api_key.id,
sample_api_key.key_type)
assert Notification.query.count() == 1
assert Notification.query.get(notification.id) is not None
assert NotificationHistory.query.count() == 1
def test_persist_notification_throws_exception_when_missing_template(sample_template, sample_api_key):
assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0
with pytest.raises(SQLAlchemyError):
persist_notification(template_id=None,
template_version=None,
recipient='+447111111111',
service_id=sample_template.service.id,
personalisation=None, notification_type='sms',
api_key_id=sample_api_key.id,
key_type=sample_api_key.key_type)
assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0
@pytest.mark.parametrize('research_mode, queue, notification_type, key_type',
[(True, 'research-mode', 'sms', 'normal'),
(True, 'research-mode', 'email', 'normal'),
(True, 'research-mode', 'email', 'team'),
(False, 'send-sms', 'sms', 'normal'),
(False, 'send-email', 'email', 'normal'),
(False, 'send-sms', 'sms', 'team'),
(False, 'research-mode', 'sms', 'test')])
def test_send_notification_to_queue(notify_db, notify_db_session,
research_mode, notification_type,
queue, key_type, mocker):
mocked = mocker.patch('app.celery.provider_tasks.deliver_{}.apply_async'.format(notification_type))
template = sample_template(notify_db, notify_db_session) if notification_type == 'sms' \
else sample_email_template(notify_db, notify_db_session)
notification = sample_notification(notify_db, notify_db_session, template=template, key_type=key_type)
send_notification_to_queue(notification=notification, research_mode=research_mode)
mocked.assert_called_once_with([str(notification.id)], queue=queue)
def test_send_notification_to_queue_throws_exception_deletes_notification(sample_notification, mocker):
mocked = mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async', side_effect=Boto3Error("EXPECTED"))
with pytest.raises(Boto3Error):
send_notification_to_queue(sample_notification, False)
mocked.assert_called_once_with([(str(sample_notification.id))], queue='send-sms')
assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0

View File

@@ -0,0 +1,158 @@
import pytest
from app.notifications.validators import check_service_message_limit, check_template_is_for_notification_type, \
check_template_is_active, service_can_send_to_recipient, check_sms_content_char_count
from app.v2.errors import BadRequestError, TooManyRequestsError
from tests.app.conftest import (sample_notification as create_notification,
sample_service as create_service, sample_service_whitelist)
@pytest.mark.parametrize('key_type', ['test', 'team', 'normal'])
def test_check_service_message_limit_with_unrestricted_service_passes(key_type,
sample_service,
sample_notification):
assert check_service_message_limit(key_type, sample_service) is None
@pytest.mark.parametrize('key_type', ['test', 'team', 'normal'])
def test_check_service_message_limit_under_message_limit_passes(key_type,
sample_service,
sample_notification):
assert check_service_message_limit(key_type, sample_service) is None
@pytest.mark.parametrize('key_type', ['team', 'normal'])
def test_check_service_message_limit_over_message_limit_fails(key_type, notify_db, notify_db_session):
service = create_service(notify_db, notify_db_session, restricted=True, limit=4)
for x in range(5):
create_notification(notify_db, notify_db_session, service=service)
with pytest.raises(TooManyRequestsError) as e:
check_service_message_limit(key_type, service)
assert e.value.status_code == 429
assert e.value.code == '10429'
assert e.value.message == 'Exceeded send limits (4) for today'
assert e.value.fields == []
@pytest.mark.parametrize('template_type, notification_type',
[('email', 'email'),
('sms', 'sms')])
def test_check_template_is_for_notification_type_pass(template_type, notification_type):
assert check_template_is_for_notification_type(notification_type=notification_type,
template_type=template_type) is None
@pytest.mark.parametrize('template_type, notification_type',
[('sms', 'email'),
('email', 'sms')])
def test_check_template_is_for_notification_type_fails_when_template_type_does_not_match_notification_type(
template_type, notification_type):
with pytest.raises(BadRequestError) as e:
check_template_is_for_notification_type(notification_type=notification_type,
template_type=template_type)
assert e.value.code == 10400
error_message = '{0} template is not suitable for {1} notification'.format(template_type, notification_type)
assert e.value.message == error_message
assert e.value.link == 'link to documentation'
assert e.value.fields == [{'template': error_message}]
def test_check_template_is_active_passes(sample_template):
assert check_template_is_active(sample_template) is None
def test_check_template_is_active_fails(sample_template):
sample_template.archived = True
from app.dao.templates_dao import dao_update_template
dao_update_template(sample_template)
with pytest.raises(BadRequestError) as e:
check_template_is_active(sample_template)
assert e.value.status_code == 400
assert e.value.code == 10400
assert e.value.message == 'Template has been deleted'
assert e.value.link == "link to documentation"
assert e.value.fields == [{'template': 'Template has been deleted'}]
@pytest.mark.parametrize('key_type',
['test', 'normal'])
def test_service_can_send_to_recipient_passes(key_type, notify_db, notify_db_session):
trial_mode_service = create_service(notify_db, notify_db_session, service_name='trial mode', restricted=True)
assert service_can_send_to_recipient(trial_mode_service.users[0].email_address,
key_type,
trial_mode_service) is None
assert service_can_send_to_recipient(trial_mode_service.users[0].mobile_number,
key_type,
trial_mode_service) is None
@pytest.mark.parametrize('key_type',
['test', 'normal'])
def test_service_can_send_to_recipient_passes_for_live_service_non_team_member(key_type, notify_db, notify_db_session):
live_service = create_service(notify_db, notify_db_session, service_name='live', restricted=False)
assert service_can_send_to_recipient("some_other_email@test.com",
key_type,
live_service) is None
assert service_can_send_to_recipient('07513332413',
key_type,
live_service) is None
def test_service_can_send_to_recipient_passes_for_whitelisted_recipient_passes(notify_db, notify_db_session,
sample_service):
sample_service_whitelist(notify_db, notify_db_session, email_address="some_other_email@test.com")
assert service_can_send_to_recipient("some_other_email@test.com",
'team',
sample_service) is None
sample_service_whitelist(notify_db, notify_db_session, mobile_number='07513332413')
assert service_can_send_to_recipient('07513332413',
'team',
sample_service) is None
@pytest.mark.parametrize('recipient', ['07513332413', 'some_other_email@test.com'])
@pytest.mark.parametrize('key_type, error_message',
[('team', 'Cant send to this recipient using a team-only API key'),
('normal',
"Cant send to this recipient when service is in trial mode see https://www.notifications.service.gov.uk/trial-mode")]) # noqa
def test_service_can_send_to_recipient_fails_when_recipient_is_not_on_team(recipient, key_type, error_message,
notify_db, notify_db_session):
trial_mode_service = create_service(notify_db, notify_db_session, service_name='trial mode', restricted=True)
with pytest.raises(BadRequestError) as exec_info:
service_can_send_to_recipient(recipient,
key_type,
trial_mode_service)
assert exec_info.value.status_code == 400
assert exec_info.value.code == 10400
assert exec_info.value.message == error_message
assert exec_info.value.link == 'link to documentation'
assert exec_info.value.fields == []
def test_service_can_send_to_recipient_fails_when_mobile_number_is_not_on_team(notify_db, notify_db_session):
live_service = create_service(notify_db, notify_db_session, service_name='live mode', restricted=False)
with pytest.raises(BadRequestError) as e:
service_can_send_to_recipient("0758964221",
'team',
live_service)
assert e.value.status_code == 400
assert e.value.code == 10400
assert e.value.message == 'Cant send to this recipient using a team-only API key'
assert e.value.link == 'link to documentation'
assert e.value.fields == []
@pytest.mark.parametrize('char_count', [495, 0, 494, 200])
def test_check_sms_content_char_count_passes(char_count, notify_api):
assert check_sms_content_char_count(char_count) is None
@pytest.mark.parametrize('char_count', [496, 500, 6000])
def test_check_sms_content_char_count_fails(char_count, notify_api):
with pytest.raises(BadRequestError) as e:
check_sms_content_char_count(char_count)
assert e.value.status_code == 400
assert e.value.code == 10400
assert e.value.message == 'Content for template has a character count greater than the limit of {}'.format(
notify_api.config['SMS_CHAR_COUNT_LIMIT'])
assert e.value.link == 'link to documentation'
assert e.value.fields == []

0
tests/app/v2/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,92 @@
import uuid
import pytest
from flask import json
from jsonschema import ValidationError
from app.v2.notifications.notification_schemas import post_sms_request, post_sms_response
from app.schema_validation import validate
valid_json = {"phone_number": "07515111111",
"template_id": str(uuid.uuid4())
}
valid_json_with_optionals = {
"phone_number": "07515111111",
"template_id": str(uuid.uuid4()),
"reference": "reference from caller",
"personalisation": {"key": "value"}
}
@pytest.mark.parametrize("input", [valid_json, valid_json_with_optionals])
def test_post_sms_schema_is_valid(input):
validate(input, post_sms_request)
def test_post_sms_json_schema_bad_uuid_and_missing_phone_number():
j = {"template_id": "notUUID"}
with pytest.raises(ValidationError) as e:
validate(j, post_sms_request)
error = json.loads(e.value.message)
assert "POST v2/notifications/sms" in error['message']
assert len(error.get('fields')) == 2
assert {"phone_number": "is a required property"} in error['fields']
assert {"template_id": "not a valid UUID"} in error['fields']
assert error.get('code') == '1001'
assert error.get('link', None) is not None
def test_post_sms_schema_with_personalisation_that_is_not_a_dict():
j = {
"phone_number": "07515111111",
"template_id": str(uuid.uuid4()),
"reference": "reference from caller",
"personalisation": "not_a_dict"
}
with pytest.raises(ValidationError) as e:
validate(j, post_sms_request)
error = json.loads(e.value.message)
assert "POST v2/notifications/sms" in error['message']
assert len(error.get('fields')) == 1
assert error['fields'][0] == {"personalisation": "should contain key value pairs"}
assert error.get('code') == '1001'
assert error.get('link', None) == 'link to error documentation (not yet implemented)'
valid_response = {
"id": str(uuid.uuid4()),
"content": {"body": "contents of message",
"from_number": "46045"},
"uri": "/v2/notifications/id",
"template": {"id": str(uuid.uuid4()),
"version": 1,
"uri": "/v2/template/id"}
}
valid_response_with_optionals = {
"id": str(uuid.uuid4()),
"reference": "reference_from_service",
"content": {"body": "contents of message",
"from_number": "46045"},
"uri": "/v2/notifications/id",
"template": {"id": str(uuid.uuid4()),
"version": 1,
"uri": "/v2/template/id"}
}
@pytest.mark.parametrize('input', [valid_response])
def test_post_sms_response_schema_is_valid(input):
validate(input, post_sms_response)
def test_post_sms_response_schema_missing_uri():
j = valid_response
del j["uri"]
with pytest.raises(ValidationError) as e:
validate(j, post_sms_response)
error = json.loads(e.value.message)
assert '1001' == error['code']
assert 'link to error documentation (not yet implemented)' == error['link']
assert 'Validation error occurred - response v2/notifications/sms' == error['message']
assert [{"uri": "is a required property"}] == error['fields']

View File

@@ -0,0 +1,106 @@
import uuid
from flask import json
from app.models import Notification
from tests import create_authorization_header
def test_post_sms_notification_returns_201(notify_api, sample_template, mocker):
with notify_api.test_request_context():
with notify_api.test_client() as client:
mocked = mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
data = {
'phone_number': '+447700900855',
'template_id': str(sample_template.id)
}
auth_header = create_authorization_header(service_id=sample_template.service_id)
response = client.post(
path='/v2/notifications/sms',
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), auth_header])
assert response.status_code == 201
resp_json = json.loads(response.get_data(as_text=True))
notifications = Notification.query.all()
assert len(notifications) == 1
notification_id = notifications[0].id
assert resp_json['id'] is not None
assert resp_json['reference'] is None
assert resp_json['content']['body'] == sample_template.content
assert resp_json['content']['from_number'] == sample_template.service.sms_sender
assert 'v2/notifications/{}'.format(notification_id) in resp_json['uri']
assert resp_json['template']['id'] == str(sample_template.id)
assert resp_json['template']['version'] == sample_template.version
assert 'v2/templates/{}'.format(sample_template.id) in resp_json['template']['uri']
assert mocked.called
def test_post_sms_notification_returns_404_and_missing_template(notify_api, sample_service):
with notify_api.test_request_context():
with notify_api.test_client() as client:
data = {
'phone_number': '+447700900855',
'template_id': str(uuid.uuid4())
}
auth_header = create_authorization_header(service_id=sample_service.id)
response = client.post(
path='/v2/notifications/sms',
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), auth_header])
assert response.status_code == 400
assert response.headers['Content-type'] == 'application/json'
error_json = json.loads(response.get_data(as_text=True))
assert error_json['code'] == 10400
assert error_json['message'] == 'Template not found'
assert error_json['fields'] == [{'template': 'Template not found'}]
assert error_json['link'] == 'link to documentation'
def test_post_sms_notification_returns_403_and_well_formed_auth_error(notify_api, sample_template):
with notify_api.test_request_context():
with notify_api.test_client() as client:
data = {
'phone_number': '+447700900855',
'template_id': str(sample_template.id)
}
response = client.post(
path='/v2/notifications/sms',
data=json.dumps(data),
headers=[('Content-Type', 'application/json')])
assert response.status_code == 401
assert response.headers['Content-type'] == 'application/json'
error_resp = json.loads(response.get_data(as_text=True))
assert error_resp['code'] == 401
assert error_resp['message'] == 'Unauthorized, authentication token must be provided'
assert error_resp['fields'] == {'token': ['Unauthorized, authentication token must be provided']}
assert error_resp['link'] == 'link to docs'
def test_post_sms_notification_returns_400_and_for_schema_problems(notify_api, sample_template):
with notify_api.test_request_context():
with notify_api.test_client() as client:
data = {
'phone_number': '+447700900855',
'template': str(sample_template.id)
}
auth_header = create_authorization_header(service_id=sample_template.service_id)
response = client.post(
path='/v2/notifications/sms',
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), auth_header])
assert response.status_code == 400
assert response.headers['Content-type'] == 'application/json'
error_resp = json.loads(response.get_data(as_text=True))
assert error_resp['code'] == '1001'
assert error_resp['message'] == 'Validation error occurred - POST v2/notifications/sms'
assert error_resp['link'] == "link to error documentation (not yet implemented)"
assert error_resp['fields'] == [{"template_id": "is a required property"}]