diff --git a/.travis.yml b/.travis.yml index 446eb43c4..7362b610f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,16 @@ deploy: deployment_group: notifications_api_deployment_group region: eu-west-1 on: *2 +- provider: codedeploy + access_key_id: AKIAJQPPNM6P6V53SWKA + secret_access_key: *1 + bucket: notifications-api-codedeploy + key: notifications-api-$TRAVIS_BRANCH-$TRAVIS_BUILD_NUMBER-$TRAVIS_COMMIT.zip + bundle_type: zip + application: notifications-api + deployment_group: notifications-api-celery + region: eu-west-1 + on: *2 before_deploy: - ./scripts/update_version_file.sh - zip -r notifications-api * diff --git a/app/__init__.py b/app/__init__.py index 3c8a3ec33..6b85f33ab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,18 +1,20 @@ import os -import re -import ast from flask import request, url_for -from flask._compat import string_types from flask import Flask, _request_ctx_stack from flask.ext.sqlalchemy import SQLAlchemy from flask_marshmallow import Marshmallow from werkzeug.local import LocalProxy from utils import logging - +from app.celery.celery import NotifyCelery +from app.clients.sms.twilio import TwilioClient +from app.encryption import Encryption db = SQLAlchemy() ma = Marshmallow() +notify_celery = NotifyCelery() +twilio_client = TwilioClient() +encryption = Encryption() api_user = LocalProxy(lambda: _request_ctx_stack.top.api_user) @@ -22,10 +24,14 @@ def create_app(): application.config.from_object(os.environ['NOTIFY_API_ENVIRONMENT']) + init_app(application) db.init_app(application) ma.init_app(application) init_app(application) logging.init_app(application) + twilio_client.init_app(application) + notify_celery.init_app(application) + encryption.init_app(application) from app.service.rest import service as service_blueprint from app.user.rest import user as user_blueprint diff --git a/app/celery/__init__.py b/app/celery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/celery/celery.py b/app/celery/celery.py new file mode 100644 index 000000000..183e50bd6 --- /dev/null +++ b/app/celery/celery.py @@ -0,0 +1,17 @@ +from celery import Celery + + +class NotifyCelery(Celery): + + def init_app(self, app): + super().__init__(app.import_name, broker=app.config['BROKER_URL']) + self.conf.update(app.config) + TaskBase = self.Task + + class ContextTask(TaskBase): + abstract = True + + def __call__(self, *args, **kwargs): + with app.app_context(): + return TaskBase.__call__(self, *args, **kwargs) + self.Task = ContextTask diff --git a/app/celery/tasks.py b/app/celery/tasks.py new file mode 100644 index 000000000..3b095d062 --- /dev/null +++ b/app/celery/tasks.py @@ -0,0 +1,32 @@ +from app import notify_celery, twilio_client, encryption +from app.clients.sms.twilio import TwilioClientException +from app.dao.templates_dao import get_model_templates +from app.dao.notifications_dao import save_notification +from app.models import Notification +from flask import current_app +from sqlalchemy.exc import SQLAlchemyError + + +@notify_celery.task(name="send-sms") +def send_sms(service_id, notification_id, encrypted_notification): + notification = encryption.decrypt(encrypted_notification) + template = get_model_templates(notification['template']) + + try: + notification_db_object = Notification( + id=notification_id, + template_id=notification['template'], + to=notification['to'], + service_id=service_id, + status='sent' + ) + save_notification(notification_db_object) + + try: + twilio_client.send_sms(notification['to'], template.content) + except TwilioClientException as e: + current_app.logger.debug(e) + save_notification(notification_db_object, {"status": "failed"}) + + except SQLAlchemyError as e: + current_app.logger.debug(e) diff --git a/app/clients/__init__.py b/app/clients/__init__.py new file mode 100644 index 000000000..06495ccbe --- /dev/null +++ b/app/clients/__init__.py @@ -0,0 +1,13 @@ + +class ClientException(Exception): + ''' + Base Exceptions for sending notifications that fail + ''' + pass + + +class Client(object): + ''' + Base client for sending notifications. + ''' + pass diff --git a/app/clients/sms/__init__.py b/app/clients/sms/__init__.py new file mode 100644 index 000000000..8b83ab8b9 --- /dev/null +++ b/app/clients/sms/__init__.py @@ -0,0 +1,17 @@ +from app.clients import (Client, ClientException) + + +class SmsClientException(ClientException): + ''' + Base Exception for SmsClients + ''' + pass + + +class SmsClient(Client): + ''' + Base Sms client for sending smss. + ''' + + def send_sms(self, *args, **kwargs): + raise NotImplemented('TODO Need to implement.') diff --git a/app/clients/sms/twilio.py b/app/clients/sms/twilio.py new file mode 100644 index 000000000..eb5613878 --- /dev/null +++ b/app/clients/sms/twilio.py @@ -0,0 +1,46 @@ +import logging +from app.clients.sms import ( + SmsClient, SmsClientException) +from twilio.rest import TwilioRestClient +from twilio import TwilioRestException + + +logger = logging.getLogger(__name__) + + +class TwilioClientException(SmsClientException): + pass + + +class TwilioClient(SmsClient): + ''' + Twilio sms client. + ''' + def init_app(self, config, *args, **kwargs): + super(TwilioClient, self).__init__(*args, **kwargs) + self.client = TwilioRestClient( + config.config.get('TWILIO_ACCOUNT_SID'), + config.config.get('TWILIO_AUTH_TOKEN')) + self.from_number = config.config.get('TWILIO_NUMBER') + + def send_sms(self, to, content): + try: + response = self.client.messages.create( + body=content, + to=to, + from_=self.from_number + ) + return response.sid + except TwilioRestException as e: + logger.exception(e) + raise TwilioClientException(e) + + def status(self, message_id): + try: + response = self.client.messages.get(message_id) + if response.status in ('delivered', 'undelivered', 'failed'): + return response.status + return None + except TwilioRestException as e: + logger.exception(e) + raise TwilioClientException(e) diff --git a/app/dao/notifications_dao.py b/app/dao/notifications_dao.py index ed3cd0f6d..f917f4429 100644 --- a/app/dao/notifications_dao.py +++ b/app/dao/notifications_dao.py @@ -14,9 +14,13 @@ def save_notification(notification, update_dict={}): db.session.commit() -def get_notification(service_id, job_id, notification_id): +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() -def get_notifications(service_id, job_id): +def get_notifications_for_job(service_id, job_id): return Notification.query.filter_by(service_id=service_id, job_id=job_id).all() + + +def get_notification(service_id, notification_id): + return Notification.query.filter_by(service_id=service_id, id=notification_id).one() diff --git a/app/encryption.py b/app/encryption.py index 51caaab72..2b4755803 100644 --- a/app/encryption.py +++ b/app/encryption.py @@ -1,5 +1,19 @@ from flask.ext.bcrypt import generate_password_hash, check_password_hash +from itsdangerous import URLSafeSerializer + + +class Encryption: + def init_app(self, app): + self.serializer = URLSafeSerializer(app.config.get('SECRET_KEY')) + self.salt = app.config.get('DANGEROUS_SALT') + + def encrypt(self, thing_to_encrypt): + return self.serializer.dumps(thing_to_encrypt, salt=self.salt) + + def decrypt(self, thing_to_decrypt): + return self.serializer.loads(thing_to_decrypt, salt=self.salt) + def hashpw(password): return generate_password_hash(password.encode('UTF-8'), 10) diff --git a/app/job/rest.py b/app/job/rest.py index 17f1e341c..1928e5d66 100644 --- a/app/job/rest.py +++ b/app/job/rest.py @@ -17,11 +17,7 @@ from app.dao.jobs_dao import ( get_jobs_by_service ) -from app.dao.notifications_dao import ( - save_notification, - get_notification, - get_notifications -) +from app.dao import notifications_dao from app.schemas import ( job_schema, @@ -89,7 +85,7 @@ def create_notification_for_job(service_id, job_id): if errors: return jsonify(result="error", message=errors), 400 try: - save_notification(notification) + notifications_dao.save_notification(notification) except Exception as e: return jsonify(result="error", message=str(e)), 500 return jsonify(data=notification_status_schema.dump(notification).data), 201 @@ -100,7 +96,7 @@ def create_notification_for_job(service_id, job_id): def get_notification_for_job(service_id, job_id, notification_id=None): if notification_id: try: - notification = get_notification(service_id, job_id, notification_id) + notification = notifications_dao.get_notification_for_job(service_id, job_id, notification_id) data, errors = notification_status_schema.dump(notification) return jsonify(data=data) except DataError: @@ -108,7 +104,7 @@ def get_notification_for_job(service_id, job_id, notification_id=None): except NoResultFound: return jsonify(result="error", message="Notification not found"), 404 else: - notifications = get_notifications(service_id, job_id) + notifications = notifications_dao.get_notifications_for_job(service_id, job_id) data, errors = notifications_status_schema.dump(notifications) return jsonify(data=data) @@ -116,13 +112,13 @@ def get_notification_for_job(service_id, job_id, notification_id=None): @job.route('//notification/', methods=['PUT']) def update_notification_for_job(service_id, job_id, notification_id): - notification = get_notification(service_id, job_id, notification_id) + notification = notifications_dao.get_notification_for_job(service_id, job_id, notification_id) update_dict, errors = notification_status_schema_load_json.load(request.get_json()) if errors: return jsonify(result="error", message=errors), 400 try: - save_notification(notification, update_dict=update_dict) + notifications_dao.save_notification(notification, update_dict=update_dict) except Exception as e: return jsonify(result="error", message=str(e)), 400 diff --git a/app/notifications/rest.py b/app/notifications/rest.py index b596e15b1..fdc3bc747 100644 --- a/app/notifications/rest.py +++ b/app/notifications/rest.py @@ -6,30 +6,51 @@ from flask import ( request ) -from app import api_user +from app import api_user, encryption from app.aws_sqs import add_notification_to_queue -from app.dao import (templates_dao) +from app.dao import (templates_dao, notifications_dao) from app.schemas import ( - email_notification_schema, sms_template_notification_schema) + email_notification_schema, + sms_template_notification_schema, + notification_status_schema +) +from app.celery.tasks import send_sms +from sqlalchemy.orm.exc import NoResultFound notifications = Blueprint('notifications', __name__) -@notifications.route('/', methods=['GET']) +def create_notification_id(): + return str(uuid.uuid4()) + + +@notifications.route('/', methods=['GET']) def get_notifications(notification_id): - # TODO return notification id details - return jsonify({'id': notification_id}), 200 + try: + notification = notifications_dao.get_notification(api_user['client'], notification_id) + return jsonify({'notification': notification_status_schema.dump(notification).data}), 200 + except NoResultFound: + return jsonify(result="error", message="not found"), 404 @notifications.route('/sms', methods=['POST']) def create_sms_notification(): - resp_json = request.get_json() - - notification, errors = sms_template_notification_schema.load(resp_json) + notification, errors = sms_template_notification_schema.load(request.get_json()) if errors: return jsonify(result="error", message=errors), 400 - notification_id = add_notification_to_queue(api_user['client'], notification['template'], 'sms', notification) + try: + templates_dao.get_model_templates(template_id=notification['template'], service_id=api_user['client']) + except NoResultFound: + return jsonify(result="error", message={'template': ['Template not found']}), 400 + + notification_id = create_notification_id() + + send_sms.apply_async(( + api_user['client'], + notification_id, + encryption.encrypt(notification)), + queue='sms') return jsonify({'notification_id': notification_id}), 201 diff --git a/app/schemas.py b/app/schemas.py index 3a9ab37e3..18007f91d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -93,13 +93,6 @@ class SmsTemplateNotificationSchema(SmsNotificationSchema): template = fields.Int(required=True) job = fields.String() - @validates('template') - def validate_template(self, value): - if not models.Template.query.filter_by(id=value).first(): - # TODO is this message consistent with what marshmallow - # would normally produce. - raise ValidationError('Template not found') - @validates_schema def validate_schema(self, data): """ diff --git a/aws_run_celery.py b/aws_run_celery.py new file mode 100644 index 000000000..814e07f4b --- /dev/null +++ b/aws_run_celery.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +from app import notify_celery, create_app +from credstash import getAllSecrets +import os + +# on aws get secrets and export to env +secrets = getAllSecrets(region="eu-west-1") +for key, val in secrets.items(): + os.environ[key] = val + +application = create_app() +application.app_context().push() diff --git a/config.py b/config.py index b92d0e9b8..241791ec3 100644 --- a/config.py +++ b/config.py @@ -20,6 +20,28 @@ class Config(object): SQLALCHEMY_RECORD_QUERIES = True VERIFY_CODE_FROM_EMAIL_ADDRESS = os.environ['VERIFY_CODE_FROM_EMAIL_ADDRESS'] + BROKER_URL = 'sqs://' + BROKER_TRANSPORT_OPTIONS = { + 'region': 'eu-west-1', + 'polling_interval': 1, # 1 second + 'visibility_timeout': 60, # 60 seconds + 'queue_name_prefix': os.environ['NOTIFICATION_QUEUE_PREFIX'] + } + CELERY_ENABLE_UTC = True, + CELERY_TIMEZONE = 'Europe/London' + CELERY_ACCEPT_CONTENT = ['json'] + CELERY_TASK_SERIALIZER = 'json' + # CELERYBEAT_SCHEDULE = { + # 'refresh-queues': { + # 'task': 'refresh-services', + # 'schedule': timedelta(seconds=5) + # } + # } + CELERY_IMPORTS = ('app.celery.tasks',) + TWILIO_ACCOUNT_SID = os.getenv('TWILIO_ACCOUNT_SID') + TWILIO_AUTH_TOKEN = os.getenv('TWILIO_AUTH_TOKEN') + TWILIO_NUMBER = os.getenv('TWILIO_NUMBER') + class Development(Config): DEBUG = True diff --git a/environment_test.sh b/environment_test.sh index 094e3ddc1..2be312597 100644 --- a/environment_test.sh +++ b/environment_test.sh @@ -10,3 +10,6 @@ export NOTIFICATION_QUEUE_PREFIX='notification_development-test' export SECRET_KEY='secret-key' export SQLALCHEMY_DATABASE_URI='postgresql://localhost/test_notification_api' export VERIFY_CODE_FROM_EMAIL_ADDRESS='no-reply@notify.works' +export TWILIO_ACCOUNT_SID="test" +export TWILIO_AUTH_TOKEN="test" +export TWILIO_NUMBER="test" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2b4789aeb..184d57249 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,9 @@ itsdangerous==0.24 Flask-Bcrypt==0.6.2 credstash==1.8.0 boto3==1.2.3 +celery==3.1.20 +twilio==4.6.0 + git+https://github.com/alphagov/notifications-python-client.git@0.2.6#egg=notifications-python-client==0.2.6 diff --git a/run_celery.py b/run_celery.py new file mode 100644 index 000000000..51ce92910 --- /dev/null +++ b/run_celery.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from app import notify_celery, create_app + +application = create_app() +application.app_context().push() diff --git a/scripts/run_celery.sh b/scripts/run_celery.sh new file mode 100755 index 000000000..8e670ea88 --- /dev/null +++ b/scripts/run_celery.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +source environment.sh +celery -A run_celery.notify_celery worker --loglevel=INFO --logfile=/var/log/notify/application.log --concurrency=4 -Q sms \ No newline at end of file diff --git a/tests/app/celery/test_tasks.py b/tests/app/celery/test_tasks.py new file mode 100644 index 000000000..c53ae2469 --- /dev/null +++ b/tests/app/celery/test_tasks.py @@ -0,0 +1,76 @@ +import uuid +import pytest +from app.celery.tasks import send_sms +from app import twilio_client +from app.clients.sms.twilio import TwilioClientException +from app.dao import notifications_dao +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm.exc import NoResultFound + + +def test_should_send_template_to_correct_sms_provider_and_persist(sample_template, mocker): + notification = { + "template": sample_template.id, + "to": "+441234123123" + } + mocker.patch('app.encryption.decrypt', return_value=notification) + mocker.patch('app.twilio_client.send_sms') + + notification_id = uuid.uuid4() + + send_sms( + sample_template.service_id, + notification_id, + "encrypted-in-reality") + + twilio_client.send_sms.assert_called_once_with("+441234123123", sample_template.content) + persisted_notification = notifications_dao.get_notification(sample_template.service_id, notification_id) + assert persisted_notification.id == notification_id + assert persisted_notification.to == '+441234123123' + assert persisted_notification.template_id == sample_template.id + assert persisted_notification.status == 'sent' + + +def test_should_persist_notification_as_failed_if_sms_client_fails(sample_template, mocker): + notification = { + "template": sample_template.id, + "to": "+441234123123" + } + mocker.patch('app.encryption.decrypt', return_value=notification) + mocker.patch('app.twilio_client.send_sms', side_effect=TwilioClientException()) + + notification_id = uuid.uuid4() + + send_sms( + sample_template.service_id, + notification_id, + "encrypted-in-reality") + + twilio_client.send_sms.assert_called_once_with("+441234123123", sample_template.content) + persisted_notification = notifications_dao.get_notification(sample_template.service_id, notification_id) + assert persisted_notification.id == notification_id + assert persisted_notification.to == '+441234123123' + assert persisted_notification.template_id == sample_template.id + assert persisted_notification.status == 'failed' + + +def test_should_not_send_sms_if_db_peristance_failed(sample_template, mocker): + notification = { + "template": sample_template.id, + "to": "+441234123123" + } + mocker.patch('app.encryption.decrypt', return_value=notification) + mocker.patch('app.twilio_client.send_sms', side_effect=TwilioClientException()) + mocker.patch('app.db.session.add', side_effect=SQLAlchemyError()) + + notification_id = uuid.uuid4() + + send_sms( + sample_template.service_id, + notification_id, + "encrypted-in-reality") + + twilio_client.send_sms.assert_not_called() + with pytest.raises(NoResultFound) as e: + notifications_dao.get_notification(sample_template.service_id, notification_id) + assert 'No row was found for one' in str(e.value) diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 26d5179a9..c3ba5c07b 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1,7 +1,6 @@ import pytest -from flask import jsonify -from app.models import (User, Service, Template, ApiKey, Job, VerifyCode, Notification) +from app.models import (User, Service, Template, ApiKey, Job, Notification) from app.dao.users_dao import (save_model_user, create_user_code, create_secret_code) from app.dao.services_dao import save_model_service from app.dao.templates_dao import save_model_template @@ -11,6 +10,18 @@ from app.dao.notifications_dao import save_notification import uuid +@pytest.fixture(scope='function') +def service_factory(notify_db, notify_db_session): + class ServiceFactory(object): + def get(self, service_name): + user = sample_user(notify_db, notify_db_session) + service = sample_service(notify_db, notify_db_session, service_name, user) + sample_template(notify_db, notify_db_session, service=service) + return service + + return ServiceFactory() + + @pytest.fixture(scope='function') def sample_user(notify_db, notify_db_session, diff --git a/tests/app/dao/test_notification_dao.py b/tests/app/dao/test_notification_dao.py index a28851520..384be5bff 100644 --- a/tests/app/dao/test_notification_dao.py +++ b/tests/app/dao/test_notification_dao.py @@ -3,7 +3,8 @@ from app.models import Notification from app.dao.notifications_dao import ( save_notification, get_notification, - get_notifications + get_notification_for_job, + get_notifications_for_job ) @@ -31,6 +32,13 @@ def test_save_notification(notify_db, notify_db_session, sample_template, sample assert 'sent' == notification_from_db.status +def test_get_notification(notify_db, notify_db_session, sample_notification): + notifcation_from_db = get_notification( + sample_notification.service.id, + sample_notification.id) + assert sample_notification == notifcation_from_db + + def test_save_notification_no_job_id(notify_db, notify_db_session, sample_template): assert Notification.query.count() == 0 @@ -54,9 +62,10 @@ def test_save_notification_no_job_id(notify_db, notify_db_session, sample_templa def test_get_notification_for_job(notify_db, notify_db_session, sample_notification): - notifcation_from_db = get_notification(sample_notification.service.id, - sample_notification.job_id, - sample_notification.id) + notifcation_from_db = get_notification_for_job( + sample_notification.service.id, + sample_notification.job_id, + sample_notification.id) assert sample_notification == notifcation_from_db @@ -70,7 +79,7 @@ def test_get_all_notifications_for_job(notify_db, notify_db_session, sample_job) template=sample_job.template, job=sample_job) - notifcations_from_db = get_notifications(sample_job.service.id, sample_job.id) + notifcations_from_db = get_notifications_for_job(sample_job.service.id, sample_job.id) assert len(notifcations_from_db) == 5 diff --git a/tests/app/notifications/test_rest.py b/tests/app/notifications/test_rest.py index 33cef53b0..d9f2d7f9c 100644 --- a/tests/app/notifications/test_rest.py +++ b/tests/app/notifications/test_rest.py @@ -1,240 +1,231 @@ -import moto import uuid - +import app.celery.tasks +import moto from tests import create_authorization_header -from flask import url_for, json +from flask import json from app.models import Service -from tests.app.conftest import sample_service as create_sample_service -from tests.app.conftest import sample_template as create_sample_template +from app.dao.templates_dao import get_model_templates -def test_get_notifications( - notify_api, notify_db, notify_db_session, sample_api_key, mocker): - """ - Tests GET endpoint '/' to retrieve entire service list. - """ +def test_get_notification_by_id(notify_api, sample_notification): with notify_api.test_request_context(): with notify_api.test_client() as client: auth_header = create_authorization_header( - service_id=sample_api_key.service_id, - path=url_for('notifications.get_notifications', notification_id=123), + service_id=sample_notification.service_id, + path='/notifications/{}'.format(sample_notification.id), method='GET') response = client.get( - url_for('notifications.get_notifications', notification_id=123), + '/notifications/{}'.format(sample_notification.id), headers=[auth_header]) + notification = json.loads(response.get_data(as_text=True))['notification'] + assert notification['status'] == 'sent' + assert notification['template'] == sample_notification.template.id + assert notification['to'] == '+44709123456' + assert notification['service'] == str(sample_notification.service_id) assert response.status_code == 200 -def test_get_notifications_empty_result( - notify_api, notify_db, notify_db_session, sample_api_key, mocker): - """ - Tests GET endpoint '/' to retrieve entire service list. - """ +def test_get_notifications_empty_result(notify_api, sample_api_key): with notify_api.test_request_context(): with notify_api.test_client() as client: + missing_notification_id = uuid.uuid4() auth_header = create_authorization_header( service_id=sample_api_key.service_id, - path=url_for('notifications.get_notifications', notification_id=123), + path='/notifications/{}'.format(missing_notification_id), method='GET') response = client.get( - url_for('notifications.get_notifications', notification_id=123), + path='/notifications/{}'.format(missing_notification_id), headers=[auth_header]) - assert response.status_code == 200 + notification = json.loads(response.get_data(as_text=True)) + assert notification['result'] == "error" + assert notification['message'] == "not found" + assert response.status_code == 404 -def test_create_sms_should_reject_if_no_phone_numbers( - notify_api, notify_db, notify_db_session, sample_api_key, mocker): - """ - Tests GET endpoint '/' to retrieve entire service list. - """ +def test_create_sms_should_reject_if_missing_required_fields(notify_api, sample_api_key, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: - data = { - 'template': "my message" - } + mocker.patch('app.celery.tasks.send_sms.apply_async') + + data = {} auth_header = create_authorization_header( service_id=sample_api_key.service_id, request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), + path='/notifications/sms', method='POST') response = client.post( - url_for('notifications.create_sms_notification'), + path='/notifications/sms', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) json_resp = json.loads(response.get_data(as_text=True)) - assert response.status_code == 400 + app.celery.tasks.send_sms.apply_async.assert_not_called() assert json_resp['result'] == 'error' assert 'Missing data for required field.' in json_resp['message']['to'][0] + assert 'Missing data for required field.' in json_resp['message']['template'][0] + assert response.status_code == 400 -def test_should_reject_bad_phone_numbers( - notify_api, notify_db, notify_db_session, mocker): - """ - Tests GET endpoint '/' to retrieve entire service list. - """ +def test_should_reject_bad_phone_numbers(notify_api, sample_template, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + data = { 'to': 'invalid', - 'template': "my message" + 'template': sample_template.id } auth_header = create_authorization_header( request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), + path='/notifications/sms', method='POST') response = client.post( - url_for('notifications.create_sms_notification'), + path='/notifications/sms', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) json_resp = json.loads(response.get_data(as_text=True)) - assert response.status_code == 400 + app.celery.tasks.send_sms.apply_async.assert_not_called() + assert json_resp['result'] == 'error' + assert len(json_resp['message'].keys()) == 1 assert 'Invalid phone number, must be of format +441234123123' in json_resp['message']['to'] + assert response.status_code == 400 -def test_send_notification_restrict_mobile(notify_api, - notify_db, - notify_db_session, - sample_api_key, - sample_template, - sample_user, - mocker): - """ - Test POST endpoint '/sms' with service notification with mobile number - not in restricted list. - """ +def test_send_notification_invalid_template_id(notify_api, sample_template, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + + data = { + 'to': '+441234123123', + 'template': 9999 + } + auth_header = create_authorization_header( + service_id=sample_template.service.id, + request_body=json.dumps(data), + path='/notifications/sms', + method='POST') + + response = client.post( + path='/notifications/sms', + data=json.dumps(data), + headers=[('Content-Type', 'application/json'), auth_header]) + + json_resp = json.loads(response.get_data(as_text=True)) + app.celery.tasks.send_sms.apply_async.assert_not_called() + + assert response.status_code == 400 + assert len(json_resp['message'].keys()) == 1 + assert 'Template not found' in json_resp['message']['template'] + + +def test_prevents_sending_to_any_mobile_on_restricted_service(notify_api, sample_template, mocker): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + Service.query.filter_by( - id=sample_template.service.id).update({'restricted': True}) + id=sample_template.service.id + ).update( + {'restricted': True} + ) invalid_mob = '+449999999999' data = { 'to': invalid_mob, 'template': sample_template.id } - assert invalid_mob != sample_user.mobile_number + auth_header = create_authorization_header( service_id=sample_template.service.id, request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), + path='/notifications/sms', method='POST') response = client.post( - url_for('notifications.create_sms_notification'), + path='/notifications/sms', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) json_resp = json.loads(response.get_data(as_text=True)) + app.celery.tasks.send_sms.apply_async.assert_not_called() + assert response.status_code == 400 assert 'Invalid phone number for restricted service' in json_resp['message']['restricted'] -def test_send_notification_invalid_template_id(notify_api, - notify_db, - notify_db_session, - sample_api_key, - sample_template, - sample_user, - mocker): - """ - Tests POST endpoint '/sms' with notifications-admin notification with invalid template id - """ +def test_should_not_allow_template_from_another_service(notify_api, service_factory, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') - Service.query.filter_by( - id=sample_template.service.id).update({'restricted': True}) - invalid_mob = '+449999999999' - data = { - 'to': invalid_mob, - 'template': 9999 - } - assert invalid_mob != sample_user.mobile_number - auth_header = create_authorization_header( - service_id=sample_template.service.id, - request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), - method='POST') + service_1 = service_factory.get('service 1') + service_2 = service_factory.get('service 2') - response = client.post( - url_for('notifications.create_sms_notification'), - data=json.dumps(data), - headers=[('Content-Type', 'application/json'), auth_header]) + service_2_templates = get_model_templates(service_id=service_2.id) - json_resp = json.loads(response.get_data(as_text=True)) - assert response.status_code == 400 - assert 'Template not found' in json_resp['message']['template'] - - -@moto.mock_sqs -def test_should_not_allow_template_from_other_service(notify_api, - notify_db, - notify_db_session, - sample_template, - sample_admin_service_id, - mocker): - """ - Tests POST endpoint '/sms' with notifications. - """ - with notify_api.test_request_context(): - with notify_api.test_client() as client: data = { 'to': '+441234123123', - 'template': sample_template.id + 'template': service_2_templates[0].id } + auth_header = create_authorization_header( - service_id=sample_admin_service_id, + service_id=service_1.id, request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), + path='/notifications/sms', method='POST') response = client.post( - url_for('notifications.create_sms_notification'), + path='/notifications/sms', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) json_resp = json.loads(response.get_data(as_text=True)) + app.celery.tasks.send_sms.apply_async.assert_not_called() + assert response.status_code == 400 assert 'Invalid template' in json_resp['message']['restricted'] -@moto.mock_sqs -def test_should_allow_valid_message(notify_api, - notify_db, - notify_db_session, - sqs_client_conn, - sample_user, - sample_template, - mocker): - """ - Tests POST endpoint '/sms' with notifications-admin notification. - """ +def test_should_allow_valid_sms_notification(notify_api, sample_template, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + mocker.patch('app.encryption.encrypt', return_value="something_encrypted") + data = { 'to': '+441234123123', 'template': sample_template.id } + auth_header = create_authorization_header( request_body=json.dumps(data), - path=url_for('notifications.create_sms_notification'), - method='POST') + path='/notifications/sms', + method='POST', + service_id=sample_template.service_id + ) response = client.post( - url_for('notifications.create_sms_notification'), + path='/notifications/sms', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) + notification_id = json.loads(response.data)['notification_id'] + app.celery.tasks.send_sms.apply_async.assert_called_once_with( + (str(sample_template.service_id), + notification_id, + "something_encrypted"), + queue="sms" + ) assert response.status_code == 201 - assert json.loads(response.data)['notification_id'] is not None + assert notification_id @moto.mock_sqs @@ -259,11 +250,11 @@ def test_send_email_valid_data(notify_api, } auth_header = create_authorization_header( request_body=json.dumps(data), - path=url_for('notifications.create_email_notification'), + path='/notifications/email', method='POST') response = client.post( - url_for('notifications.create_email_notification'), + path='/notifications/email', data=json.dumps(data), headers=[('Content-Type', 'application/json'), auth_header]) @@ -283,7 +274,7 @@ def test_valid_message_with_service_id(notify_api, with notify_api.test_client() as client: job_id = uuid.uuid4() service_id = sample_template.service.id - url = url_for('notifications.create_sms_for_service', service_id=service_id) + url = '/notifications/sms/service/{}'.format(service_id) data = { 'to': '+441234123123', 'template': sample_template.id, @@ -316,7 +307,7 @@ def test_message_with_incorrect_service_id_should_fail(notify_api, job_id = uuid.uuid4() invalid_service_id = uuid.uuid4() - url = url_for('notifications.create_sms_for_service', service_id=invalid_service_id) + url = '/notifications/sms/service/{}'.format(invalid_service_id) data = { 'to': '+441234123123', diff --git a/tests/app/test_encryption.py b/tests/app/test_encryption.py new file mode 100644 index 000000000..d3566b202 --- /dev/null +++ b/tests/app/test_encryption.py @@ -0,0 +1,20 @@ +from app.encryption import Encryption + +encryption = Encryption() + + +def test_should_encrypt_content(notify_api): + encryption.init_app(notify_api) + assert encryption.encrypt("this") != "this" + + +def test_should_decrypt_content(notify_api): + encryption.init_app(notify_api) + encrypted = encryption.encrypt("this") + assert encryption.decrypt(encrypted) == "this" + + +def test_should_encrypt_json(notify_api): + encryption.init_app(notify_api) + encrypted = encryption.encrypt({"this": "that"}) + assert encryption.decrypt(encrypted) == {"this": "that"} diff --git a/tests/conftest.py b/tests/conftest.py index 795a791df..53c318deb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,13 @@ def notify_db_session(request): request.addfinalizer(teardown) +@pytest.fixture(scope='function') +def notify_config(notify_api): + notify_api.config['NOTIFY_API_ENVIRONMENT'] = 'test' + notify_api.config.from_object(configs['test']) + return notify_api.config + + @pytest.fixture(scope='function') def os_environ(request): env_patch = mock.patch('os.environ', {})