From 80761871c6a05b91292fa343638f0791395a32e4 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 28 Nov 2025 14:50:49 +0100 Subject: [PATCH 1/2] feat: sms webhooks --- .github/workflows/ci.yml | 2 + .../webhooks/v1/authentication_validation.py | 72 +++++++++ .../webhooks/v1/webhook_utils.py | 50 ++++++ .../numbers/webhooks/v1/numbers_webhooks.py | 55 ++----- .../sms/webhooks/v1/events/__init__.py | 17 ++ .../webhooks/v1/events/sms_webhooks_event.py | 95 +++++++++++ .../sms/webhooks/v1/internal/__init__.py | 5 + .../sms/webhooks/v1/internal/webhook_event.py | 7 + sinch/domains/sms/webhooks/v1/sms_webhooks.py | 114 ++++++++++++++ .../e2e/sms/features/steps/webhooks.steps.py | 148 ++++++++++++++++++ 10 files changed, 522 insertions(+), 43 deletions(-) create mode 100644 sinch/domains/authentication/webhooks/v1/webhook_utils.py create mode 100644 sinch/domains/sms/webhooks/v1/events/__init__.py create mode 100644 sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py create mode 100644 sinch/domains/sms/webhooks/v1/internal/__init__.py create mode 100644 sinch/domains/sms/webhooks/v1/internal/webhook_event.py create mode 100644 sinch/domains/sms/webhooks/v1/sms_webhooks.py create mode 100644 tests/e2e/sms/features/steps/webhooks.steps.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b30d1f60..e3e56bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 0997bf73..304bde67 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -1,5 +1,7 @@ import hashlib import hmac +import base64 +import json from typing import Dict, Union, Optional, List @@ -61,3 +63,73 @@ def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: if isinstance(header_value, list): return header_value[0] if header_value else None return header_value + + +def validate_webhook_signature_with_nonce( + callback_secret: str, + headers: Dict[str, str], + body: str +) -> bool: + """ + Validate signature headers for webhook callbacks that use nonce and timestamp. + + :param callback_secret: Secret associated with the webhook. + :type callback_secret: str + :param headers: Incoming request's headers. + :type headers: Dict[str, str] + :param body: Incoming request's body. + :type body: str + :returns: True if the X-Sinch-Webhook-Signature header is valid. + :rtype: bool + """ + if callback_secret is None: + return False + + normalized_headers = normalize_headers(headers) + signature = get_header(normalized_headers.get('x-sinch-webhook-signature')) + if signature is None: + return False + + nonce = get_header(normalized_headers.get('x-sinch-webhook-signature-nonce')) + timestamp = get_header(normalized_headers.get('x-sinch-webhook-signature-timestamp')) + + if nonce is None or timestamp is None: + return False + + body_as_string = body + if isinstance(body, dict): + body_as_string = json.dumps(body) + + signed_data = compute_signed_data(body_as_string, nonce, timestamp) + + expected_signature = calculate_webhook_signature(signed_data, callback_secret) + return hmac.compare_digest(signature, expected_signature) + + +def compute_signed_data(body: str, nonce: str, timestamp: str) -> str: + """ + Compute signed data for webhook signature validation. + + Format: body.nonce.timestamp (with dots as separators) + """ + return f'{body}.{nonce}.{timestamp}' + + +def calculate_webhook_signature(signed_data: str, secret: str) -> str: + """ + Calculate webhook signature using HMAC-SHA256 with Base64 encoding. + + :param signed_data: The data to sign (body.nonce.timestamp) + :type signed_data: str + :param secret: The secret key for HMAC + :type secret: str + :returns: Base64-encoded HMAC-SHA256 signature + :rtype: str + """ + return base64.b64encode( + hmac.new( + key=secret.encode('utf-8'), + msg=signed_data.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + ).decode('utf-8') diff --git a/sinch/domains/authentication/webhooks/v1/webhook_utils.py b/sinch/domains/authentication/webhooks/v1/webhook_utils.py new file mode 100644 index 00000000..5d05b88a --- /dev/null +++ b/sinch/domains/authentication/webhooks/v1/webhook_utils.py @@ -0,0 +1,50 @@ +import json +import re +from datetime import datetime +from typing import Any, Dict + + +def parse_json(payload: str) -> Dict[str, Any]: + """ + Parse JSON string into a dictionary. + + :param payload: JSON string to parse. + :type payload: str + :returns: Parsed dictionary. + :rtype: Dict[str, Any] + :raises ValueError: If JSON parsing fails. + """ + try: + return json.loads(payload) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON: {e}") + + +def normalize_iso_timestamp(timestamp: str) -> datetime: + """ + Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()`. + + - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. + - Replaces trailing "Z" with "+00:00" to indicate UTC. + - Trims microseconds to 6 digits. + + :param timestamp: Timestamp string to normalize. + :type timestamp: str + :returns: Timezone-aware datetime object. + :rtype: datetime + :raises ValueError: If timestamp format is invalid. + """ + if timestamp.endswith("Z"): + timestamp = timestamp.replace("Z", "+00:00") + elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): + timestamp += "+00:00" + match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) + if match_ms: + micro_trimmed = match_ms.group(1)[:6] + timestamp = re.sub( + r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp + ) + try: + return datetime.fromisoformat(timestamp) + except ValueError as e: + raise ValueError(f"Invalid timestamp format: {e}") diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py index 2645879a..b324ceeb 100644 --- a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py +++ b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py @@ -1,28 +1,28 @@ -import json from typing import Any, Dict, Union -from datetime import datetime -import re -from pydantic import StrictBool, StrictStr from sinch.domains.authentication.webhooks.v1.authentication_validation import ( validate_signature_header, ) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent class NumbersWebhooks: - def __init__(self, callback_secret: StrictStr): + def __init__(self, callback_secret: str): self.callback_secret = callback_secret def validate_authentication_header( - self, headers: Dict[StrictStr, StrictStr], json_payload: StrictStr - ) -> StrictBool: + self, headers: Dict[str, str], json_payload: str + ) -> bool: """ Validate the authorization header for a callback request :param headers: Incoming request's headers :type headers: Dict[str, str] :param json_payload: Incoming request's raw body - :type json_payload: StrictStr + :type json_payload: str :returns: True if the X-Sinch-Signature header is valid :rtype: bool """ @@ -31,7 +31,7 @@ def validate_authentication_header( ) def parse_event( - self, event_body: Union[StrictStr, Dict[StrictStr, Any]] + self, event_body: Union[str, Dict[str, Any]] ) -> NumbersWebhooksEvent: """ Parses the event payload into a NumbersWebhooksEvent object. @@ -41,47 +41,16 @@ def parse_event( UTC and returns a timezone-aware ``datetime`` object. :param event_body: The event payload. - :type event_body: Union[StrictStr, Dict[StrictStr, Any]] + :type event_body: Union[str, Dict[str, Any]] :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. :rtype: NumbersWebhooksEvent """ if isinstance(event_body, str): - event_body = self._parse_json(event_body) + event_body = parse_json(event_body) timestamp = event_body.get("timestamp") if timestamp: - event_body["timestamp"] = self._normalize_iso_timestamp(timestamp) + event_body["timestamp"] = normalize_iso_timestamp(timestamp) try: return NumbersWebhooksEvent(**event_body) except Exception as e: raise ValueError(f"Failed to parse event body: {e}") - - def _parse_json(self, payload: StrictStr) -> Dict[StrictStr, Any]: - """ - Parse JSON string into a dictionary. - """ - try: - return json.loads(payload) - except json.JSONDecodeError as e: - raise ValueError(f"Failed to decode JSON: {e}") - - def _normalize_iso_timestamp(self, timestamp: StrictStr) -> datetime: - """ - Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()` - - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. - - Replaces trailing "Z" with "+00:00" to indicate UTC. - - Trims microseconds to 6 digits. - """ - if timestamp.endswith("Z"): - timestamp = timestamp.replace("Z", "+00:00") - elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): - timestamp += "+00:00" - match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) - if match_ms: - micro_trimmed = match_ms.group(1)[:6] - timestamp = re.sub( - r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp - ) - try: - return datetime.fromisoformat(timestamp) - except ValueError as e: - raise ValueError(f"Invalid timestamp format: {e}") diff --git a/sinch/domains/sms/webhooks/v1/events/__init__.py b/sinch/domains/sms/webhooks/v1/events/__init__.py new file mode 100644 index 00000000..bb5a10da --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/events/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import ( + IncomingSMSWebhookEvent, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, + MediaBody, + MediaItem, +) + +__all__ = [ + "IncomingSMSWebhookEvent", + "MOTextWebhookEvent", + "MOBinaryWebhookEvent", + "MOMediaWebhookEvent", + "MediaBody", + "MediaItem", +] diff --git a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py new file mode 100644 index 00000000..e7dd62bc --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py @@ -0,0 +1,95 @@ +from datetime import datetime +from typing import Optional, Union, Literal, Annotated +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.sms.webhooks.v1.internal import WebhookEvent + + +class MediaItem(WebhookEvent): + url: StrictStr = Field(..., description="URL to the media file") + content_type: StrictStr = Field( + ..., description="Content type of the media file" + ) + status: StrictStr = Field(..., description="Status of the media upload") + code: StrictInt = Field(..., description="Status code") + + +class MediaBody(WebhookEvent): + subject: Optional[StrictStr] = Field( + default=None, description="The subject text" + ) + message: Optional[StrictStr] = Field( + default=None, description="The message text" + ) + media: conlist(MediaItem) = Field(..., description="Array of media items") + + +class BaseIncomingSMSWebhookEvent(WebhookEvent): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + + +class MOTextWebhookEvent(BaseIncomingSMSWebhookEvent): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) + + +class MOBinaryWebhookEvent(BaseIncomingSMSWebhookEvent): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) + + +class MOMediaWebhookEvent(BaseIncomingSMSWebhookEvent): + body: MediaBody = Field( + ..., + description="The media message body containing subject, message, and media items.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) + + +# Union type for isinstance checks +_IncomingSMSWebhookEventUnion = Union[ + MOTextWebhookEvent, MOBinaryWebhookEvent, MOMediaWebhookEvent +] + +# Discriminated union for validation +IncomingSMSWebhookEvent = Annotated[ + _IncomingSMSWebhookEventUnion, Field(discriminator="type") +] diff --git a/sinch/domains/sms/webhooks/v1/internal/__init__.py b/sinch/domains/sms/webhooks/v1/internal/__init__.py new file mode 100644 index 00000000..329bdf65 --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.webhooks.v1.internal.webhook_event import ( + WebhookEvent, +) + +__all__ = ["WebhookEvent"] diff --git a/sinch/domains/sms/webhooks/v1/internal/webhook_event.py b/sinch/domains/sms/webhooks/v1/internal/webhook_event.py new file mode 100644 index 00000000..0d2857ed --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/internal/webhook_event.py @@ -0,0 +1,7 @@ +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WebhookEvent(BaseModelConfigurationResponse): + pass diff --git a/sinch/domains/sms/webhooks/v1/sms_webhooks.py b/sinch/domains/sms/webhooks/v1/sms_webhooks.py new file mode 100644 index 00000000..61ab9713 --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/sms_webhooks.py @@ -0,0 +1,114 @@ +import json +from typing import Any, Dict, Union, Optional +from pydantic import TypeAdapter +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + validate_webhook_signature_with_nonce, +) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.sms.webhooks.v1.events import ( + IncomingSMSWebhookEvent, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + + +SmsCallback = Union[ + BatchDeliveryReport, + RecipientDeliveryReport, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, +] + + +class SmsWebhooks: + def __init__(self, app_secret: Optional[str] = None): + self.app_secret = app_secret + + def validate_authentication_header( + self, headers: Dict[str, str], json_payload: str + ) -> bool: + """ + Validate the authorization header for a callback request. + + :param headers: Incoming request's headers + :type headers: Dict[str, str] + :param json_payload: Incoming request's raw body + :type json_payload: str + :returns: True if the X-Sinch-Webhook-Signature header is valid + :rtype: bool + """ + if not self.app_secret: + return False + return validate_webhook_signature_with_nonce( + self.app_secret, headers, json_payload + ) + + def parse_event( + self, event_body: Union[str, Dict[str, Any]] + ) -> SmsCallback: + """ + Parse the event payload into an SMS callback object. + + Handles datetime conversion for timestamp fields and routes to the + appropriate event type based on the `type` field. + + :param event_body: The event payload (JSON string or dict). + :type event_body: Union[str, Dict[str, Any]] + :returns: A parsed SMS callback object. + :rtype: SmsCallback + :raises ValueError: If the event type is unknown or parsing fails. + """ + if isinstance(event_body, str): + event_body = parse_json(event_body) + + event_type = event_body.get("type") + if not event_type: + raise ValueError(f"Unknown SMS event: {json.dumps(event_body)}") + + # Handle delivery reports + if event_type in ("delivery_report_sms", "delivery_report_mms"): + return BatchDeliveryReport(**event_body) + + # Handle recipient delivery reports + if event_type in ( + "recipient_delivery_report_sms", + "recipient_delivery_report_mms", + ): + if "at" in event_body and isinstance(event_body["at"], str): + event_body["at"] = normalize_iso_timestamp(event_body["at"]) + if "operator_status_at" in event_body and isinstance( + event_body["operator_status_at"], str + ): + event_body["operator_status_at"] = normalize_iso_timestamp( + event_body["operator_status_at"] + ) + return RecipientDeliveryReport(**event_body) + + # Handle incoming SMS messages using discriminated union + if event_type in ("mo_text", "mo_binary", "mo_media"): + if "received_at" in event_body and isinstance( + event_body["received_at"], str + ): + event_body["received_at"] = normalize_iso_timestamp( + event_body["received_at"] + ) + if "sent_at" in event_body and isinstance( + event_body["sent_at"], str + ): + event_body["sent_at"] = normalize_iso_timestamp( + event_body["sent_at"] + ) + + adapter = TypeAdapter(IncomingSMSWebhookEvent) + return adapter.validate_python(event_body) + + raise ValueError(f"Unknown SMS event type: {event_type}") diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py new file mode 100644 index 00000000..8a1f1f14 --- /dev/null +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -0,0 +1,148 @@ +import json +import requests +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks +from sinch.domains.sms.webhooks.v1.events import ( + MOTextWebhookEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + +SINCH_SMS_CALLBACK_SECRET = 'KayakingTheSwell' + + +def parse_event(context, response): + context.headers = dict(response.headers) + context.raw_event = response.text + return json.loads(context.raw_event) + + +@given('the SMS Webhooks handler is available') +def step_webhook_handler_is_available(context): + context.sms_webhook = SmsWebhooks(SINCH_SMS_CALLBACK_SECRET) + + +@when('I send a request to trigger an "incoming SMS" event') +def step_send_incoming_sms_event(context): + response = requests.get('http://localhost:3017/webhooks/sms/incoming-sms') + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the header of the event "IncomingSMS" contains a valid signature') +def step_check_valid_signature_incoming_sms(context): + assert context.sms_webhook.validate_authentication_header( + context.headers, context.raw_event + ), 'Signature validation failed' + + +@then('the header of the event "DeliveryReport" contains a valid signature') +def step_check_valid_signature_delivery_report(context): + assert context.sms_webhook.validate_authentication_header( + context.headers, context.raw_event + ), 'Signature validation failed' + + +@then('the SMS event describes an "incoming SMS" event') +def step_check_incoming_sms_event(context): + incoming_sms_event: MOTextWebhookEvent = context.event + assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' + assert incoming_sms_event.from_ == '12015555555' + assert incoming_sms_event.to == '12017777777' + assert incoming_sms_event.body == 'Hello John! 👋' + assert incoming_sms_event.type == 'mo_text' + assert incoming_sms_event.operator_id == '311071' + expected_received_at = datetime(2024, 6, 6, 7, 52, 37, 386000, tzinfo=timezone.utc) + assert incoming_sms_event.received_at == expected_received_at + + +@when('I send a request to trigger an "SMS delivery report" event') +def step_send_delivery_report_event(context): + response = requests.get('http://localhost:3017/webhooks/sms/delivery-report-sms') + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the SMS event describes an "SMS delivery report" event') +def step_check_delivery_report_event(context): + delivery_report_event: BatchDeliveryReport = context.event + assert delivery_report_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH8' + assert delivery_report_event.client_reference == 'client-ref' + assert delivery_report_event.statuses is not None + assert len(delivery_report_event.statuses) > 0 + + status = delivery_report_event.statuses[0] + assert status.code == 0 + assert status.count == 2 + assert status.status == 'Delivered' + assert status.recipients is not None + assert len(status.recipients) == 2 + assert status.recipients[0] == '12017777777' + assert status.recipients[1] == '33612345678' + assert delivery_report_event.type == 'delivery_report_sms' + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Delivered"') +def step_send_recipient_delivery_report_event_delivered(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' + ) + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Aborted"') +def step_send_recipient_delivery_report_event_aborted(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' + ) + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the header of the event "DeliveryReport" with the status "Delivered" contains a valid signature') +def step_check_valid_signature_with_status_delivered(context): + assert context.sms_webhook.validate_authentication_header( + context.headers, context.raw_event + ), 'Signature validation failed' + + +@then('the header of the event "DeliveryReport" with the status "Aborted" contains a valid signature') +def step_check_valid_signature_with_status_aborted(context): + assert context.sms_webhook.validate_authentication_header( + context.headers, context.raw_event + ), 'Signature validation failed' + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Delivered"') +def step_check_recipient_delivery_report_delivered(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12017777777' + assert recipient_dr_event.code == 0 + assert recipient_dr_event.status == 'Delivered' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 19, 210000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at + + expected_operator_status_at = datetime(2024, 6, 6, 8, 17, 0, tzinfo=timezone.utc) + assert recipient_dr_event.operator_status_at == expected_operator_status_at + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Aborted"') +def step_check_recipient_delivery_report_aborted(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12010000000' + assert recipient_dr_event.code == 412 + assert recipient_dr_event.status == 'Aborted' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 15, 603000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at From db43c7ce6c25c10b976c1b738c87fb20e0b9c6c6 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Sat, 29 Nov 2025 16:44:56 +0100 Subject: [PATCH 2/2] code review --- .../v1/response/recipient_delivery_report.py | 16 +++++- .../webhooks/v1/events/sms_webhooks_event.py | 4 +- sinch/domains/sms/webhooks/v1/sms_webhooks.py | 8 --- .../e2e/sms/features/steps/webhooks.steps.py | 28 +--------- .../authentication/test_webhook_utils.py | 56 +++++++++++++++++++ 5 files changed, 76 insertions(+), 36 deletions(-) create mode 100644 tests/unit/domains/authentication/test_webhook_utils.py diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py index 76f29a21..91d4aa59 100644 --- a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Optional, Union from datetime import datetime -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt, StrictStr, field_validator from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( DeliveryReceiptStatusCodeType, ) @@ -16,6 +16,9 @@ from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + normalize_iso_timestamp, +) class RecipientDeliveryReport(BaseModelConfigurationResponse): @@ -64,3 +67,12 @@ class RecipientDeliveryReport(BaseModelConfigurationResponse): type: RecipientDeliveryReportType = Field( default=..., description="The recipient delivery report type." ) + + @field_validator("at", "operator_status_at", mode="before") + @classmethod + def normalize_timestamp( + cls, value: Optional[Union[str, datetime]] + ) -> Optional[Union[str, datetime]]: + if isinstance(value, str): + return normalize_iso_timestamp(value) + return value diff --git a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py index e7dd62bc..f8abe3cb 100644 --- a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py +++ b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py @@ -9,7 +9,9 @@ class MediaItem(WebhookEvent): content_type: StrictStr = Field( ..., description="Content type of the media file" ) - status: StrictStr = Field(..., description="Status of the media upload") + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload" + ) code: StrictInt = Field(..., description="Status code") diff --git a/sinch/domains/sms/webhooks/v1/sms_webhooks.py b/sinch/domains/sms/webhooks/v1/sms_webhooks.py index 61ab9713..d5f26563 100644 --- a/sinch/domains/sms/webhooks/v1/sms_webhooks.py +++ b/sinch/domains/sms/webhooks/v1/sms_webhooks.py @@ -83,14 +83,6 @@ def parse_event( "recipient_delivery_report_sms", "recipient_delivery_report_mms", ): - if "at" in event_body and isinstance(event_body["at"], str): - event_body["at"] = normalize_iso_timestamp(event_body["at"]) - if "operator_status_at" in event_body and isinstance( - event_body["operator_status_at"], str - ): - event_body["operator_status_at"] = normalize_iso_timestamp( - event_body["operator_status_at"] - ) return RecipientDeliveryReport(**event_body) # Handle incoming SMS messages using discriminated union diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 8a1f1f14..1ce5a626 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -1,4 +1,3 @@ -import json import requests from datetime import datetime, timezone from behave import given, when, then @@ -17,7 +16,6 @@ def parse_event(context, response): context.headers = dict(response.headers) context.raw_event = response.text - return json.loads(context.raw_event) @given('the SMS Webhooks handler is available') @@ -32,15 +30,9 @@ def step_send_incoming_sms_event(context): context.event = context.sms_webhook.parse_event(context.raw_event) -@then('the header of the event "IncomingSMS" contains a valid signature') -def step_check_valid_signature_incoming_sms(context): - assert context.sms_webhook.validate_authentication_header( - context.headers, context.raw_event - ), 'Signature validation failed' - - -@then('the header of the event "DeliveryReport" contains a valid signature') -def step_check_valid_signature_delivery_report(context): +@then('the header of the event "{event_type}" contains a valid signature') +@then('the header of the event "{event_type}" with the status "{status}" contains a valid signature') +def step_check_valid_signature(context, event_type, status=None): assert context.sms_webhook.validate_authentication_header( context.headers, context.raw_event ), 'Signature validation failed' @@ -103,20 +95,6 @@ def step_send_recipient_delivery_report_event_aborted(context): context.event = context.sms_webhook.parse_event(context.raw_event) -@then('the header of the event "DeliveryReport" with the status "Delivered" contains a valid signature') -def step_check_valid_signature_with_status_delivered(context): - assert context.sms_webhook.validate_authentication_header( - context.headers, context.raw_event - ), 'Signature validation failed' - - -@then('the header of the event "DeliveryReport" with the status "Aborted" contains a valid signature') -def step_check_valid_signature_with_status_aborted(context): - assert context.sms_webhook.validate_authentication_header( - context.headers, context.raw_event - ), 'Signature validation failed' - - @then('the SMS event describes an SMS recipient delivery report event with the status "Delivered"') def step_check_recipient_delivery_report_delivered(context): recipient_dr_event: RecipientDeliveryReport = context.event diff --git a/tests/unit/domains/authentication/test_webhook_utils.py b/tests/unit/domains/authentication/test_webhook_utils.py new file mode 100644 index 00000000..059b4416 --- /dev/null +++ b/tests/unit/domains/authentication/test_webhook_utils.py @@ -0,0 +1,56 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) + + +class TestParseJson: + def test_parse_json_expects_valid_json_string(self): + """Test parse_json with a valid JSON string.""" + json_string = '{"key": "value", "number": 123}' + result = parse_json(json_string) + assert result == {"key": "value", "number": 123} + + def test_parse_json_expects_invalid_json_raises_value_error(self): + """Test parse_json with invalid JSON raises ValueError.""" + invalid_json = '{"key": "value"' + with pytest.raises(ValueError, match="Failed to decode JSON"): + parse_json(invalid_json) + + +class TestNormalizeIsoTimestamp: + def test_normalize_iso_timestamp_expects_zulu_suffix(self): + """Test normalize_iso_timestamp with Zulu timezone suffix (Z).""" + timestamp_str = "2025-03-15T14:30:45.123Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 3, 15, 14, 30, 45, 123000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_timezone_suffix(self): + """Test normalize_iso_timestamp without timezone suffix (assumes UTC).""" + timestamp_str = "2025-07-22T09:15:33.456" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 7, 22, 9, 15, 33, 456000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_trims_microseconds(self): + """Test normalize_iso_timestamp trims microseconds to 6 digits.""" + timestamp_str = "2025-11-08T16:42:17.789123456+00:00" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 11, 8, 16, 42, 17, 789123, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_microseconds(self): + """Test normalize_iso_timestamp without microseconds.""" + timestamp_str = "2025-01-31T23:59:00Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 1, 31, 23, 59, 0, 0, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_invalid_format_raises_value_error(self): + """Test normalize_iso_timestamp with invalid format raises ValueError.""" + invalid_timestamp = "not-a-timestamp" + with pytest.raises(ValueError, match="Invalid timestamp format"): + normalize_iso_timestamp(invalid_timestamp)