diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 328816279b..19b03edfa9 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -20,7 +20,7 @@ services:
- db
db:
- image: postgres:11.20-bullseye@sha256:98fac4e8dc6fb58a75f2be563e876842f53db5baadb0d98abdd3205a20f6e6eb
+ image: postgres:11.22-bullseye@sha256:c886a3236b3d11abc302e64309186c90a69b49e53ccff23fd8c8b057b5b4bce9
volumes:
- ./initdb:/docker-entrypoint-initdb.d
restart: always
@@ -38,7 +38,7 @@ services:
- "5432:5432"
redis:
- image: redis:6.2@sha256:9e75c88539241ad7f61bc9c39ea4913b354064b8a75ca5fc40e1cef41b645bc0
+ image: redis:6.2@sha256:d4948d011cc38e94f0aafb8f9a60309bd93034e07d10e0767af534512cf012a9
restart: always
command: redis-server --port 6380
ports:
diff --git a/.devcontainer/scripts/notify-dev-entrypoint.sh b/.devcontainer/scripts/notify-dev-entrypoint.sh
index e2f99ea29a..a2d1fa10de 100755
--- a/.devcontainer/scripts/notify-dev-entrypoint.sh
+++ b/.devcontainer/scripts/notify-dev-entrypoint.sh
@@ -25,6 +25,7 @@ echo -e "complete -F __start_kubectl k" >> ~/.zshrc
# Smoke test
# requires adding files .env_staging and .env_prod to the root of the project
+echo -e "alias smoke-local='cd /workspace && cp .env_smoke_local tests_smoke/.env && poetry run make smoke-test-local'" >> ~/.zshrc
echo -e "alias smoke-staging='cd /workspace && cp .env_smoke_staging tests_smoke/.env && poetry run make smoke-test'" >> ~/.zshrc
echo -e "alias smoke-prod='cd /workspace && cp .env_smoke_prod tests_smoke/.env && poetry run make smoke-test'" >> ~/.zshrc
diff --git a/.env.example b/.env.example
index 8e60a9b5ae..cb36eefda5 100644
--- a/.env.example
+++ b/.env.example
@@ -19,3 +19,6 @@ AWS_PINPOINT_REGION=us-west-2
AWS_EMF_ENVIRONMENT=local
CONTACT_FORM_EMAIL_ADDRESS = ""
+
+AWS_PINPOINT_SC_POOL_ID=
+AWS_PINPOINT_SC_TEMPLATE_IDS=
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 7976f87fde..1027577cb7 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -27,15 +27,15 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Initialize CodeQL
- uses: github/codeql-action/init@2f93e4319b2f04a2efc38fa7f78bd681bc3f7b2f # v2.23.2
+ uses: github/codeql-action/init@ffd3158cb9024ebd018dbf20756f28befbd168c7 # v2.24.10
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
- uses: github/codeql-action/autobuild@2f93e4319b2f04a2efc38fa7f78bd681bc3f7b2f # v2.23.2
+ uses: github/codeql-action/autobuild@ffd3158cb9024ebd018dbf20756f28befbd168c7 # v2.24.10
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@2f93e4319b2f04a2efc38fa7f78bd681bc3f7b2f # v2.23.2
+ uses: github/codeql-action/analyze@ffd3158cb9024ebd018dbf20756f28befbd168c7 # v2.24.10
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index b38d18159d..4f9a621187 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
- image: postgres:11.20-bullseye@sha256:4e4b23580ada59c9ec5a712bdff9f91b0e6a7898d9ea954306b953c426727cef
+ image: postgres:11.22-bullseye@sha256:c886a3236b3d11abc302e64309186c90a69b49e53ccff23fd8c8b057b5b4bce9
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -67,7 +67,7 @@ jobs:
run: |
cp -f .env.example .env
- name: Checks for new endpoints against AWS WAF rules
- uses: cds-snc/notification-utils/.github/actions/waffles@578b4147fe6c7a8f89241649b763f94ef24b2ad4 # 52.2.2
+ uses: cds-snc/notification-utils/.github/actions/waffles@52.2.2
with:
app-loc: '/github/workspace'
app-libs: '/github/workspace/env/site-packages'
diff --git a/Makefile b/Makefile
index debab9d61e..919458bf49 100644
--- a/Makefile
+++ b/Makefile
@@ -46,6 +46,10 @@ format:
smoke-test:
cd tests_smoke && poetry run python smoke_test.py
+.PHONY: smoke-test-local
+smoke-test-local:
+ cd tests_smoke && poetry run python smoke_test.py --local --nofiles
+
.PHONY: run
run: ## Run the web app
poetry run flask run -p 6011 --host=0.0.0.0
diff --git a/app/__init__.py b/app/__init__.py
index 77a2a7d545..c3c144620e 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -28,6 +28,7 @@
PerformancePlatformClient,
)
from app.clients.salesforce.salesforce_client import SalesforceClient
+from app.clients.sms.aws_pinpoint import AwsPinpointClient
from app.clients.sms.aws_sns import AwsSnsClient
from app.dbsetup import RoutingSQLAlchemy
from app.encryption import CryptoSigner
@@ -45,6 +46,7 @@
notify_celery = NotifyCelery()
aws_ses_client = AwsSesClient()
aws_sns_client = AwsSnsClient()
+aws_pinpoint_client = AwsPinpointClient()
signer_notification = CryptoSigner()
signer_personalisation = CryptoSigner()
signer_complaint = CryptoSigner()
@@ -107,6 +109,7 @@ def create_app(application, config=None):
statsd_client.init_app(application)
logging.init_app(application, statsd_client)
aws_sns_client.init_app(application, statsd_client=statsd_client)
+ aws_pinpoint_client.init_app(application, statsd_client=statsd_client)
aws_ses_client.init_app(application.config["AWS_REGION"], statsd_client=statsd_client)
notify_celery.init_app(application)
@@ -120,7 +123,7 @@ def create_app(application, config=None):
performance_platform_client.init_app(application)
document_download_client.init_app(application)
- clients.init_app(sms_clients=[aws_sns_client], email_clients=[aws_ses_client])
+ clients.init_app(sms_clients=[aws_sns_client, aws_pinpoint_client], email_clients=[aws_ses_client])
if application.config["FF_SALESFORCE_CONTACT"]:
salesforce_client.init_app(application)
diff --git a/app/aws/mocks.py b/app/aws/mocks.py
index 46c6f5fe10..7a7943a0ac 100644
--- a/app/aws/mocks.py
+++ b/app/aws/mocks.py
@@ -192,6 +192,83 @@ def sns_failed_callback(provider_response, reference=None, timestamp="2016-06-28
return _sns_callback(body)
+# Note that 1467074434 = 2016-06-28 00:40:34.558 UTC
+def pinpoint_successful_callback(reference=None, timestamp=1467074434, destination="+1XXX5550100"):
+ body = {
+ "eventType": "TEXT_SUCCESSFUL",
+ "eventVersion": "1.0",
+ "eventTimestamp": timestamp,
+ "isFinal": False,
+ "originationPhoneNumber": "+18078061258",
+ "destinationPhoneNumber": destination,
+ "isoCountryCode": "CA",
+ "mcc": "302",
+ "mnc": "610",
+ "carrierName": "Bell Cellular Inc. / Aliant Telecom",
+ "messageId": reference,
+ "messageRequestTimestamp": timestamp,
+ "messageEncoding": "GSM",
+ "messageType": "TRANSACTIONAL",
+ "messageStatus": "SUCCESSFUL",
+ "messageStatusDescription": "Message has been accepted by phone carrier",
+ "totalMessageParts": 1,
+ "totalMessagePrice": 0.00581,
+ "totalCarrierFee": 0.00767,
+ }
+
+ return _pinpoint_callback(body)
+
+
+def pinpoint_delivered_callback(reference=None, timestamp=1467074434, destination="+1XXX5550100"):
+ body = {
+ "eventType": "TEXT_DELIVERED",
+ "eventVersion": "1.0",
+ "eventTimestamp": timestamp,
+ "isFinal": True,
+ "originationPhoneNumber": "+13655550100",
+ "destinationPhoneNumber": destination,
+ "isoCountryCode": "CA",
+ "mcc": "302",
+ "mnc": "610",
+ "carrierName": "Bell Cellular Inc. / Aliant Telecom",
+ "messageId": reference,
+ "messageRequestTimestamp": timestamp,
+ "messageEncoding": "GSM",
+ "messageType": "TRANSACTIONAL",
+ "messageStatus": "DELIVERED",
+ "messageStatusDescription": "Message has been accepted by phone",
+ "totalMessageParts": 1,
+ "totalMessagePrice": 0.00581,
+ "totalCarrierFee": 0.006,
+ }
+
+ return _pinpoint_callback(body)
+
+
+# Note that 1467074434 = 2016-06-28 00:40:34.558 UTC
+def pinpoint_failed_callback(provider_response, reference=None, timestamp=1467074434, destination="+1XXX5550100"):
+ body = {
+ "eventType": "TEXT_CARRIER_UNREACHABLE",
+ "eventVersion": "1.0",
+ "eventTimestamp": timestamp,
+ "isFinal": True,
+ "originationPhoneNumber": "+13655550100",
+ "destinationPhoneNumber": destination,
+ "isoCountryCode": "CA",
+ "messageId": reference,
+ "messageRequestTimestamp": timestamp,
+ "messageEncoding": "GSM",
+ "messageType": "TRANSACTIONAL",
+ "messageStatus": "CARRIER_UNREACHABLE",
+ "messageStatusDescription": provider_response,
+ "totalMessageParts": 1,
+ "totalMessagePrice": 0.00581,
+ "totalCarrierFee": 0.006,
+ }
+
+ return _pinpoint_callback(body)
+
+
def _ses_bounce_callback(reference, bounce_type, bounce_subtype=None):
ses_message_body = {
"bounce": {
@@ -267,3 +344,19 @@ def _sns_callback(body):
"UnsubscribeUrl": "https://sns.ca-central-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REACTED]",
"MessageAttributes": {},
}
+
+
+def _pinpoint_callback(body):
+ return {
+ "Type": "Notification",
+ "MessageId": "8e83c020-1234-1234-1234-92a8ee9baa0a",
+ "TopicArn": "arn:aws:sns:ca-central-1:12341234:ses_notifications",
+ "Subject": None,
+ "Message": json.dumps(body),
+ "Timestamp": "2017-11-17T12:14:03.710Z",
+ "SignatureVersion": "1",
+ "Signature": "[REDACTED]",
+ "SigningCertUrl": "https://sns.ca-central-1.amazonaws.com/SimpleNotificationService-[REDACTED].pem",
+ "UnsubscribeUrl": "https://sns.ca-central-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REACTED]",
+ "MessageAttributes": {},
+ }
diff --git a/app/celery/process_pinpoint_receipts_tasks.py b/app/celery/process_pinpoint_receipts_tasks.py
new file mode 100644
index 0000000000..6192ee40cc
--- /dev/null
+++ b/app/celery/process_pinpoint_receipts_tasks.py
@@ -0,0 +1,152 @@
+from datetime import datetime
+from typing import Union
+
+from flask import current_app, json
+from notifications_utils.statsd_decorators import statsd
+from sqlalchemy.orm.exc import NoResultFound
+
+from app import notify_celery, statsd_client
+from app.config import QueueNames
+from app.dao import notifications_dao
+from app.models import (
+ NOTIFICATION_DELIVERED,
+ NOTIFICATION_PERMANENT_FAILURE,
+ NOTIFICATION_SENT,
+ NOTIFICATION_TECHNICAL_FAILURE,
+ NOTIFICATION_TEMPORARY_FAILURE,
+ PINPOINT_PROVIDER,
+)
+from app.notifications.callbacks import _check_and_queue_callback_task
+from celery.exceptions import Retry
+
+# Pinpoint receipts are of the form:
+# {
+# "eventType": "TEXT_DELIVERED",
+# "eventVersion": "1.0",
+# "eventTimestamp": 1712944268877,
+# "isFinal": true,
+# "originationPhoneNumber": "+13655550100",
+# "destinationPhoneNumber": "+16135550123",
+# "isoCountryCode": "CA",
+# "mcc": "302",
+# "mnc": "610",
+# "carrierName": "Bell Cellular Inc. / Aliant Telecom",
+# "messageId": "221bc70c-7ee6-4987-b1ba-9684ba25be20",
+# "messageRequestTimestamp": 1712944267685,
+# "messageEncoding": "GSM",
+# "messageType": "TRANSACTIONAL",
+# "messageStatus": "DELIVERED",
+# "messageStatusDescription": "Message has been accepted by phone",
+# "totalMessageParts": 1,
+# "totalMessagePrice": 0.00581,
+# "totalCarrierFee": 0.006
+# }
+
+
+@notify_celery.task(bind=True, name="process-pinpoint-result", max_retries=5, default_retry_delay=300)
+@statsd(namespace="tasks")
+def process_pinpoint_results(self, response):
+ try:
+ receipt = json.loads(response["Message"])
+ reference = receipt["messageId"]
+ status = receipt["messageStatus"]
+ provider_response = receipt["messageStatusDescription"]
+
+ notification_status = determine_pinpoint_status(status, provider_response)
+
+ if notification_status == NOTIFICATION_SENT:
+ return # we don't want to update the status to sent if it's already sent
+
+ if not notification_status:
+ current_app.logger.warning(f"unhandled provider response for reference {reference}, received '{provider_response}'")
+ notification_status = NOTIFICATION_TECHNICAL_FAILURE # revert to tech failure by default
+
+ try:
+ notification = notifications_dao.dao_get_notification_by_reference(reference)
+ except NoResultFound:
+ try:
+ current_app.logger.warning(
+ f"RETRY {self.request.retries}: notification not found for Pinpoint reference {reference} (update to {notification_status}). "
+ f"Callback may have arrived before notification was persisted to the DB. Adding task to retry queue"
+ )
+ self.retry(queue=QueueNames.RETRY)
+ except self.MaxRetriesExceededError:
+ current_app.logger.warning(
+ f"notification not found for Pinpoint reference: {reference} (update to {notification_status}). Giving up."
+ )
+ return
+ if notification.sent_by != PINPOINT_PROVIDER:
+ current_app.logger.exception(f"Pinpoint callback handled notification {notification.id} not sent by Pinpoint")
+ return
+
+ if notification.status != NOTIFICATION_SENT:
+ notifications_dao._duplicate_update_warning(notification, notification_status)
+ return
+
+ notifications_dao._update_notification_status(
+ notification=notification,
+ status=notification_status,
+ provider_response=provider_response,
+ )
+
+ if notification_status != NOTIFICATION_DELIVERED:
+ current_app.logger.info(
+ (
+ f"Pinpoint delivery failed: notification id {notification.id} and reference {reference} has error found. "
+ f"Provider response: {provider_response}"
+ )
+ )
+ else:
+ current_app.logger.info(
+ f"Pinpoint callback return status of {notification_status} for notification: {notification.id}"
+ )
+
+ statsd_client.incr(f"callback.pinpoint.{notification_status}")
+
+ if notification.sent_at:
+ statsd_client.timing_with_dates("callback.pinpoint.elapsed-time", datetime.utcnow(), notification.sent_at)
+
+ _check_and_queue_callback_task(notification)
+
+ except Retry:
+ raise
+
+ except Exception as e:
+ current_app.logger.exception(f"Error processing Pinpoint results: {str(e)}")
+ self.retry(queue=QueueNames.RETRY)
+
+
+def determine_pinpoint_status(status: str, provider_response: str) -> Union[str, None]:
+ """Determine the notification status based on the SMS status and provider response.
+
+ Args:
+ status (str): message status from AWS
+ provider_response (str): detailed status from the SMS provider
+
+ Returns:
+ Union[str, None]: the notification status or None if the status is not handled
+ """
+
+ if status == "DELIVERED":
+ return NOTIFICATION_DELIVERED
+ elif status == "SUCCESSFUL": # carrier has accepted the message but it hasn't gone to the phone yet
+ return NOTIFICATION_SENT
+
+ response_lower = provider_response.lower()
+
+ if "blocked" in response_lower:
+ return NOTIFICATION_TECHNICAL_FAILURE
+ elif "invalid" in response_lower:
+ return NOTIFICATION_TECHNICAL_FAILURE
+ elif "is opted out" in response_lower:
+ return NOTIFICATION_PERMANENT_FAILURE
+ elif "unknown error" in response_lower:
+ return NOTIFICATION_TECHNICAL_FAILURE
+ elif "exceed max price" in response_lower:
+ return NOTIFICATION_TECHNICAL_FAILURE
+ elif "phone carrier is currently unreachable/unavailable" in response_lower:
+ return NOTIFICATION_TEMPORARY_FAILURE
+ elif "phone is currently unreachable/unavailable" in response_lower:
+ return NOTIFICATION_PERMANENT_FAILURE
+ else:
+ return None
diff --git a/app/clients/freshdesk.py b/app/clients/freshdesk.py
index 66c97546fa..fd0ecc978b 100644
--- a/app/clients/freshdesk.py
+++ b/app/clients/freshdesk.py
@@ -62,6 +62,9 @@ def _generate_description(self):
f"- Organisation id: {self.contact.organisation_id}",
f"- Organisation name: {self.contact.department_org_name}",
f"- Logo filename: {self.contact.branding_url}",
+ f"- Logo name: {self.contact.branding_logo_name}",
+ f"- Alt text english: {self.contact.alt_text_en}",
+ f"- Alt text french: {self.contact.alt_text_fr}",
"
",
f"Un nouveau logo a été téléchargé par {self.contact.name} ({self.contact.email_address}) pour le service suivant :",
f"- Identifiant du service : {self.contact.service_id}",
@@ -69,6 +72,9 @@ def _generate_description(self):
f"- Identifiant de l'organisation: {self.contact.organisation_id}",
f"- Nom de l'organisation: {self.contact.department_org_name}",
f"- Nom du fichier du logo : {self.contact.branding_url}",
+ f"- Nom du logo : {self.contact.branding_logo_name}",
+ f"- Texte alternatif anglais : {self.contact.alt_text_en}",
+ f"- Texte alternatif français : {self.contact.alt_text_fr}",
]
)
diff --git a/app/clients/sms/aws_pinpoint.py b/app/clients/sms/aws_pinpoint.py
new file mode 100644
index 0000000000..37140323c0
--- /dev/null
+++ b/app/clients/sms/aws_pinpoint.py
@@ -0,0 +1,57 @@
+from time import monotonic
+
+import boto3
+import phonenumbers
+
+from app.clients.sms import SmsClient
+
+
+class AwsPinpointClient(SmsClient):
+ """
+ AWS Pinpoint SMS client
+ """
+
+ def init_app(self, current_app, statsd_client, *args, **kwargs):
+ self._client = boto3.client("pinpoint-sms-voice-v2", region_name="ca-central-1")
+ super(AwsPinpointClient, self).__init__(*args, **kwargs)
+ # super(SmsClient, self).__init__(*args, **kwargs)
+ self.current_app = current_app
+ self.name = "pinpoint"
+ self.statsd_client = statsd_client
+
+ def get_name(self):
+ return self.name
+
+ def send_sms(self, to, content, reference, multi=True, sender=None):
+ pool_id = self.current_app.config["AWS_PINPOINT_SC_POOL_ID"]
+ messageType = "TRANSACTIONAL"
+ matched = False
+
+ for match in phonenumbers.PhoneNumberMatcher(to, "US"):
+ matched = True
+ to = phonenumbers.format_number(match.number, phonenumbers.PhoneNumberFormat.E164)
+ destinationNumber = to
+
+ try:
+ start_time = monotonic()
+ response = self._client.send_text_message(
+ DestinationPhoneNumber=destinationNumber,
+ OriginationIdentity=pool_id,
+ MessageBody=content,
+ MessageType=messageType,
+ ConfigurationSetName=self.current_app.config["AWS_PINPOINT_CONFIGURATION_SET_NAME"],
+ )
+ except Exception as e:
+ self.statsd_client.incr("clients.pinpoint.error")
+ raise Exception(e)
+ finally:
+ elapsed_time = monotonic() - start_time
+ self.current_app.logger.info("AWS Pinpoint request finished in {}".format(elapsed_time))
+ self.statsd_client.timing("clients.pinpoint.request-time", elapsed_time)
+ self.statsd_client.incr("clients.pinpoint.success")
+ return response["MessageId"]
+
+ if not matched:
+ self.statsd_client.incr("clients.pinpoint.error")
+ self.current_app.logger.error("No valid numbers found in {}".format(to))
+ raise ValueError("No valid numbers found for SMS delivery")
diff --git a/app/config.py b/app/config.py
index b9b82dedb4..fa8e0e389d 100644
--- a/app/config.py
+++ b/app/config.py
@@ -266,6 +266,9 @@ class Config(object):
AWS_SES_ACCESS_KEY = os.getenv("AWS_SES_ACCESS_KEY")
AWS_SES_SECRET_KEY = os.getenv("AWS_SES_SECRET_KEY")
AWS_PINPOINT_REGION = os.getenv("AWS_PINPOINT_REGION", "us-west-2")
+ AWS_PINPOINT_SC_POOL_ID = os.getenv("AWS_PINPOINT_SC_POOL_ID", None)
+ AWS_PINPOINT_CONFIGURATION_SET_NAME = os.getenv("AWS_PINPOINT_CONFIGURATION_SET_NAME", "pinpoint-configuration")
+ AWS_PINPOINT_SC_TEMPLATE_IDS = env.list("AWS_PINPOINT_SC_TEMPLATE_IDS", [])
AWS_US_TOLL_FREE_NUMBER = os.getenv("AWS_US_TOLL_FREE_NUMBER")
CSV_UPLOAD_BUCKET_NAME = os.getenv("CSV_UPLOAD_BUCKET_NAME", "notification-alpha-canada-ca-csv-upload")
ASSET_DOMAIN = os.getenv("ASSET_DOMAIN", "assets.notification.canada.ca")
@@ -300,6 +303,7 @@ class Config(object):
INVITATION_EMAIL_TEMPLATE_ID = "4f46df42-f795-4cc4-83bb-65ca312f49cc"
SMS_CODE_TEMPLATE_ID = "36fb0730-6259-4da1-8a80-c8de22ad4246"
EMAIL_2FA_TEMPLATE_ID = "299726d2-dba6-42b8-8209-30e1d66ea164"
+ EMAIL_MAGIC_LINK_TEMPLATE_ID = "6e97fd09-6da0-4cc8-829d-33cf5b818103"
NEW_USER_EMAIL_VERIFICATION_TEMPLATE_ID = "ece42649-22a8-4d06-b87f-d52d5d3f0a27"
PASSWORD_RESET_TEMPLATE_ID = "474e9242-823b-4f99-813d-ed392e7f1201"
FORCED_PASSWORD_RESET_TEMPLATE_ID = "e9a65a6b-497b-42f2-8f43-1736e43e13b3"
@@ -357,6 +361,7 @@ class Config(object):
"app.celery.scheduled_tasks",
"app.celery.reporting_tasks",
"app.celery.nightly_tasks",
+ "app.celery.process_pinpoint_receipts_tasks",
)
CELERYBEAT_SCHEDULE = {
# app/celery/scheduled_tasks.py
diff --git a/app/delivery/send_to_providers.py b/app/delivery/send_to_providers.py
index 33590c7667..c291bbd16a 100644
--- a/app/delivery/send_to_providers.py
+++ b/app/delivery/send_to_providers.py
@@ -46,6 +46,7 @@
NOTIFICATION_SENT,
NOTIFICATION_TECHNICAL_FAILURE,
NOTIFICATION_VIRUS_SCAN_FAILED,
+ PINPOINT_PROVIDER,
SMS_TYPE,
BounceRateStatus,
Notification,
@@ -68,6 +69,7 @@ def send_sms_to_provider(notification):
notification.id,
notification.international,
notification.reply_to_text,
+ template_id=notification.template_id,
)
template_dict = dao_get_template_by_id(notification.template_id, notification.template_version).__dict__
@@ -334,9 +336,15 @@ def update_notification_to_sending(notification, provider):
dao_update_notification(notification)
-def provider_to_use(notification_type, notification_id, international=False, sender=None):
+def provider_to_use(notification_type, notification_id, international=False, sender=None, template_id=None):
+ # Temporary redirect setup for template IDs that are meant for the short code usage.
+ if notification_type == SMS_TYPE and template_id is not None and str(template_id) in Config.AWS_PINPOINT_SC_TEMPLATE_IDS:
+ return clients.get_client_by_name_and_type("pinpoint", SMS_TYPE)
+
active_providers_in_order = [
- p for p in get_provider_details_by_notification_type(notification_type, international) if p.active
+ p
+ for p in get_provider_details_by_notification_type(notification_type, international)
+ if p.active and p.identifier != PINPOINT_PROVIDER
]
if not active_providers_in_order:
@@ -353,12 +361,16 @@ def get_html_email_options(service: Service):
"fip_banner_english": False,
"fip_banner_french": True,
"logo_with_background_colour": False,
+ "alt_text_en": None,
+ "alt_text_fr": None,
}
else:
return {
"fip_banner_english": True,
"fip_banner_french": False,
"logo_with_background_colour": False,
+ "alt_text_en": None,
+ "alt_text_fr": None,
}
logo_url = get_logo_url(service.email_branding.logo) if service.email_branding.logo else None
@@ -371,6 +383,8 @@ def get_html_email_options(service: Service):
"brand_logo": logo_url,
"brand_text": service.email_branding.text,
"brand_name": service.email_branding.name,
+ "alt_text_en": service.email_branding.alt_text_en,
+ "alt_text_fr": service.email_branding.alt_text_fr,
}
diff --git a/app/email_branding/email_branding_schema.py b/app/email_branding/email_branding_schema.py
index b7070eafba..06366bb8c1 100644
--- a/app/email_branding/email_branding_schema.py
+++ b/app/email_branding/email_branding_schema.py
@@ -10,8 +10,10 @@
"text": {"type": ["string", "null"]},
"logo": {"type": ["string", "null"]},
"brand_type": {"enum": BRANDING_TYPES},
+ "alt_text_en": {"type": "string"},
+ "alt_text_fr": {"type": "string"},
},
- "required": ["name"],
+ "required": ["name", "alt_text_en", "alt_text_fr"],
}
post_update_email_branding_schema = {
@@ -24,6 +26,8 @@
"text": {"type": ["string", "null"]},
"logo": {"type": ["string", "null"]},
"brand_type": {"enum": BRANDING_TYPES},
+ "alt_text_en": {"type": "string"},
+ "alt_text_fr": {"type": "string"},
},
"required": [],
}
diff --git a/app/models.py b/app/models.py
index 215ad47372..f79867918e 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1276,9 +1276,10 @@ def get_link(self):
SNS_PROVIDER = "sns"
+PINPOINT_PROVIDER = "pinpoint"
SES_PROVIDER = "ses"
-SMS_PROVIDERS = [SNS_PROVIDER]
+SMS_PROVIDERS = [SNS_PROVIDER, PINPOINT_PROVIDER]
EMAIL_PROVIDERS = [SES_PROVIDER]
PROVIDERS = SMS_PROVIDERS + EMAIL_PROVIDERS
diff --git a/app/user/contact_request.py b/app/user/contact_request.py
index edb02ad956..cfca30cafb 100644
--- a/app/user/contact_request.py
+++ b/app/user/contact_request.py
@@ -31,6 +31,9 @@ class ContactRequest:
notification_types: str = field(default="")
expected_volume: str = field(default="")
branding_url: str = field(default="")
+ branding_logo_name: str = field(default="")
+ alt_text_en: str = field(default="")
+ alt_text_fr: str = field(default="")
def __post_init__(self):
# email address is mandatory for us
diff --git a/app/user/rest.py b/app/user/rest.py
index f52893f252..ea28646d41 100644
--- a/app/user/rest.py
+++ b/app/user/rest.py
@@ -500,6 +500,9 @@ def send_branding_request(user_id):
organisation_id=data["organisation_id"],
department_org_name=data["organisation_name"],
branding_url=get_logo_url(data["filename"]),
+ branding_logo_name=data["branding_logo_name"] if "branding_logo_name" in data else "",
+ alt_text_en=data["alt_text_en"],
+ alt_text_fr=data["alt_text_fr"],
)
contact.tags = ["z_skip_opsgenie", "z_skip_urgent_escalation"]
diff --git a/ci/Dockerfile.test b/ci/Dockerfile.test
index e068dfbfd5..3a5874db57 100644
--- a/ci/Dockerfile.test
+++ b/ci/Dockerfile.test
@@ -1,6 +1,6 @@
# Heavily inspired from Dockerfile, this one also install requirements_for_test.txt
-FROM python:3.10-alpine@sha256:860f632e67178d9e90c7dfa9844a5e02098220bff5716d3c2fe1870325f00853
+FROM python:3.10-alpine@sha256:7edffe5acc6a2c4c009fece2fbdc85f04fde4c8481202473b880ef3f8fbb2939
ENV PYTHONDONTWRITEBYTECODE 1
ENV POETRY_VERSION "1.7.1"
diff --git a/docker-compose.yml b/docker-compose.yml
index 610d8dc306..443727d0c6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,7 +18,7 @@ services:
- "listen_addresses=*"
restart: always
redis:
- image: redis:6.2@sha256:9e75c88539241ad7f61bc9c39ea4913b354064b8a75ca5fc40e1cef41b645bc0
+ image: redis:6.2@sha256:d4948d011cc38e94f0aafb8f9a60309bd93034e07d10e0767af534512cf012a9
web:
image: notification-api
restart: always
diff --git a/local/Dockerfile b/local/Dockerfile
index f4ea41376c..8c0e128f7a 100644
--- a/local/Dockerfile
+++ b/local/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.10-alpine@sha256:860f632e67178d9e90c7dfa9844a5e02098220bff5716d3c2fe1870325f00853
+FROM python:3.10-alpine@sha256:7edffe5acc6a2c4c009fece2fbdc85f04fde4c8481202473b880ef3f8fbb2939
ENV PYTHONDONTWRITEBYTECODE 1
ENV POETRY_VERSION "1.7.1"
diff --git a/migrations/versions/0449_update_magic_link_auth.py b/migrations/versions/0449_update_magic_link_auth.py
new file mode 100644
index 0000000000..6e29d5501c
--- /dev/null
+++ b/migrations/versions/0449_update_magic_link_auth.py
@@ -0,0 +1,97 @@
+"""
+
+Revision ID: 0448_update_verify_code2
+Revises: 0449_update_magic_link_auth
+Create Date: 2023-10-05 00:00:00
+
+"""
+from datetime import datetime
+
+from alembic import op
+from flask import current_app
+
+revision = "0449_update_magic_link_auth"
+down_revision = "0448_update_verify_code2"
+
+near_content = "\n".join(
+ [
+ "[[en]]"
+ "Hi ((name)),"
+ ""
+ "Here is your magic link to log in to GC Notify:"
+ ""
+ "^ **[Sign-in](((link_url_en)))**"
+ "[[/en]]"
+ ""
+ "---"
+ ""
+ "[[fr]]"
+ "Bonjour ((name)),"
+ ""
+ "Voici votre lien magique pour vous connecter à Notification GC:"
+ ""
+ "^ **[Connectez-vous](((link_url_fr)))**"
+ "[[/fr]]"
+ ]
+)
+
+
+template = {
+ "id": current_app.config["EMAIL_MAGIC_LINK_TEMPLATE_ID"],
+ "template_type": "email",
+ "subject": "Sign in | Connectez-vous",
+ "content": near_content,
+ "process_type": "priority",
+ "name": "Sign in - Magic Link | Se connecter - Lien magique",
+}
+
+
+def upgrade():
+ conn = op.get_bind()
+
+ template_insert = """
+ INSERT INTO templates (id, name, template_type, created_at, updated_at, content, service_id, subject, created_by_id, version, archived, process_type, hidden)
+ VALUES ('{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', false, '{}', false)
+ """
+
+ template_history_insert = """
+ INSERT INTO templates_history (id, name, template_type, created_at, content, archived, service_id, subject,
+ created_by_id, version, process_type, hidden)
+ VALUES ('{}', '{}', '{}', '{}', '{}', False, '{}', '{}', '{}', {}, '{}', false)
+ """
+ op.execute(
+ template_insert.format(
+ template["id"],
+ template["name"],
+ template["template_type"],
+ datetime.utcnow(),
+ datetime.utcnow(),
+ template["content"],
+ current_app.config["NOTIFY_SERVICE_ID"],
+ template["subject"],
+ current_app.config["NOTIFY_USER_ID"],
+ 1,
+ template["process_type"],
+ )
+ )
+
+ op.execute(
+ template_history_insert.format(
+ template["id"],
+ template["name"],
+ template["template_type"],
+ datetime.utcnow(),
+ template["content"],
+ current_app.config["NOTIFY_SERVICE_ID"],
+ template["subject"],
+ current_app.config["NOTIFY_USER_ID"],
+ 1,
+ template["process_type"],
+ )
+ )
+
+ op.execute("INSERT INTO auth_type (name) VALUES ('magic_link')")
+
+
+def downgrade():
+ op.execute("DELETE FROM auth_type WHERE name = 'magic_link'")
diff --git a/tests/app/celery/test_process_pinpoint_receipts_tasks.py b/tests/app/celery/test_process_pinpoint_receipts_tasks.py
new file mode 100644
index 0000000000..60b8096170
--- /dev/null
+++ b/tests/app/celery/test_process_pinpoint_receipts_tasks.py
@@ -0,0 +1,234 @@
+from datetime import datetime
+
+import pytest
+from freezegun import freeze_time
+
+from app import statsd_client
+from app.aws.mocks import (
+ pinpoint_delivered_callback,
+ pinpoint_failed_callback,
+ pinpoint_successful_callback,
+)
+from app.celery.process_pinpoint_receipts_tasks import process_pinpoint_results
+from app.dao.notifications_dao import get_notification_by_id
+from app.models import (
+ NOTIFICATION_DELIVERED,
+ NOTIFICATION_PERMANENT_FAILURE,
+ NOTIFICATION_SENT,
+ NOTIFICATION_TECHNICAL_FAILURE,
+ NOTIFICATION_TEMPORARY_FAILURE,
+)
+from app.notifications.callbacks import create_delivery_status_callback_data
+from celery.exceptions import MaxRetriesExceededError
+from tests.app.conftest import create_sample_notification
+from tests.app.db import (
+ create_notification,
+ create_service_callback_api,
+ save_notification,
+)
+
+
+def test_process_pinpoint_results_delivered(sample_template, notify_db, notify_db_session, mocker):
+ mock_logger = mocker.patch("app.celery.process_pinpoint_receipts_tasks.current_app.logger.info")
+ mock_callback_task = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ notification = create_sample_notification(
+ notify_db,
+ notify_db_session,
+ template=sample_template,
+ reference="ref",
+ status=NOTIFICATION_SENT,
+ sent_by="pinpoint",
+ sent_at=datetime.utcnow(),
+ )
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_SENT
+
+ process_pinpoint_results(pinpoint_delivered_callback(reference="ref"))
+
+ assert mock_callback_task.called_once_with(get_notification_by_id(notification.id))
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_DELIVERED
+ assert get_notification_by_id(notification.id).provider_response == "Message has been accepted by phone"
+
+ mock_logger.assert_called_once_with(f"Pinpoint callback return status of delivered for notification: {notification.id}")
+
+
+def test_process_pinpoint_results_succeeded(sample_template, notify_db, notify_db_session, mocker):
+ mock_callback_task = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ notification = create_sample_notification(
+ notify_db,
+ notify_db_session,
+ template=sample_template,
+ reference="ref",
+ status=NOTIFICATION_SENT,
+ sent_by="pinpoint",
+ sent_at=datetime.utcnow(),
+ )
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_SENT
+
+ process_pinpoint_results(pinpoint_successful_callback(reference="ref"))
+
+ assert mock_callback_task.not_called()
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_SENT
+ assert get_notification_by_id(notification.id).provider_response is None
+
+
+@pytest.mark.parametrize(
+ "provider_response, expected_status, should_log_warning, should_save_provider_response",
+ [
+ (
+ "Blocked as spam by phone carrier",
+ NOTIFICATION_TECHNICAL_FAILURE,
+ False,
+ True,
+ ),
+ (
+ "Phone carrier is currently unreachable/unavailable",
+ NOTIFICATION_TEMPORARY_FAILURE,
+ False,
+ True,
+ ),
+ (
+ "Phone is currently unreachable/unavailable",
+ NOTIFICATION_PERMANENT_FAILURE,
+ False,
+ True,
+ ),
+ ("This is not a real response", NOTIFICATION_TECHNICAL_FAILURE, True, True),
+ ],
+)
+def test_process_pinpoint_results_failed(
+ sample_template,
+ notify_db,
+ notify_db_session,
+ mocker,
+ provider_response,
+ expected_status,
+ should_log_warning,
+ should_save_provider_response,
+):
+ mock_logger = mocker.patch("app.celery.process_pinpoint_receipts_tasks.current_app.logger.info")
+ mock_warning_logger = mocker.patch("app.celery.process_pinpoint_receipts_tasks.current_app.logger.warning")
+ mock_callback_task = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ notification = create_sample_notification(
+ notify_db,
+ notify_db_session,
+ template=sample_template,
+ reference="ref",
+ status=NOTIFICATION_SENT,
+ sent_by="pinpoint",
+ sent_at=datetime.utcnow(),
+ )
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_SENT
+ process_pinpoint_results(pinpoint_failed_callback(provider_response=provider_response, reference="ref"))
+
+ assert mock_callback_task.called_once_with(get_notification_by_id(notification.id))
+ assert get_notification_by_id(notification.id).status == expected_status
+
+ if should_save_provider_response:
+ assert get_notification_by_id(notification.id).provider_response == provider_response
+ else:
+ assert get_notification_by_id(notification.id).provider_response is None
+
+ mock_logger.assert_called_once_with(
+ (
+ f"Pinpoint delivery failed: notification id {notification.id} and reference ref has error found. "
+ f"Provider response: {provider_response}"
+ )
+ )
+
+ assert mock_warning_logger.call_count == int(should_log_warning)
+
+
+def test_pinpoint_callback_should_retry_if_notification_is_missing(notify_db, mocker):
+ mock_retry = mocker.patch("app.celery.process_pinpoint_receipts_tasks.process_pinpoint_results.retry")
+ mock_callback_task = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ process_pinpoint_results(pinpoint_delivered_callback(reference="ref"))
+
+ mock_callback_task.assert_not_called()
+ assert mock_retry.call_count == 1
+
+
+def test_pinpoint_callback_should_give_up_after_max_tries(notify_db, mocker):
+ mocker.patch(
+ "app.celery.process_pinpoint_receipts_tasks.process_pinpoint_results.retry",
+ side_effect=MaxRetriesExceededError,
+ )
+ mock_logger = mocker.patch("app.celery.process_pinpoint_receipts_tasks.current_app.logger.warning")
+ mock_callback_task = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ process_pinpoint_results(pinpoint_delivered_callback(reference="ref")) is None
+ mock_callback_task.assert_not_called()
+
+ mock_logger.assert_called_with("notification not found for Pinpoint reference: ref (update to delivered). Giving up.")
+
+
+def test_process_pinpoint_results_retry_called(sample_template, mocker):
+ save_notification(
+ create_notification(
+ sample_template,
+ reference="ref1",
+ sent_at=datetime.utcnow(),
+ status=NOTIFICATION_SENT,
+ sent_by="pinpoint",
+ )
+ )
+
+ mocker.patch(
+ "app.dao.notifications_dao._update_notification_status",
+ side_effect=Exception("EXPECTED"),
+ )
+ mocked = mocker.patch("app.celery.process_pinpoint_receipts_tasks.process_pinpoint_results.retry")
+ process_pinpoint_results(response=pinpoint_delivered_callback(reference="ref1"))
+ assert mocked.call_count == 1
+
+
+def test_process_pinpoint_results_does_not_process_other_providers(sample_template, mocker):
+ mock_logger = mocker.patch("app.celery.process_pinpoint_receipts_tasks.current_app.logger.exception")
+ mock_dao = mocker.patch("app.dao.notifications_dao._update_notification_status")
+ save_notification(
+ create_notification(
+ sample_template,
+ reference="ref1",
+ sent_at=datetime.utcnow(),
+ status=NOTIFICATION_SENT,
+ sent_by="sns",
+ )
+ )
+
+ process_pinpoint_results(response=pinpoint_delivered_callback(reference="ref1")) is None
+ assert mock_logger.called_once_with("")
+ assert not mock_dao.called
+
+
+def test_process_pinpoint_results_calls_service_callback(sample_template, notify_db_session, notify_db, mocker):
+ with freeze_time("2021-01-01T12:00:00"):
+ mocker.patch("app.statsd_client.incr")
+ mocker.patch("app.statsd_client.timing_with_dates")
+ mock_send_status = mocker.patch("app.celery.service_callback_tasks.send_delivery_status_to_service.apply_async")
+ mock_callback = mocker.patch("app.notifications.callbacks._check_and_queue_callback_task")
+
+ notification = create_sample_notification(
+ notify_db,
+ notify_db_session,
+ template=sample_template,
+ reference="ref",
+ status=NOTIFICATION_SENT,
+ sent_by="pinpoint",
+ sent_at=datetime.utcnow(),
+ )
+ callback_api = create_service_callback_api(service=sample_template.service, url="https://example.com")
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_SENT
+
+ process_pinpoint_results(pinpoint_delivered_callback(reference="ref"))
+
+ assert mock_callback.called_once_with(get_notification_by_id(notification.id))
+ assert get_notification_by_id(notification.id).status == NOTIFICATION_DELIVERED
+ assert get_notification_by_id(notification.id).provider_response == "Message has been accepted by phone"
+ statsd_client.timing_with_dates.assert_any_call("callback.pinpoint.elapsed-time", datetime.utcnow(), notification.sent_at)
+ statsd_client.incr.assert_any_call("callback.pinpoint.delivered")
+ updated_notification = get_notification_by_id(notification.id)
+ signed_data = create_delivery_status_callback_data(updated_notification, callback_api)
+ mock_send_status.assert_called_once_with([str(notification.id), signed_data], queue="service-callbacks")
diff --git a/tests/app/clients/test_freshdesk.py b/tests/app/clients/test_freshdesk.py
index a3e0713d5f..3e8b56227d 100644
--- a/tests/app/clients/test_freshdesk.py
+++ b/tests/app/clients/test_freshdesk.py
@@ -130,13 +130,19 @@ def match_json(request):
"- Organisation id: 6b72e84f-8591-42e1-93b8-7d24a45e1d79
"
"- Organisation name: best org name ever
"
"- Logo filename: branding_url
"
+ "- Logo name: branding_logo_name
"
+ "- Alt text english: en alt text
"
+ "- Alt text french: fr alt text
"
"
"
"Un nouveau logo a été téléchargé par name (test@email.com) pour le service suivant :
"
"- Identifiant du service : 8624bd36-b70b-4d4b-a459-13e1f4770b92
"
"- Nom du service : t6
"
"- Identifiant de l'organisation: 6b72e84f-8591-42e1-93b8-7d24a45e1d79
"
"- Nom de l'organisation: best org name ever
"
- "- Nom du fichier du logo : branding_url",
+ "- Nom du fichier du logo : branding_url
"
+ "- Nom du logo : branding_logo_name
"
+ "- Texte alternatif anglais : en alt text
"
+ "- Texte alternatif français : fr alt text",
"email": "test@email.com",
"priority": 1,
"status": 2,
@@ -166,6 +172,9 @@ def match_json(request):
"department_org_name": "best org name ever",
"service_id": "8624bd36-b70b-4d4b-a459-13e1f4770b92",
"branding_url": "branding_url",
+ "branding_logo_name": "branding_logo_name",
+ "alt_text_en": "en alt text",
+ "alt_text_fr": "fr alt text",
}
with notify_api.app_context():
response = freshdesk.Freshdesk(ContactRequest(**data)).send_ticket()
diff --git a/tests/app/delivery/test_send_to_providers.py b/tests/app/delivery/test_send_to_providers.py
index 018df49123..ea91e1a503 100644
--- a/tests/app/delivery/test_send_to_providers.py
+++ b/tests/app/delivery/test_send_to_providers.py
@@ -596,19 +596,20 @@ def test_get_html_email_renderer_with_branding_details_and_render_fip_banner_eng
sample_service.email_branding = None
notify_db.session.add_all([sample_service])
notify_db.session.commit()
-
options = send_to_providers.get_html_email_options(sample_service)
assert options == {
"fip_banner_english": True,
"fip_banner_french": False,
"logo_with_background_colour": False,
+ "alt_text_en": None,
+ "alt_text_fr": None,
}
def test_get_html_email_renderer_prepends_logo_path(notify_api):
Service = namedtuple("Service", ["email_branding"])
- EmailBranding = namedtuple("EmailBranding", ["brand_type", "colour", "name", "logo", "text"])
+ EmailBranding = namedtuple("EmailBranding", ["brand_type", "colour", "name", "logo", "text", "alt_text_en", "alt_text_fr"])
email_branding = EmailBranding(
brand_type=BRANDING_ORG_NEW,
@@ -616,6 +617,8 @@ def test_get_html_email_renderer_prepends_logo_path(notify_api):
logo="justice-league.png",
name="Justice League",
text="League of Justice",
+ alt_text_en="alt_text_en",
+ alt_text_fr="alt_text_fr",
)
service = Service(
email_branding=email_branding,
@@ -628,7 +631,7 @@ def test_get_html_email_renderer_prepends_logo_path(notify_api):
def test_get_html_email_renderer_handles_email_branding_without_logo(notify_api):
Service = namedtuple("Service", ["email_branding"])
- EmailBranding = namedtuple("EmailBranding", ["brand_type", "colour", "name", "logo", "text"])
+ EmailBranding = namedtuple("EmailBranding", ["brand_type", "colour", "name", "logo", "text", "alt_text_en", "alt_text_fr"])
email_branding = EmailBranding(
brand_type=BRANDING_ORG_BANNER_NEW,
@@ -636,6 +639,8 @@ def test_get_html_email_renderer_handles_email_branding_without_logo(notify_api)
logo=None,
name="Justice League",
text="League of Justice",
+ alt_text_en="alt_text_en",
+ alt_text_fr="alt_text_fr",
)
service = Service(
email_branding=email_branding,
@@ -649,6 +654,8 @@ def test_get_html_email_renderer_handles_email_branding_without_logo(notify_api)
assert renderer["brand_text"] == "League of Justice"
assert renderer["brand_colour"] == "#000000"
assert renderer["brand_name"] == "Justice League"
+ assert renderer["alt_text_en"] == "alt_text_en"
+ assert renderer["alt_text_fr"] == "alt_text_fr"
def test_should_not_update_notification_if_research_mode_on_exception(sample_service, sample_notification, mocker):
diff --git a/tests/app/email_branding/test_rest.py b/tests/app/email_branding/test_rest.py
index 5cee670554..f2a360a17e 100644
--- a/tests/app/email_branding/test_rest.py
+++ b/tests/app/email_branding/test_rest.py
@@ -78,6 +78,8 @@ def test_post_create_email_branding(admin_request, notify_db_session):
"colour": "#0000ff",
"logo": "/images/test_x2.png",
"brand_type": BRANDING_ORG_NEW,
+ "alt_text_en": "hello world",
+ "alt_text_fr": "bonjour le monde",
}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
assert data["name"] == response["data"]["name"]
@@ -85,6 +87,8 @@ def test_post_create_email_branding(admin_request, notify_db_session):
assert data["logo"] == response["data"]["logo"]
assert data["name"] == response["data"]["text"]
assert data["brand_type"] == response["data"]["brand_type"]
+ assert data["alt_text_en"] == response["data"]["alt_text_en"]
+ assert data["alt_text_fr"] == response["data"]["alt_text_fr"]
def test_post_create_email_branding_without_brand_type_defaults(admin_request, notify_db_session):
@@ -92,16 +96,15 @@ def test_post_create_email_branding_without_brand_type_defaults(admin_request, n
"name": "test email_branding",
"colour": "#0000ff",
"logo": "/images/test_x2.png",
+ "alt_text_en": "hello world",
+ "alt_text_fr": "bonjour le monde",
}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
assert BRANDING_ORG_NEW == response["data"]["brand_type"]
def test_post_create_email_branding_without_logo_is_ok(admin_request, notify_db_session):
- data = {
- "name": "test email_branding",
- "colour": "#0000ff",
- }
+ data = {"name": "test email_branding", "colour": "#0000ff", "alt_text_en": "hello", "alt_text_fr": "bonjour"}
response = admin_request.post(
"email_branding.create_email_branding",
_data=data,
@@ -111,13 +114,15 @@ def test_post_create_email_branding_without_logo_is_ok(admin_request, notify_db_
def test_post_create_email_branding_colour_is_valid(admin_request, notify_db_session):
- data = {"logo": "images/text_x2.png", "name": "test branding"}
+ data = {"logo": "images/text_x2.png", "name": "test branding", "alt_text_en": "hello", "alt_text_fr": "bonjour"}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
assert response["data"]["logo"] == data["logo"]
assert response["data"]["name"] == "test branding"
assert response["data"]["colour"] is None
assert response["data"]["text"] == "test branding"
+ assert response["data"]["alt_text_en"] == "hello"
+ assert response["data"]["alt_text_fr"] == "bonjour"
def test_post_create_email_branding_with_text(admin_request, notify_db_session):
@@ -125,6 +130,8 @@ def test_post_create_email_branding_with_text(admin_request, notify_db_session):
"text": "text for brand",
"logo": "images/text_x2.png",
"name": "test branding",
+ "alt_text_en": "hello",
+ "alt_text_fr": "bonjour",
}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
@@ -132,6 +139,8 @@ def test_post_create_email_branding_with_text(admin_request, notify_db_session):
assert response["data"]["name"] == "test branding"
assert response["data"]["colour"] is None
assert response["data"]["text"] == "text for brand"
+ assert response["data"]["alt_text_en"] == "hello"
+ assert response["data"]["alt_text_fr"] == "bonjour"
def test_post_create_email_branding_with_text_and_name(admin_request, notify_db_session):
@@ -139,6 +148,8 @@ def test_post_create_email_branding_with_text_and_name(admin_request, notify_db_
"name": "name for brand",
"text": "text for brand",
"logo": "images/text_x2.png",
+ "alt_text_en": "hello",
+ "alt_text_fr": "bonjour",
}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
@@ -146,20 +157,35 @@ def test_post_create_email_branding_with_text_and_name(admin_request, notify_db_
assert response["data"]["name"] == "name for brand"
assert response["data"]["colour"] is None
assert response["data"]["text"] == "text for brand"
+ assert response["data"]["alt_text_en"] == "hello"
+ assert response["data"]["alt_text_fr"] == "bonjour"
def test_post_create_email_branding_with_text_as_none_and_name(admin_request, notify_db_session):
- data = {"name": "name for brand", "text": None, "logo": "images/text_x2.png"}
+ data = {
+ "name": "name for brand",
+ "text": None,
+ "logo": "images/text_x2.png",
+ "alt_text_en": "hello",
+ "alt_text_fr": "bonjour",
+ }
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
assert response["data"]["logo"] == data["logo"]
assert response["data"]["name"] == "name for brand"
assert response["data"]["colour"] is None
assert response["data"]["text"] is None
+ assert response["data"]["alt_text_en"] == "hello"
+ assert response["data"]["alt_text_fr"] == "bonjour"
def test_post_create_email_branding_returns_400_when_name_is_missing(admin_request, notify_db_session):
- data = {"text": "some text", "logo": "images/text_x2.png"}
+ data = {
+ "text": "some text",
+ "logo": "images/text_x2.png",
+ "alt_text_en": "hello",
+ "alt_text_fr": "bonjour",
+ }
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=400)
assert response["errors"][0]["message"] == "name is a required property"
@@ -176,7 +202,7 @@ def test_post_create_email_branding_returns_400_when_name_is_missing(admin_reque
],
)
def test_post_update_email_branding_updates_field(admin_request, notify_db_session, data_update):
- data = {"name": "test email_branding", "logo": "images/text_x2.png"}
+ data = {"name": "test email_branding", "logo": "images/text_x2.png", "alt_text_en": "hello", "alt_text_fr": "bonjour"}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
email_branding_id = response["data"]["id"]
@@ -205,7 +231,7 @@ def test_post_update_email_branding_updates_field(admin_request, notify_db_sessi
],
)
def test_post_update_email_branding_updates_field_with_text(admin_request, notify_db_session, data_update):
- data = {"name": "test email_branding", "logo": "images/text_x2.png"}
+ data = {"name": "test email_branding", "logo": "images/text_x2.png", "alt_text_en": "hello", "alt_text_fr": "bonjour"}
response = admin_request.post("email_branding.create_email_branding", _data=data, _expected_status=201)
email_branding_id = response["data"]["id"]
diff --git a/tests/app/user/test_rest.py b/tests/app/user/test_rest.py
index 8a480848bd..bdffcf4cdb 100644
--- a/tests/app/user/test_rest.py
+++ b/tests/app/user/test_rest.py
@@ -964,6 +964,8 @@ def test_send_branding_request(client, sample_service, sample_organisation, mock
"organisation_id": str(sample_service.organisation.id),
"organisation_name": sample_service.organisation.name,
"filename": "branding_url",
+ "alt_text_en": "hello world",
+ "alt_text_fr": "bonjour",
}
mocked_freshdesk = mocker.patch("app.user.rest.Freshdesk.send_ticket", return_value=201)
mocked_salesforce_client = mocker.patch("app.user.rest.salesforce_client")
diff --git a/tests_smoke/README.md b/tests_smoke/README.md
new file mode 100644
index 0000000000..531de7879a
--- /dev/null
+++ b/tests_smoke/README.md
@@ -0,0 +1,14 @@
+# Smoke Tests
+
+This repository contains a set of smoke tests for our application. Smoke testing, also known as "Build Verification Testing", is a type of software testing that comprises of a non-exhaustive set of tests that aim at ensuring that the most important functions work. The phrase 'smoke testing' comes from the hardware testing, where you plug in a new piece of hardware and turn it on for the first time. If it starts smoking, you know you have a problem.
+
+## Getting Started
+
+These smoke tests are designed to run in the api devcontainer.
+
+in the root of the repo create `.env` files for the environments you with to smoke test, for example `.env_smoke_local`, `.env_smoke_staging`, and `.env_smoke_prod`. For required values see the [.env.example](.env.example) file).
+
+## Running the tests
+
+in the devcontainer run the aliases `smoke-local`, `smoke-staging`, or `smoke-prod` to run the tests.
+
diff --git a/tests_smoke/smoke/common.py b/tests_smoke/smoke/common.py
index 3b52669b7a..f248bc8926 100644
--- a/tests_smoke/smoke/common.py
+++ b/tests_smoke/smoke/common.py
@@ -2,8 +2,6 @@
import json
import os
import time
-
-# from notifications_utils.s3 import s3upload as utils_s3upload
import urllib
import uuid
from enum import Enum
@@ -41,6 +39,7 @@ class Config:
EMAIL_TEMPLATE_ID = os.environ.get("SMOKE_EMAIL_TEMPLATE_ID")
SMS_TEMPLATE_ID = os.environ.get("SMOKE_SMS_TEMPLATE_ID")
API_KEY = os.environ.get("SMOKE_API_KEY", "")
+ JOB_SIZE = int(os.environ.get("SMOKE_JOB_SIZE", 2))
boto_session = Session(
@@ -67,8 +66,8 @@ def rows_to_csv(rows: List[List[str]]):
return output.getvalue()
-def job_line(data: str, number_of_lines: int) -> Iterator[List[str]]:
- return map(lambda n: [data, f"var{n}"], range(0, number_of_lines))
+def job_line(data: str, number_of_lines: int, prefix: str = "") -> Iterator[List[str]]:
+ return map(lambda n: [data, f"{prefix} {n}"], range(0, number_of_lines))
def pretty_print(data: Any):
@@ -120,7 +119,6 @@ def job_succeeded(service_id: str, job_id: str) -> bool:
return success
-# from notifications_utils.s3 import s3upload as utils_s3upload
def utils_s3upload(filedata, region, bucket_name, file_location, content_type="binary/octet-stream", tags=None):
_s3 = boto_session.resource("s3")
diff --git a/tests_smoke/smoke/test_admin_csv.py b/tests_smoke/smoke/test_admin_csv.py
index fc2d035a0a..e6f962266f 100644
--- a/tests_smoke/smoke/test_admin_csv.py
+++ b/tests_smoke/smoke/test_admin_csv.py
@@ -13,13 +13,13 @@
)
-def test_admin_csv(notification_type: Notification_type):
+def test_admin_csv(notification_type: Notification_type, local: bool = False):
print(f"test_admin_csv ({notification_type.value})... ", end="", flush=True)
if notification_type == Notification_type.EMAIL:
- data = rows_to_csv([["email address", "var"], *job_line(Config.EMAIL_TO, 2)])
+ data = rows_to_csv([["email address", "var"], *job_line(Config.EMAIL_TO, Config.JOB_SIZE, prefix="smoke test admin csv")])
else:
- data = rows_to_csv([["phone number", "var"], *job_line(Config.SMS_TO, 2)])
+ data = rows_to_csv([["phone number", "var"], *job_line(Config.SMS_TO, Config.JOB_SIZE, prefix="smoke test admin csv")])
upload_id = s3upload(Config.SERVICE_ID, data)
metadata_kwargs = {
@@ -42,8 +42,11 @@ def test_admin_csv(notification_type: Notification_type):
print("FAILED: post to send_notification failed")
exit(1)
- success = job_succeeded(Config.SERVICE_ID, upload_id)
- if not success:
- print("FAILED: job didn't finish successfully")
- exit(1)
- print("Success")
+ if local:
+ print(f"Check manually for {Config.JOB_SIZE} {notification_type.value}s")
+ else:
+ success = job_succeeded(Config.SERVICE_ID, upload_id)
+ if not success:
+ print("FAILED: job didn't finish successfully")
+ exit(1)
+ print("Success")
diff --git a/tests_smoke/smoke/test_admin_one_off.py b/tests_smoke/smoke/test_admin_one_off.py
index 8d52ea55a6..faaee84c92 100644
--- a/tests_smoke/smoke/test_admin_one_off.py
+++ b/tests_smoke/smoke/test_admin_one_off.py
@@ -4,7 +4,7 @@
from .common import Config, Notification_type, pretty_print, single_succeeded
-def test_admin_one_off(notification_type: Notification_type):
+def test_admin_one_off(notification_type: Notification_type, local: bool = False):
print(f"test_admin_one_off ({notification_type.value})... ", end="", flush=True)
token = create_jwt_token(Config.ADMIN_CLIENT_SECRET, client_id=Config.ADMIN_CLIENT_USER_NAME)
@@ -17,7 +17,7 @@ def test_admin_one_off(notification_type: Notification_type):
"to": to,
"template_id": template_id,
"created_by": Config.USER_ID,
- "personalisation": {"var": "var"},
+ "personalisation": {"var": "smoke test admin one off"},
},
headers={"Authorization": f"Bearer {token}"},
)
@@ -28,9 +28,12 @@ def test_admin_one_off(notification_type: Notification_type):
print("FAILED: post to send_notification failed")
exit(1)
- uri = f"{Config.API_HOST_NAME}/service/{Config.SERVICE_ID}/notifications/{body['id']}"
- success = single_succeeded(uri, use_jwt=True)
- if not success:
- print("FAILED: job didn't finish successfully")
- exit(1)
- print("Success")
+ if local:
+ print(f"Check manually for 1 {notification_type.value}")
+ else:
+ uri = f"{Config.API_HOST_NAME}/service/{Config.SERVICE_ID}/notifications/{body['id']}"
+ success = single_succeeded(uri, use_jwt=True)
+ if not success:
+ print("FAILED: job didn't finish successfully")
+ exit(1)
+ print("Success")
diff --git a/tests_smoke/smoke/test_api_bulk.py b/tests_smoke/smoke/test_api_bulk.py
index e31455589d..91897770cf 100644
--- a/tests_smoke/smoke/test_api_bulk.py
+++ b/tests_smoke/smoke/test_api_bulk.py
@@ -12,7 +12,7 @@
)
-def test_api_bulk(notification_type: Notification_type):
+def test_api_bulk(notification_type: Notification_type, local: bool = False):
print(f"test_api_bulk ({notification_type.value})... ", end="", flush=True)
template_id = Config.EMAIL_TEMPLATE_ID if notification_type == Notification_type.EMAIL else Config.SMS_TEMPLATE_ID
to = Config.EMAIL_TO if notification_type == Notification_type.EMAIL else Config.SMS_TO
@@ -23,7 +23,7 @@ def test_api_bulk(notification_type: Notification_type):
json={
"name": f"My bulk name {datetime.utcnow().isoformat()}",
"template_id": template_id,
- "csv": rows_to_csv([[header, "var"], *job_line(to, 2)]),
+ "csv": rows_to_csv([[header, "var"], *job_line(to, Config.JOB_SIZE, prefix="smoke test api bulk")]),
},
headers={"Authorization": f"ApiKey-v1 {Config.API_KEY}"},
)
@@ -32,8 +32,11 @@ def test_api_bulk(notification_type: Notification_type):
print("FAILED: post failed")
exit(1)
- success = job_succeeded(Config.SERVICE_ID, response.json()["data"]["id"])
- if not success:
- print("FAILED: job didn't finish successfully")
- exit(1)
- print("Success")
+ if local:
+ print(f"Check manually for {Config.JOB_SIZE} {notification_type.value}s")
+ else:
+ success = job_succeeded(Config.SERVICE_ID, response.json()["data"]["id"])
+ if not success:
+ print("FAILED: job didn't finish successfully")
+ exit(1)
+ print("Success")
diff --git a/tests_smoke/smoke/test_api_one_off.py b/tests_smoke/smoke/test_api_one_off.py
index 643192bb64..d4ab8e470a 100644
--- a/tests_smoke/smoke/test_api_one_off.py
+++ b/tests_smoke/smoke/test_api_one_off.py
@@ -11,7 +11,7 @@
)
-def test_api_one_off(notification_type: Notification_type, attachment_type: Attachment_type = Attachment_type.NONE):
+def test_api_one_off(notification_type: Notification_type, attachment_type: Attachment_type = Attachment_type.NONE, local: bool = False):
if attachment_type is Attachment_type.NONE:
print(f"test_api_oneoff ({notification_type.value})... ", end="", flush=True)
else:
@@ -51,7 +51,7 @@ def test_api_one_off(notification_type: Notification_type, attachment_type: Atta
}
else:
data["personalisation"] = {
- "var": "var",
+ "var": "smoke test api one off",
}
response = requests.post(
@@ -64,10 +64,12 @@ def test_api_one_off(notification_type: Notification_type, attachment_type: Atta
print(f"FAILED: post to v2/notifications/{notification_type.value} failed")
exit(1)
- uri = response.json()["uri"]
-
- success = single_succeeded(uri, use_jwt=False)
- if not success:
- print("FAILED: job didn't finish successfully")
- exit(1)
- print("Success")
+ if local:
+ print(f"Check manually for 1 {notification_type.value}")
+ else:
+ uri = response.json()["uri"]
+ success = single_succeeded(uri, use_jwt=False)
+ if not success:
+ print("FAILED: job didn't finish successfully")
+ exit(1)
+ print("Success")
diff --git a/tests_smoke/smoke_test.py b/tests_smoke/smoke_test.py
index 2b9fc2399b..ccde49ddef 100644
--- a/tests_smoke/smoke_test.py
+++ b/tests_smoke/smoke_test.py
@@ -1,3 +1,5 @@
+import argparse
+
from smoke.common import Attachment_type, Config, Notification_type # type: ignore
from smoke.test_admin_csv import test_admin_csv # type: ignore
from smoke.test_admin_one_off import test_admin_one_off # type: ignore
@@ -5,15 +7,22 @@
from smoke.test_api_one_off import test_api_one_off # type: ignore
if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-l", "--local", default=False, action='store_true', help="run locally, do not check for delivery success (default false)")
+ parser.add_argument("--nofiles", default=False, action='store_true', help="do not send files (default false)")
+ args = parser.parse_args()
+
print("API Smoke test\n")
for key in ["API_HOST_NAME", "SERVICE_ID", "EMAIL_TEMPLATE_ID", "SMS_TEMPLATE_ID", "EMAIL_TO", "SMS_TO"]:
print(f"{key:>17}: {Config.__dict__[key]}")
print("")
for notification_type in [Notification_type.EMAIL, Notification_type.SMS]:
- test_admin_one_off(notification_type)
- test_admin_csv(notification_type)
- test_api_one_off(notification_type)
- test_api_bulk(notification_type)
- test_api_one_off(Notification_type.EMAIL, Attachment_type.ATTACHED)
- test_api_one_off(Notification_type.EMAIL, Attachment_type.LINK)
+ test_admin_one_off(notification_type, local=args.local)
+ test_admin_csv(notification_type, local=args.local)
+ test_api_one_off(notification_type, local=args.local)
+ test_api_bulk(notification_type, local=args.local)
+
+ if not args.nofiles:
+ test_api_one_off(Notification_type.EMAIL, attachment_type=Attachment_type.ATTACHED, local=args.local)
+ test_api_one_off(Notification_type.EMAIL, attachment_type=Attachment_type.LINK, local=args.local)