Skip to content

Commit

Permalink
Merge branch 'main' into 1979-enrollment-confirmation-sms-notification
Browse files Browse the repository at this point in the history
  • Loading branch information
MackHalliday authored Oct 23, 2024
2 parents ca8fa9c + d73d442 commit 842ee32
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 19 deletions.
2 changes: 2 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ fileignoreconfig:
checksum: d149822d65e28a9488c04602528eb6e4f148c0b508f95c95460fbe4b4531d73c
- filename: app/va/va_profile/va_profile_client.py
checksum: fe634f26f7dc3874f4afcfd1ba3f03bae380b53befe973a752c7347097a88701
- filename: tests/lambda_functions/vetext_incoming_forwarder_lambda/test_vetext_incoming_forwarder_lambda.py
checksum: 7494eb4321fd2fbc3ff3915d8753d8fec7a936a69dc6ab78f0b532a701f032eb
129 changes: 111 additions & 18 deletions app/clients/sms/twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from urllib.parse import parse_qs

from twilio.rest import Client
from twilio.rest.api.v2010.account.message import MessageInstance
from twilio.base.exceptions import TwilioRestException

from app.clients.sms import SmsClient, SmsStatusRecord

from app.exceptions import InvalidProviderException


TWILIO_RESPONSE_MAP = {
'accepted': 'created',
'queued': 'sending',
Expand Down Expand Up @@ -125,6 +128,23 @@ def name(self):
def get_name(self):
return self.name

def get_twilio_message(self, message_sid: str) -> MessageInstance | None:
"""
Fetches a Twilio message by its message sid.
Args:
message_sid (str): the Twilio message id
Returns:
MessageInstance: the Twilio message instance if found, otherwise None
"""
message = None
try:
message = self._client.messages(message_sid).fetch()
except TwilioRestException:
self.logger.warning('Twilio message not found: %s', message_sid)
return message

def send_sms(
self,
to,
Expand Down Expand Up @@ -232,22 +252,104 @@ def translate_delivery_status(
self.logger.info('Translating Twilio delivery status')
self.logger.debug(twilio_delivery_status_message)

decoded_msg, parsed_dict = self._parse_twilio_message(twilio_delivery_status_message)

message_sid = parsed_dict['MessageSid'][0]
twilio_delivery_status = parsed_dict['MessageStatus'][0]
error_code = parsed_dict.get('ErrorCode', [])

status, status_reason = self._evaluate_status(message_sid, twilio_delivery_status, error_code)

status = SmsStatusRecord(
decoded_msg,
message_sid,
status,
status_reason,
)

self.logger.debug('Twilio delivery status translation: %s', status)

return status

def update_notification_status_override(self, message_sid: str) -> None:
"""
Updates the status of the notification based on the Twilio message status, bypassing any logic.
Args:
message_sid (str): the Twilio message id
Returns:
None
"""
# Importing inline to resolve a circular import error when importing at the top of the file
from app.dao.notifications_dao import dao_update_notifications_by_reference

self.logger.info('Updating notification status for message: %s', message_sid)

message = self.get_twilio_message(message_sid)
self.logger.debug('Twilio message: %s', message)

if message:
status, status_reason = self._evaluate_status(message_sid, message.status, [])
update_dict = {
'status': status,
'status_reason': status_reason,
}
updated_count, updated_history_count = dao_update_notifications_by_reference(
[
message_sid,
],
update_dict,
)
self.logger.info(
'Updated notification status for message: %s. Updated %s notifications and %s notification history',
message_sid,
updated_count,
updated_history_count,
)

def _parse_twilio_message(self, twilio_delivery_status_message: MessageInstance) -> tuple[str, dict]:
"""
Parses the base64 encoded delivery status message from Twilio and returns a dictionary.
Args:
twilio_delivery_status_message (str): the base64 encoded Twilio delivery status message
Returns:
tuple: a tuple containing the decoded message and a dictionary of the parsed message
Raises:
ValueError: if the Twilio delivery status message is empty
"""
if not twilio_delivery_status_message:
raise ValueError('Twilio delivery status message is empty')

decoded_msg = base64.b64decode(twilio_delivery_status_message).decode()

parsed_dict = parse_qs(decoded_msg)
message_sid = parsed_dict['MessageSid'][0]
twilio_delivery_status = parsed_dict['MessageStatus'][0]
error_code_data = parsed_dict.get('ErrorCode', None)

return decoded_msg, parsed_dict

def _evaluate_status(self, message_sid: str, twilio_delivery_status: str, error_codes: list) -> tuple[str, str]:
"""
Evaluates the Twilio delivery status and error codes to determine the notification status.
Args:
message_sid (str): the Twilio message id
twilio_delivery_status (str): the Twilio message status
error_codes (list): the Twilio error codes
Returns:
tuple: a tuple containing the notification status and status reason
Raises:
ValueError: if the Twilio delivery status is invalid
"""
if twilio_delivery_status not in self.twilio_notify_status_map:
value_error = f'Invalid Twilio delivery status: {twilio_delivery_status}'
raise ValueError(value_error)

if error_code_data and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'):
error_code = error_code_data[0]
if error_codes and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'):
error_code = error_codes[0]

if error_code in self.twilio_error_code_map:
notify_delivery_status: TwilioStatus = self.twilio_error_code_map[error_code]
Expand All @@ -258,21 +360,12 @@ def translate_delivery_status(
notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status]
else:
# Logic not being changed, just want to log this for now
if error_code_data:
if error_codes:
self.logger.warning(
'Error code: %s existed but status for message: %s was not failed nor undelivered',
error_code_data[0],
error_codes[0],
message_sid,
)
notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status]

status = SmsStatusRecord(
decoded_msg,
message_sid,
notify_delivery_status.status,
notify_delivery_status.status_reason,
)

self.logger.debug('Twilio delivery status translation: %s', status)

return status
return notify_delivery_status.status, notify_delivery_status.status_reason
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""This module is used to transfer incoming twilio requests to a Vetext endpoint."""

import base64
from copy import deepcopy
from cryptography.fernet import Fernet, MultiFernet
import json
import logging
Expand Down Expand Up @@ -180,6 +181,12 @@ def vetext_incoming_forwarder_lambda_handler(
for event_body in event_bodies:
logger.debug('Processing event_body: %s', event_body)
logger.info('Processing MessageSid: %s', event_body.get('MessageSid'))
# We cannot currently handle audio, images, etc. Only forward if it has a Body field
if not event_body.get('Body'):
redacted_event = deepcopy(event_body)
redacted_event['From'] = 'redacted'
logger.warning('Event was missing a body: %s', redacted_event)
continue
response = make_vetext_request(event_body)

if response is None:
Expand Down
69 changes: 69 additions & 0 deletions tests/app/clients/test_twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,3 +687,72 @@ def test_send_sms_raises_invalid_provider_error_with_invalid_twilio_number(
)

twilio_sms_client.send_sms(to, content, reference)


def test_get_twilio_message(
notify_api,
mocker,
):
twilio_sid = 'test_sid'
response_dict = make_twilio_message_response_dict()
response_dict['sid'] = twilio_sid

with requests_mock.Mocker() as r_mock:
r_mock.get(
f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json',
json=response_dict,
status_code=200,
)

response = twilio_sms_client.get_twilio_message(twilio_sid)

assert response.sid == twilio_sid
assert response.status == 'sent'


def test_update_notification_status_override(
notify_api,
mocker,
sample_notification,
notify_db_session,
):
response_dict = make_twilio_message_response_dict()
response_dict['sid'] = 'test_sid'
response_dict['status'] = 'delivered'
twilio_sid = response_dict['sid']

notification = sample_notification(status='sending', reference=twilio_sid)

with requests_mock.Mocker() as r_mock:
r_mock.get(
f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json',
json=response_dict,
status_code=200,
)

twilio_sms_client.update_notification_status_override(twilio_sid)

# Retrieve the updated notification
notify_db_session.session.refresh(notification)
assert notification.status == 'delivered'


def test_update_notification_with_unknown_sid(
notify_api,
mocker,
sample_notification,
notify_db_session,
):
twilio_sid = 'test_sid'

notification = sample_notification(status='sending', reference=twilio_sid)

# Mock the call to get_twilio_message
mocker.patch('app.clients')
mocker.patch('app.clients.sms.twilio.TwilioSMSClient.get_twilio_message', side_effect=TwilioRestException)

twilio_sms_client.update_notification_status_override(twilio_sid)

# Retrieve the updated notification
notify_db_session.session.refresh(notification)
assert notification.status == 'sending'
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"""

from copy import deepcopy
from botocore.config import Config
import pytest
import json
import base64
Expand Down Expand Up @@ -72,6 +71,33 @@
'body': 'QWNjb3VudFNpZD1BQ2M5OTZkM2I1YzIzODQ0NmRhMzFhZGMyZDIwNWY3YTE5JkFkZE9ucz0lN0IlMjJzdGF0dXMlMjIlM0ElMjJzdWNjZXNzZnVsJTIyJTJDJTIybWVzc2FnZSUyMiUzQW51bGwlMkMlMjJjb2RlJTIyJTNBbnVsbCUyQyUyMnJlc3VsdHMlMjIlM0ElN0IlN0QlN0QmQXBpVmVyc2lvbj0yMDEwLTA0LTAxJkJvZHk9dGVzdCtib2R5KzZhNTQ2M2NhLWM1OWEtNDVjMy05ZmMwLWRmMWFkMTk4ZjBkOCZGcm9tPSUyQjE4ODg4ODg4ODg4JkZyb21DaXR5PUxPUytBTkdFTEVTJkZyb21Db3VudHJ5PVVTJkZyb21TdGF0ZT1DQSZGcm9tWmlwPTEyMzQ1Jk1lc3NhZ2VTaWQ9U00zYWEwZGEzOWFjZTI0NGY5OTkzZGUwNTIyMTY1YTY1NSZNZXNzYWdpbmdTZXJ2aWNlU2lkPU1HMWRkMmQyYjM1YjU5NDZmMDg2ZmM1NTZkZTYwZDRlODcmTnVtTWVkaWE9MCZOdW1TZWdtZW50cz0xJlNtc01lc3NhZ2VTaWQ9U00zYWEwZGEzOWFjZTI0NGY5OTkzZGUwNTIyMTY1YTY1NSZTbXNTaWQ9U00zYWEwZGEzOWFjZTI0NGY5OTkzZGUwNTIyMTY1YTY1NSZTbXNTdGF0dXM9cmVjZWl2ZWQmVG89JTJCMTIzNDU2Nzg5MDEmVG9DaXR5PVBST1ZJREVOQ0UmVG9Db3VudHJ5PVVTJlRvU3RhdGU9UkkmVG9aaXA9MDI5MDE=',
}

alb_invoke_without_message_body = {
'requestContext': {'elb': {'targetGroupArn': ''}},
'httpMethod': 'POST',
'path': '/twoway/vettext',
'queryStringParameters': {},
'headers': {
'accept': '*/*',
'connection': 'close',
'content-length': '552',
'content-type': 'application/x-www-form-urlencoded',
'host': 'staging-api.va.gov',
'i-twilio-idempotency-token': '09f6d617-b893-4864-8f42-24a36ec48691',
'user-agent': 'TwilioProxy/1.1',
'x-amzn-trace-id': '',
'x-forwarded-for': '',
'x-forwarded-host': 'api.va.gov:443',
'x-forwarded-port': '443',
'x-forwarded-proto': 'https',
'x-forwarded-scheme': 'https',
'x-home-region': 'us1',
'x-real-ip': '',
'x-twilio-signature': 'BdVa7Am7J6kWWRglWhynjK0L9hk=',
'x-use-static-proxy': 'true',
},
'body': 'QWNjb3VudFNpZD1BQ2M0MmI5MjA1NjQzMTRlOTRhMDNmOTQzY2Y4ZjZhYjc0JkFkZE9ucz0lN0IlMjJzdGF0dXMlMjIlM0ElMjJzdWNjZXNzZnVsJTIyJTJDJTIybWVzc2FnZSUyMiUzQW51bGwlMkMlMjJjb2RlJTIyJTNBbnVsbCUyQyUyMnJlc3VsdHMlMjIlM0ElN0IlN0QlN0QmQXBpVmVyc2lvbj0yMDEwLTA0LTAxJkJvZHk9JkZyb209JTJCMTg4ODg4ODg4ODgmRnJvbUNpdHk9TE9TK0FOR0VMRVMmRnJvbUNvdW50cnk9VVMmRnJvbVN0YXRlPUNBJkZyb21aaXA9MTIzNDUmTWVzc2FnZVNpZD1TTTU1MTIxNDMxMTgyNjQ2ODc4ZTM0N2YzODFkYWJlNTE3Jk1lc3NhZ2luZ1NlcnZpY2VTaWQ9TUc2YTg1YmFlYjkwMDI0MTQzYTlmZDk5ZjE5ZDVhZWZmZCZOdW1NZWRpYT0wJk51bVNlZ21lbnRzPTEmU21zTWVzc2FnZVNpZD1TTTU1MTIxNDMxMTgyNjQ2ODc4ZTM0N2YzODFkYWJlNTE3JlNtc1NpZD1TTTU1MTIxNDMxMTgyNjQ2ODc4ZTM0N2YzODFkYWJlNTE3JlNtc1N0YXR1cz1yZWNlaXZlZCZUbz0lMkIxMjM0NTY3ODkwMSZUb0NpdHk9UFJPVklERU5DRSZUb0NvdW50cnk9VVMmVG9TdGF0ZT1SSSZUb1ppcD0wMjkwMQ==',
}

sqsInvokedWithAddOn = {
'Records': [
{
Expand Down Expand Up @@ -514,6 +540,35 @@ def test_ut_validate_twilio_event_returns_false(all_path_env_param_set, event):
assert not validate_twilio_event(new_event)


def test_missing_message_body(all_path_env_param_set, mocker):
"""Test that messaages coming from Twilio without a message body are not sent anywhere
Args:
all_path_env_param_set (_type_): Set environmental variables
"""
from lambda_functions.vetext_incoming_forwarder_lambda.vetext_incoming_forwarder_lambda import (
vetext_incoming_forwarder_lambda_handler,
process_body_from_alb_invocation,
)

# Mocks
mocker.patch(f'{LAMBDA_MODULE}.validate_twilio_event', return_value=True)
mock_make_vetext_request = mocker.patch(f'{LAMBDA_MODULE}.make_vetext_request')
mock_logger = mocker.patch(f'{LAMBDA_MODULE}.logger')

# Expected behavior
event = deepcopy(alb_invoke_without_message_body)
event_body = process_body_from_alb_invocation(event)[0] # First and only item in the list
redacted_body = event_body.copy()
redacted_body['From'] = 'redacted'
response = vetext_incoming_forwarder_lambda_handler(event, True)

# Validation
mock_make_vetext_request.assert_not_called()
mock_logger.warning.assert_called_once_with('Event was missing a body: %s', redacted_body)
assert response['statusCode'] == 200


@pytest.mark.parametrize(
'fernet',
[
Expand Down

0 comments on commit 842ee32

Please sign in to comment.