diff --git a/app/__init__.py b/app/__init__.py index efa1a6697..e30655832 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,6 +23,7 @@ from werkzeug.local import LocalProxy from app.celery.celery import NotifyCelery from app.clients import Clients +from app.clients.cbc_proxy import CBCProxyClient, CBCProxyNoopClient from app.clients.document_download import DocumentDownloadClient from app.clients.email.aws_ses import AwsSesClient from app.clients.email.aws_ses_stub import AwsSesStubClient @@ -60,6 +61,7 @@ zendesk_client = ZendeskClient() statsd_client = StatsdClient() redis_store = RedisClient() performance_platform_client = PerformancePlatformClient() +cbc_proxy_client = CBCProxyNoopClient() document_download_client = DocumentDownloadClient() metrics = GDSMetrics() @@ -112,6 +114,11 @@ def create_app(application): performance_platform_client.init_app(application) document_download_client.init_app(application) + global cbc_proxy_client + if application.config['CBC_PROXY_AWS_ACCESS_KEY_ID']: + cbc_proxy_client = CBCProxyClient() + cbc_proxy_client.init_app(application) + register_blueprint(application) register_v2_blueprints(application) diff --git a/app/celery/broadcast_message_tasks.py b/app/celery/broadcast_message_tasks.py index f73705f5a..9a02e2096 100644 --- a/app/celery/broadcast_message_tasks.py +++ b/app/celery/broadcast_message_tasks.py @@ -2,8 +2,9 @@ import requests from flask import current_app from notifications_utils.statsd_decorators import statsd -from app import notify_celery +from app import cbc_proxy_client, notify_celery +from app.models import BroadcastEventMessageType from app.dao.broadcast_message_dao import dao_get_broadcast_event_by_id @@ -12,6 +13,19 @@ from app.dao.broadcast_message_dao import dao_get_broadcast_event_by_id def send_broadcast_event(broadcast_event_id, provider='stub-1'): broadcast_event = dao_get_broadcast_event_by_id(broadcast_event_id) + if broadcast_event.message_type == BroadcastEventMessageType.ALERT: + current_app.logger.info( + f'invoking cbc proxy to send ' + f'broadcast_event {broadcast_event.reference} ' + f'msgType {broadcast_event.message_type} to {provider}' + ) + + cbc_proxy_client.create_and_send_broadcast( + identifier=str(broadcast_event.id), + headline="GOV.UK Notify Broadcast", + description=broadcast_event.transmitted_content['body'], + ) + current_app.logger.info( f'sending broadcast_event {broadcast_event.reference} ' f'msgType {broadcast_event.message_type} to {provider}' diff --git a/app/clients/cbc_proxy.py b/app/clients/cbc_proxy.py new file mode 100644 index 000000000..73db662ba --- /dev/null +++ b/app/clients/cbc_proxy.py @@ -0,0 +1,90 @@ +import json + +import boto3 + +# The variable names in this file have specific meaning in a CAP message +# +# identifier is a unique field for each CAP message +# +# headline is a field which we are not sure if we will use +# +# description is the body of the message +# +# references is a whitespace separated list of message identifiers +# where each identifier is a previous sent message +# ie a Cancel message would have a unique identifier but have the identifier of +# the preceeding Alert message in the references field + + +# Noop = no operation +class CBCProxyNoopClient: + + def init_app(self, app): + pass + + def create_and_send_broadcast( + self, + identifier, headline, description, + ): + pass + + # We have not implementated updating a broadcast + def update_and_send_broadcast( + self, + identifier, references, headline, description, + ): + pass + + # We have not implemented cancelling a broadcast + def cancel_broadcast( + self, + identifier, references, headline, description, + ): + pass + + +class CBCProxyClient: + + def init_app(self, app): + self._lambda_client = boto3.client( + 'lambda', + region_name='eu-west-2', + aws_access_key_id=app.config['CBC_PROXY_AWS_ACCESS_KEY_ID'], + aws_secret_access_key=app.config['CBC_PROXY_AWS_SECRET_ACCESS_KEY'], + ) + + def create_and_send_broadcast( + self, + identifier, headline, description, + ): + payload_bytes = bytes(json.dumps({ + 'identifier': identifier, + 'headline': headline, + 'description': description, + }), encoding='utf8') + + result = self._lambda_client.invoke( + FunctionName='bt-ee-1-proxy', + InvocationType='RequestResponse', + Payload=payload_bytes, + ) + + if result['StatusCode'] > 299: + raise Exception('Could not invoke lambda') + + if 'FunctionError' in result: + raise Exception('Function exited with unhandled exception') + + # We have not implementated updating a broadcast + def update_and_send_broadcast( + self, + identifier, references, headline, description, + ): + pass + + # We have not implemented cancelling a broadcast + def cancel_broadcast( + self, + identifier, references, headline, description, + ): + pass diff --git a/app/config.py b/app/config.py index 7b9981e52..fedcc811e 100644 --- a/app/config.py +++ b/app/config.py @@ -353,6 +353,11 @@ class Config(object): AWS_REGION = 'eu-west-1' + # CBC Proxy + # if the access keys are empty then noop client is used + CBC_PROXY_AWS_ACCESS_KEY_ID = os.environ.get('CBC_PROXY_AWS_ACCESS_KEY_ID', '') + CBC_PROXY_AWS_SECRET_ACCESS_KEY = os.environ.get('CBC_PROXY_AWS_SECRET_ACCESS_KEY', '') + ###################### # Config overrides ### diff --git a/manifest.yml.j2 b/manifest.yml.j2 index f23ec08cd..8085644cd 100644 --- a/manifest.yml.j2 +++ b/manifest.yml.j2 @@ -125,6 +125,11 @@ applications: AWS_ACCESS_KEY_ID: '{{ AWS_ACCESS_KEY_ID }}' AWS_SECRET_ACCESS_KEY: '{{ AWS_SECRET_ACCESS_KEY }}' + {% if CBC_PROXY_AWS_ACCESS_KEY_ID is defined %} + CBC_PROXY_AWS_ACCESS_KEY_ID: '{{ CBC_PROXY_AWS_ACCESS_KEY_ID }}' + CBC_PROXY_AWS_SECRET_ACCESS_KEY: '{{ CBC_PROXY_AWS_SECRET_ACCESS_KEY }}' + {% endif %} + STATSD_HOST: "notify-statsd-exporter-{{ environment }}.apps.internal" ZENDESK_API_KEY: '{{ ZENDESK_API_KEY }}' diff --git a/tests/app/celery/test_broadcast_message_tasks.py b/tests/app/celery/test_broadcast_message_tasks.py index 76d5e2001..c1830ce5c 100644 --- a/tests/app/celery/test_broadcast_message_tasks.py +++ b/tests/app/celery/test_broadcast_message_tasks.py @@ -9,7 +9,7 @@ from tests.app.db import create_template, create_broadcast_message, create_broad @freeze_time('2020-08-01 12:00') -def test_send_broadcast_event_sends_data_correctly(sample_service): +def test_send_broadcast_event_sends_data_correctly(mocker, sample_service): template = create_template(sample_service, BROADCAST_TYPE) broadcast_message = create_broadcast_message( template, @@ -18,10 +18,20 @@ def test_send_broadcast_event_sends_data_correctly(sample_service): ) event = create_broadcast_event(broadcast_message) + mock_create_broadcast = mocker.patch( + 'app.cbc_proxy_client.create_and_send_broadcast', + ) + with requests_mock.Mocker() as request_mock: request_mock.post("http://test-cbc-proxy/broadcasts/events/stub-1", json={'valid': 'true'}, status_code=200) send_broadcast_event(broadcast_event_id=str(event.id)) + mock_create_broadcast.assert_called_once_with( + identifier=str(event.id), + headline="GOV.UK Notify Broadcast", + description='this is an emergency broadcast message', + ) + assert request_mock.call_count == 1 assert request_mock.request_history[0].method == 'POST' assert request_mock.request_history[0].headers["Content-type"] == "application/json" @@ -36,16 +46,22 @@ def test_send_broadcast_event_sends_data_correctly(sample_service): } -def test_send_broadcast_event_sends_references(sample_service): +def test_send_broadcast_event_sends_references(mocker, sample_service): template = create_template(sample_service, BROADCAST_TYPE, content='content') broadcast_message = create_broadcast_message(template, areas=['london'], status=BroadcastStatusType.BROADCASTING) alert_event = create_broadcast_event(broadcast_message, message_type=BroadcastEventMessageType.ALERT) cancel_event = create_broadcast_event(broadcast_message, message_type=BroadcastEventMessageType.CANCEL) + mock_create_broadcast = mocker.patch( + 'app.cbc_proxy_client.create_and_send_broadcast', + ) + with requests_mock.Mocker() as request_mock: request_mock.post("http://test-cbc-proxy/broadcasts/events/stub-1", json={'valid': 'true'}, status_code=200) send_broadcast_event(broadcast_event_id=str(cancel_event.id)) + assert not mock_create_broadcast.called + assert request_mock.call_count == 1 assert request_mock.request_history[0].method == 'POST' assert request_mock.request_history[0].headers["Content-type"] == "application/json" @@ -56,11 +72,15 @@ def test_send_broadcast_event_sends_references(sample_service): assert cbc_json['previous_event_references'] == [alert_event.reference] -def test_send_broadcast_event_errors(sample_service): +def test_send_broadcast_event_errors(mocker, sample_service): template = create_template(sample_service, BROADCAST_TYPE) broadcast_message = create_broadcast_message(template, status=BroadcastStatusType.BROADCASTING) event = create_broadcast_event(broadcast_message) + mock_create_broadcast = mocker.patch( + 'app.cbc_proxy_client.create_and_send_broadcast', + ) + with requests_mock.Mocker() as request_mock: request_mock.post("http://test-cbc-proxy/broadcasts/events/stub-1", text='503 bad gateway', status_code=503) # we're not retrying or anything for the moment - but this'll ensure any exception gets logged @@ -68,3 +88,9 @@ def test_send_broadcast_event_errors(sample_service): send_broadcast_event(broadcast_event_id=str(event.id)) assert ex.value.response.status_code == 503 + + mock_create_broadcast.assert_called_once_with( + identifier=str(event.id), + headline="GOV.UK Notify Broadcast", + description='this is an emergency broadcast message', + ) diff --git a/tests/app/clients/test_cbc_proxy.py b/tests/app/clients/test_cbc_proxy.py new file mode 100644 index 000000000..606231f62 --- /dev/null +++ b/tests/app/clients/test_cbc_proxy.py @@ -0,0 +1,127 @@ +import json + +import pytest + +from app.clients.cbc_proxy import CBCProxyClient + + +@pytest.fixture(scope='function') +def cbc_proxy(client, mocker): + client = CBCProxyClient() + current_app = mocker.Mock(config={ + 'CBC_PROXY_AWS_ACCESS_KEY_ID': 'cbc-proxy-aws-access-key-id', + 'CBC_PROXY_AWS_SECRET_ACCESS_KEY': 'cbc-proxy-aws-secret-access-key', + }) + client.init_app(current_app) + return client + + +def test_cbc_proxy_lambda_client_has_correct_region(cbc_proxy): + assert cbc_proxy._lambda_client._client_config.region_name == 'eu-west-2' + + +def test_cbc_proxy_lambda_client_has_correct_keys(cbc_proxy): + key = cbc_proxy._lambda_client._request_signer._credentials.access_key + secret = cbc_proxy._lambda_client._request_signer._credentials.secret_key + + assert key == 'cbc-proxy-aws-access-key-id' + assert secret == 'cbc-proxy-aws-secret-access-key' + + +def test_cbc_proxy_create_and_send_invokes_function(mocker, cbc_proxy): + identifier = 'my-identifier' + headline = 'my-headline' + description = 'my-description' + + ld_client_mock = mocker.patch.object( + cbc_proxy, + '_lambda_client', + create=True, + ) + + ld_client_mock.invoke.return_value = { + 'StatusCode': 200, + } + + cbc_proxy.create_and_send_broadcast( + identifier=identifier, + headline=headline, + description=description, + ) + + ld_client_mock.invoke.assert_called_once_with( + FunctionName='bt-ee-1-proxy', + InvocationType='RequestResponse', + Payload=mocker.ANY, + ) + + kwargs = ld_client_mock.invoke.mock_calls[0][-1] + payload_bytes = kwargs['Payload'] + payload = json.loads(payload_bytes) + + assert payload['identifier'] == identifier + assert payload['headline'] == headline + assert payload['description'] == description + + +def test_cbc_proxy_create_and_send_handles_invoke_error(mocker, cbc_proxy): + identifier = 'my-identifier' + headline = 'my-headline' + description = 'my-description' + + ld_client_mock = mocker.patch.object( + cbc_proxy, + '_lambda_client', + create=True, + ) + + ld_client_mock.invoke.return_value = { + 'StatusCode': 400, + } + + with pytest.raises(Exception) as e: + cbc_proxy.create_and_send_broadcast( + identifier=identifier, + headline=headline, + description=description, + ) + + assert e.match('Could not invoke lambda') + + ld_client_mock.invoke.assert_called_once_with( + FunctionName='bt-ee-1-proxy', + InvocationType='RequestResponse', + Payload=mocker.ANY, + ) + + +def test_cbc_proxy_create_and_send_handles_function_error(mocker, cbc_proxy): + identifier = 'my-identifier' + headline = 'my-headline' + description = 'my-description' + + ld_client_mock = mocker.patch.object( + cbc_proxy, + '_lambda_client', + create=True, + ) + + ld_client_mock.invoke.return_value = { + 'StatusCode': 200, + 'FunctionError': 'something', + } + + with pytest.raises(Exception) as e: + cbc_proxy.create_and_send_broadcast( + identifier=identifier, + headline=headline, + description=description, + ) + + assert e.match('Function exited with unhandled exception') + + ld_client_mock.invoke.assert_called_once_with( + FunctionName='bt-ee-1-proxy', + InvocationType='RequestResponse', + Payload=mocker.ANY, + )