diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py new file mode 100644 index 000000000..2cafda15d --- /dev/null +++ b/examples/event_notification_handler_endpoint.py @@ -0,0 +1,73 @@ +""" +event_notification_handler_endpoint.py - receive and process event notifications (AKA thin events) like "v1.billing.meter.error_report_triggered" using EventNotificationHandler. + +In this example, we: + - write a fallback callback to handle unrecognized event notifications + - create a StripeClient called client + - Initialize an EventNotificationHandler with the client, webhook secret, and fallback callback + - register a specific handler for the "v1.billing.meter.error_report_triggered" event notification type + - use handler.handle() to process the received notification webhook body +""" + +import os +from flask import Flask, request, jsonify + +from stripe import StripeClient, UnhandledNotificationDetails +from stripe.v2.core import EventNotification +from stripe.events import V1BillingMeterErrorReportTriggeredEventNotification + +app = Flask(__name__) +api_key = os.environ.get("STRIPE_API_KEY", "") +webhook_secret = os.environ.get("WEBHOOK_SECRET", "") + + +def fallback_callback( + notif: EventNotification, + client: StripeClient, + details: UnhandledNotificationDetails, +): + print(f"Got an unhandled event of type {notif.type}!") + + +client = StripeClient(api_key) +handler = client.notification_handler(webhook_secret, fallback_callback) + +# Handles events delivered through a channel that has already authenticated them, such as +# AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header. +unverified_handler = client.notification_handler_without_verification( + fallback_callback +) + + +# can be anywhere in your codebase; registering on both handlers means either +# endpoint below will route this event type +@handler.on_v1_billing_meter_error_report_triggered +@unverified_handler.on_v1_billing_meter_error_report_triggered +def handle_meter_error( + notif: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, +): + event = notif.fetch_event() + print(f"Err! No meter found: {event.data.developer_message_summary}") + + +@app.route("/webhook", methods=["POST"]) +def webhook(): + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") + + try: + handler.handle(webhook_body, sig_header) + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 500 + + +@app.route("/webhook-from-cloud-provider", methods=["POST"]) +def webhook_from_cloud_provider(): + # no signature header to pass along; the channel already authenticated this event + try: + unverified_handler.handle(request.data) + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 500 diff --git a/stripe/__init__.py b/stripe/__init__.py index 6f2ad0969..1bde68b9d 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -301,6 +301,11 @@ def set_app_info( OAuthErrorObject as OAuthErrorObject, ) from stripe._event import Event as Event + from stripe._event_notification_handler import ( + StripeEventNotificationHandler as StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification as StripeEventNotificationHandlerWithoutVerification, + UnhandledNotificationDetails as UnhandledNotificationDetails, + ) from stripe._event_service import EventService as EventService from stripe._exchange_rate import ExchangeRate as ExchangeRate from stripe._exchange_rate_service import ( @@ -696,6 +701,18 @@ def set_app_info( "ErrorObject": ("stripe._error_object", False), "OAuthErrorObject": ("stripe._error_object", False), "Event": ("stripe._event", False), + "StripeEventNotificationHandler": ( + "stripe._event_notification_handler", + False, + ), + "StripeEventNotificationHandlerWithoutVerification": ( + "stripe._event_notification_handler", + False, + ), + "UnhandledNotificationDetails": ( + "stripe._event_notification_handler", + False, + ), "EventService": ("stripe._event_service", False), "ExchangeRate": ("stripe._exchange_rate", False), "ExchangeRateService": ("stripe._exchange_rate_service", False), diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py new file mode 100644 index 000000000..7793e92b1 --- /dev/null +++ b/stripe/_event_notification_handler.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +from dataclasses import dataclass +from typing_extensions import TYPE_CHECKING + +from typing import TypeVar, Callable, List + +# Import at runtime for isinstance check and type annotations +from stripe.v2.core._event import EventNotification, UnknownEventNotification + +if TYPE_CHECKING: + from stripe._stripe_client import StripeClient + + # event-notification-types: The beginning of the section generated from our OpenAPI spec + from stripe.events._v1_billing_meter_error_report_triggered_event import ( + V1BillingMeterErrorReportTriggeredEventNotification, + ) + from stripe.events._v1_billing_meter_no_meter_found_event import ( + V1BillingMeterNoMeterFoundEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_failed_event import ( + V2CommerceProductCatalogImportsFailedEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_processing_event import ( + V2CommerceProductCatalogImportsProcessingEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_succeeded_event import ( + V2CommerceProductCatalogImportsSucceededEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_succeeded_with_errors_event import ( + V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, + ) + from stripe.events._v2_core_account_closed_event import ( + V2CoreAccountClosedEventNotification, + ) + from stripe.events._v2_core_account_created_event import ( + V2CoreAccountCreatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_customer_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_customer_updated_event import ( + V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_merchant_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_merchant_updated_event import ( + V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_recipient_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_recipient_updated_event import ( + V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_defaults_updated_event import ( + V2CoreAccountIncludingDefaultsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_future_requirements_updated_event import ( + V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_identity_updated_event import ( + V2CoreAccountIncludingIdentityUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_requirements_updated_event import ( + V2CoreAccountIncludingRequirementsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_link_returned_event import ( + V2CoreAccountLinkReturnedEventNotification, + ) + from stripe.events._v2_core_account_person_created_event import ( + V2CoreAccountPersonCreatedEventNotification, + ) + from stripe.events._v2_core_account_person_deleted_event import ( + V2CoreAccountPersonDeletedEventNotification, + ) + from stripe.events._v2_core_account_person_updated_event import ( + V2CoreAccountPersonUpdatedEventNotification, + ) + from stripe.events._v2_core_account_updated_event import ( + V2CoreAccountUpdatedEventNotification, + ) + from stripe.events._v2_core_event_destination_ping_event import ( + V2CoreEventDestinationPingEventNotification, + ) + # event-notification-types: The end of the section generated from our OpenAPI spec + +# internal type to represent any EventNotification subclass +EventNotificationChild = TypeVar( + "EventNotificationChild", bound="EventNotification" +) + + +@dataclass +class UnhandledNotificationDetails: + """ + Information about an unhandled event notification to make it easier to respond (and potentially update your integration). + """ + + is_known_event_type: bool + """ + If true, the unhandled event's type is known to the SDK (i.e., it was successfully deserialized into a specific `EventNotification` subclass). + """ + + +FallbackCallback = Callable[ + [EventNotification, "StripeClient", UnhandledNotificationDetails], None +] +""" +This function is called when no other callback is registered for a given event notification type. +""" + + +class _BaseEventNotificationHandler: + """ + Shared internal registration and dispatch machinery for the two user-facing event handlers. + """ + + def __init__( + self, + client: "StripeClient", + fallback_callback: FallbackCallback, + ) -> None: + self._registered_handlers = {} + self._client = client + self.fallback_callback = fallback_callback + # once this is true, adding additional handlers results in an error + self._has_handled_events = False + + def _dispatch(self, event_notif: "EventNotification"): + # Create a new client with the event's context. + # This is thread-safe since we're not modifying the original client. + # The new client reuses the HTTP client to avoid TLS handshake overhead. + client_with_event_context = self._client.with_stripe_context( + event_notif.context + ) + + if event_notif.type in self._registered_handlers: + self._registered_handlers[event_notif.type]( + event_notif, client_with_event_context + ) + else: + self.fallback_callback( + event_notif, + client_with_event_context, + UnhandledNotificationDetails( + is_known_event_type=not isinstance( + event_notif, UnknownEventNotification + ) + ), + ) + + def _register( + self, + event_type: str, + func: "Callable[[EventNotificationChild, StripeClient], None]", + ) -> None: + if self._has_handled_events: + raise RuntimeError( + "Cannot register new event handlers after .handle() has been called. This is indicative of a bug." + ) + if event_type in self._registered_handlers: + raise ValueError( + f'Handler for event type "{event_type}" already registered.' + ) + + self._registered_handlers[event_type] = func + + @property + def registered_event_types(self) -> List[str]: + """ + Returns an alphabetized list of all event types that have registered handlers. + """ + return sorted(self._registered_handlers.keys()) + + # event-notification-registration-methods: The beginning of the section generated from our OpenAPI spec + def on_v1_billing_meter_error_report_triggered( + self, + func: "Callable[[V1BillingMeterErrorReportTriggeredEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V1BillingMeterErrorReportTriggeredEvent` (`v1.billing.meter.error_report_triggered`) event notification. + """ + self._register( + "v1.billing.meter.error_report_triggered", + func, + ) + return func + + def on_v1_billing_meter_no_meter_found( + self, + func: "Callable[[V1BillingMeterNoMeterFoundEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V1BillingMeterNoMeterFoundEvent` (`v1.billing.meter.no_meter_found`) event notification. + """ + self._register( + "v1.billing.meter.no_meter_found", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_failed( + self, + func: "Callable[[V2CommerceProductCatalogImportsFailedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsFailedEvent` (`v2.commerce.product_catalog.imports.failed`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.failed", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_processing( + self, + func: "Callable[[V2CommerceProductCatalogImportsProcessingEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsProcessingEvent` (`v2.commerce.product_catalog.imports.processing`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.processing", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_succeeded( + self, + func: "Callable[[V2CommerceProductCatalogImportsSucceededEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsSucceededEvent` (`v2.commerce.product_catalog.imports.succeeded`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.succeeded", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_succeeded_with_errors( + self, + func: "Callable[[V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsSucceededWithErrorsEvent` (`v2.commerce.product_catalog.imports.succeeded_with_errors`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.succeeded_with_errors", + func, + ) + return func + + def on_v2_core_account_closed( + self, + func: "Callable[[V2CoreAccountClosedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountClosedEvent` (`v2.core.account.closed`) event notification. + """ + self._register( + "v2.core.account.closed", + func, + ) + return func + + def on_v2_core_account_created( + self, + func: "Callable[[V2CoreAccountCreatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountCreatedEvent` (`v2.core.account.created`) event notification. + """ + self._register( + "v2.core.account.created", + func, + ) + return func + + def on_v2_core_account_including_configuration_customer_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.customer].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.customer].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_customer_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerUpdatedEvent` (`v2.core.account[configuration.customer].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.customer].updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_merchant_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.merchant].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.merchant].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_merchant_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantUpdatedEvent` (`v2.core.account[configuration.merchant].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.merchant].updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_recipient_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.recipient].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.recipient].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_recipient_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientUpdatedEvent` (`v2.core.account[configuration.recipient].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.recipient].updated", + func, + ) + return func + + def on_v2_core_account_including_defaults_updated( + self, + func: "Callable[[V2CoreAccountIncludingDefaultsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingDefaultsUpdatedEvent` (`v2.core.account[defaults].updated`) event notification. + """ + self._register( + "v2.core.account[defaults].updated", + func, + ) + return func + + def on_v2_core_account_including_future_requirements_updated( + self, + func: "Callable[[V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingFutureRequirementsUpdatedEvent` (`v2.core.account[future_requirements].updated`) event notification. + """ + self._register( + "v2.core.account[future_requirements].updated", + func, + ) + return func + + def on_v2_core_account_including_identity_updated( + self, + func: "Callable[[V2CoreAccountIncludingIdentityUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingIdentityUpdatedEvent` (`v2.core.account[identity].updated`) event notification. + """ + self._register( + "v2.core.account[identity].updated", + func, + ) + return func + + def on_v2_core_account_including_requirements_updated( + self, + func: "Callable[[V2CoreAccountIncludingRequirementsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingRequirementsUpdatedEvent` (`v2.core.account[requirements].updated`) event notification. + """ + self._register( + "v2.core.account[requirements].updated", + func, + ) + return func + + def on_v2_core_account_link_returned( + self, + func: "Callable[[V2CoreAccountLinkReturnedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountLinkReturnedEvent` (`v2.core.account_link.returned`) event notification. + """ + self._register( + "v2.core.account_link.returned", + func, + ) + return func + + def on_v2_core_account_person_created( + self, + func: "Callable[[V2CoreAccountPersonCreatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonCreatedEvent` (`v2.core.account_person.created`) event notification. + """ + self._register( + "v2.core.account_person.created", + func, + ) + return func + + def on_v2_core_account_person_deleted( + self, + func: "Callable[[V2CoreAccountPersonDeletedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonDeletedEvent` (`v2.core.account_person.deleted`) event notification. + """ + self._register( + "v2.core.account_person.deleted", + func, + ) + return func + + def on_v2_core_account_person_updated( + self, + func: "Callable[[V2CoreAccountPersonUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonUpdatedEvent` (`v2.core.account_person.updated`) event notification. + """ + self._register( + "v2.core.account_person.updated", + func, + ) + return func + + def on_v2_core_account_updated( + self, + func: "Callable[[V2CoreAccountUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountUpdatedEvent` (`v2.core.account.updated`) event notification. + """ + self._register( + "v2.core.account.updated", + func, + ) + return func + + def on_v2_core_event_destination_ping( + self, + func: "Callable[[V2CoreEventDestinationPingEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreEventDestinationPingEvent` (`v2.core.event_destination.ping`) event notification. + """ + self._register( + "v2.core.event_destination.ping", + func, + ) + return func + + # event-notification-registration-methods: The end of the section generated from our OpenAPI spec + + +class StripeEventNotificationHandler(_BaseEventNotificationHandler): + """ + An on-rails experience for handling Stripe event notifications. Define callbacks for individual event types and an instance of this class will be responsible for verifying and routing the event. + """ + + def __init__( + self, + client: "StripeClient", + webhook_secret: str, + fallback_callback: FallbackCallback, + ) -> None: + super().__init__(client, fallback_callback) + if not webhook_secret: + raise ValueError("webhook_secret must be a non-empty string") + self._webhook_secret = webhook_secret + + def handle(self, webhook_body: str, sig_header: str): + # set before parsing, so that even a failed parse locks out registration. + # modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup + # making a race condition here unlikely + self._has_handled_events = True + + event_notif = self._client.parse_event_notification( + webhook_body, sig_header, self._webhook_secret + ) + + self._dispatch(event_notif) + + @staticmethod + def without_verification( + client: "StripeClient", + fallback_callback: FallbackCallback, + ) -> "StripeEventNotificationHandlerWithoutVerification": + return StripeEventNotificationHandlerWithoutVerification( + client, fallback_callback + ) + + +class StripeEventNotificationHandlerWithoutVerification( + _BaseEventNotificationHandler +): + """ + A variant of StripeEventNotificationHandler that parses events without verifying webhook signatures. Intended for pre-authenticated channels like AWS EventBridge, Azure Event Grid, or your own pre-authenticated queuing system. + + Prefer `StripeEventNotificationHandler.without_verification()` or `client.notification_handler_without_verification()` instead of constructing it directly. + """ + + def handle(self, webhook_body: str): + self._has_handled_events = True + + event_notif = ( + self._client.parse_event_notification_without_verification( + webhook_body + ) + ) + + self._dispatch(event_notif) diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index c086ba3d5..a810170e4 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -9,6 +9,11 @@ from stripe._api_mode import ApiMode from stripe._error import AuthenticationError +from stripe._event_notification_handler import ( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + FallbackCallback, +) from stripe._request_options import extract_options_from_dict from stripe._requestor_options import RequestorOptions, BaseAddresses from stripe._client_options import _ClientOptions @@ -333,6 +338,51 @@ def deserialize( api_mode=api_mode, ) + def with_stripe_context( + self, stripe_context: "Optional[Union[str, StripeContext]]" + ) -> "StripeClient": + """ + Creates a new StripeClient with the same configuration as this client, + but with a different stripe_context. This is useful for handling webhooks + where each event may have its own context. + + The new client reuses the HTTP client from this client to avoid + re-establishing TLS connections. + """ + return StripeClient( + api_key=self._requestor.api_key, # type: ignore + stripe_account=self._requestor._options.stripe_account, + stripe_context=stripe_context, + stripe_version=self._requestor._options.stripe_version, + base_addresses=self._requestor._options.base_addresses, + client_id=self._options.client_id, + max_network_retries=self._requestor._options.max_network_retries, + http_client=self._requestor._client, + ) + + def notification_handler( + self, webhook_secret: str, fallback_callback: FallbackCallback + ) -> StripeEventNotificationHandler: + """ + Returns an StripeEventNotificationHandler instance tied to this client. + """ + return StripeEventNotificationHandler( + self, webhook_secret, fallback_callback + ) + + def notification_handler_without_verification( + self, fallback_callback: FallbackCallback + ) -> StripeEventNotificationHandlerWithoutVerification: + """ + A variant of StripeEventNotificationHandler that parses events without + verifying webhook signatures. Intended for pre-authenticated channels + like AWS EventBridge, Azure Event Grid, or your own queue system that + verifies payloads before storage. + """ + return StripeEventNotificationHandler.without_verification( + self, fallback_callback + ) + # deprecated v1 services: The beginning of the section generated from our OpenAPI spec @property @deprecated( diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py new file mode 100644 index 000000000..9ad872843 --- /dev/null +++ b/tests/test_event_notification_handler.py @@ -0,0 +1,827 @@ +import json +import pytest +from typing import Optional +from unittest.mock import Mock + +from stripe import SignatureVerificationError, StripeClient +from stripe._event_notification_handler import ( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + UnhandledNotificationDetails, +) +from stripe._stripe_context import StripeContext +from stripe.events._v1_billing_meter_error_report_triggered_event import ( + V1BillingMeterErrorReportTriggeredEventNotification, +) +from stripe.events._v2_core_account_created_event import ( + V2CoreAccountCreatedEventNotification, +) +from stripe.v2.core._event import EventNotification, UnknownEventNotification +from tests.http_client_mock import HTTPClientMock +from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header + + +class TestEventNotificationHandler: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> Mock: + """Mock handler for unhandled events""" + return Mock() + + @pytest.fixture(scope="function") + def event_handler( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> StripeEventNotificationHandler: + return StripeEventNotificationHandler( + client=stripe_client, + webhook_secret=DUMMY_WEBHOOK_SECRET, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + """A payload for v1.billing.meter.error_report_triggered event""" + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def v2_account_created_payload(self) -> str: + """A payload for v2.core.account.created event with None context""" + return json.dumps( + { + "id": "evt_789", + "object": "v2.core.event", + "type": "v2.core.account.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": None, + "related_object": { + "id": "acct_abc", + "type": "account", + "url": "/v2/core/accounts/acct_abc", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + """A payload for an unknown event type (llama.created)""" + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + def test_routes_event_to_registered_handler( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that a registered event type is routed to the correct handler""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_called_once() + + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + ) + + fallback_callback.assert_not_called() + + def test_routes_different_events_to_correct_handlers( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + v2_account_created_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that different event types route to their respective handlers""" + billing_handler = Mock() + account_handler = Mock() + + event_handler.on_v1_billing_meter_error_report_triggered( + billing_handler + ) + event_handler.on_v2_core_account_created(account_handler) + + sig_header1 = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header1) + + sig_header2 = generate_header(payload=v2_account_created_payload) + event_handler.handle(v2_account_created_payload, sig_header2) + + billing_handler.assert_called_once() + account_handler.assert_called_once() + + assert isinstance( + billing_handler.call_args[0][0], + V1BillingMeterErrorReportTriggeredEventNotification, + ) + assert isinstance( + account_handler.call_args[0][0], + V2CoreAccountCreatedEventNotification, + ) + + fallback_callback.assert_not_called() + + def test_handler_receives_correct_runtime_type( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that handlers receive the correctly typed event notification""" + received_event: Optional[EventNotification] = None + received_client: Optional[StripeClient] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_event, received_client + received_event = event + received_client = client + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert isinstance( + received_event, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert received_event.type == "v1.billing.meter.error_report_triggered" + assert received_event.id == "evt_123" + assert received_event.related_object.id == "mtr_123" + assert isinstance(received_client, StripeClient) + + def test_cannot_register_handler_after_handling( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that registering handlers after handle() raises RuntimeError""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + event_handler.on_v2_core_account_created(Mock()) + + def test_failed_parse_still_prevents_registration( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Attempting to handle an event locks registration even if the parse fails""" + with pytest.raises(SignatureVerificationError): + event_handler.handle(v1_billing_meter_payload, "t=1,v1=not-a-sig") + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + event_handler.on_v2_core_account_created(Mock()) + + def test_cannot_register_duplicate_handler( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registering the same event type twice raises ValueError""" + handler1 = Mock() + handler2 = Mock() + + event_handler.on_v1_billing_meter_error_report_triggered(handler1) + + with pytest.raises( + ValueError, + match='Handler for event type "v1.billing.meter.error_report_triggered" already registered', + ): + event_handler.on_v1_billing_meter_error_report_triggered(handler2) + + def test_handler_uses_event_stripe_context( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the handler receives a client with stripe_context from the event""" + received_context: Optional[StripeContext | str] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_context + received_context = client._requestor._options.stripe_context + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert str(received_context) == "event_context_456" + + def test_stripe_context_restored_after_handler_success( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the original stripe_context is restored after successful handler execution""" + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + assert ( + str(client._requestor._options.stripe_context) + == "event_context_456" + ) + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_stripe_context_restored_after_handler_error( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the original stripe_context is restored even when handler raises an exception""" + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + assert ( + str(client._requestor._options.stripe_context) + == "event_context_456" + ) + raise RuntimeError("Handler error!") + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + + with pytest.raises(RuntimeError, match="Handler error!"): + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_stripe_context_set_to_none_when_event_has_no_context( + self, + event_handler: StripeEventNotificationHandler, + v2_account_created_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that stripe_context is set to None when event context is None""" + received_context: Optional[StripeContext | str] = None + + def handler( + event: V2CoreAccountCreatedEventNotification, client: StripeClient + ) -> None: + nonlocal received_context + received_context = client._requestor._options.stripe_context + + event_handler.on_v2_core_account_created(handler) + + # Verify we're working with StripeContext instances + assert isinstance( + stripe_client._requestor._options.stripe_context, StripeContext + ) + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v2_account_created_payload) + event_handler.handle(v2_account_created_payload, sig_header) + + assert received_context is None + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_unknown_event_routes_to_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that events without SDK types route to on_unhandled handler""" + sig_header = generate_header(payload=unknown_event_payload) + + event_handler.handle(unknown_event_payload, sig_header) + + fallback_callback.assert_called_once() + + call_args = fallback_callback.call_args[0] + event_notif = call_args[0] + client = call_args[1] + info = call_args[2] + + assert isinstance(event_notif, UnknownEventNotification) + assert event_notif.type == "llama.created" + assert isinstance(client, StripeClient) + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is False + + def test_known_unregistered_event_routes_to_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that known event types without a registered handler route to on_unhandled""" + sig_header = generate_header(payload=v1_billing_meter_payload) + + event_handler.handle(v1_billing_meter_payload, sig_header) + + fallback_callback.assert_called_once() + + call_args = fallback_callback.call_args[0] + event_notif = call_args[0] + client = call_args[1] + info = call_args[2] + + assert isinstance( + event_notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert event_notif.type == "v1.billing.meter.error_report_triggered" + assert isinstance(client, StripeClient) + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_registered_event_does_not_call_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that registered events don't trigger on_unhandled""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_called_once() + fallback_callback.assert_not_called() + + def test_handler_client_retains_configuration( + self, + http_client_mock: HTTPClientMock, + fallback_callback: Mock, + v1_billing_meter_payload: str, + ) -> None: + """Test that the client passed to handlers retains all configuration except stripe_context""" + api_key = "sk_test_custom_key" + original_context = "original_context_xyz" + + client = StripeClient( + api_key=api_key, + stripe_context=StripeContext.parse(original_context), + http_client=http_client_mock.get_mock_http_client(), + ) + + notif_handler = StripeEventNotificationHandler( + client=client, + webhook_secret=DUMMY_WEBHOOK_SECRET, + fallback_callback=fallback_callback, + ) + + received_api_key: Optional[str] = None + received_context: Optional[StripeContext | str] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_api_key, received_context + received_api_key = client._requestor.api_key + received_context = client._requestor._options.stripe_context + + notif_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + notif_handler.handle(v1_billing_meter_payload, sig_header) + + assert received_api_key == api_key + assert str(received_context) == "event_context_456" + assert ( + str(client._requestor._options.stripe_context) == original_context + ) + + def test_on_unhandled_receives_correct_info_for_unknown( + self, + event_handler: StripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that on_unhandled receives correct UnhandledNotificationDetails for unknown events""" + sig_header = generate_header(payload=unknown_event_payload) + + event_handler.handle(unknown_event_payload, sig_header) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is False + + def test_on_unhandled_receives_correct_info_for_known_unregistered( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that on_unhandled receives correct UnhandledNotificationDetails for known unregistered events""" + sig_header = generate_header(payload=v1_billing_meter_payload) + + event_handler.handle(v1_billing_meter_payload, sig_header) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_validates_webhook_signature( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that invalid webhook signatures are rejected""" + from stripe._error import SignatureVerificationError + + with pytest.raises(SignatureVerificationError): + event_handler.handle(v1_billing_meter_payload, "invalid_signature") + + def test_registered_event_types_empty( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns empty list when no handlers are registered""" + assert event_handler.registered_event_types == [] + + def test_registered_event_types_single( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns a single event type""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert event_handler.registered_event_types == [ + "v1.billing.meter.error_report_triggered" + ] + + def test_registered_event_types_multiple_alphabetized( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns multiple event types in alphabetical order""" + handler = Mock() + + # Register in non-alphabetical order + event_handler.on_v2_core_account_updated(handler) + event_handler.on_v1_billing_meter_error_report_triggered(handler) + event_handler.on_v2_core_account_created(handler) + + expected = [ + "v1.billing.meter.error_report_triggered", + "v2.core.account.created", + "v2.core.account.updated", + ] + + assert event_handler.registered_event_types == expected + + def test_can_call_wrapped_functions( + self, event_handler: StripeEventNotificationHandler + ): + @event_handler.on_v1_billing_meter_error_report_triggered # type: ignore + def rand_int(notif, client): + """cool docstring""" + return 4 + + assert rand_int(None, None) == 4 # type: ignore + + def test_rejects_empty_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects an empty webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret="", + fallback_callback=fallback_callback, + ) + + def test_rejects_none_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects a None webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret=None, # type: ignore + fallback_callback=fallback_callback, + ) + + +class TestEventNotificationHandlerWithoutVerification: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> Mock: + return Mock() + + @pytest.fixture(scope="function") + def handler_without_verification( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> StripeEventNotificationHandlerWithoutVerification: + return StripeEventNotificationHandler.without_verification( + client=stripe_client, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + def test_routes_event_to_registered_handler( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + ) + fallback_callback.assert_not_called() + + def test_handle_takes_single_argument( + self, + handler_without_verification, + v1_billing_meter_payload: str, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + # No signature needed - just the payload + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + + def test_fallback_receives_unregistered_events( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(v1_billing_meter_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_unknown_event_has_is_known_event_type_false( + self, + handler_without_verification, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(unknown_event_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert info.is_known_event_type is False + + def test_context_propagation( + self, + handler_without_verification, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + received_context = None + + def handler(event, client): + nonlocal received_context + received_context = client._requestor._options.stripe_context + + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(v1_billing_meter_payload) + + assert str(received_context) == "event_context_456" + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_static_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + handler = StripeEventNotificationHandler.without_verification( + stripe_client, fallback_callback + ) + assert isinstance( + handler, StripeEventNotificationHandlerWithoutVerification + ) + + def test_failed_parse_still_prevents_registration( + self, + handler_without_verification: StripeEventNotificationHandlerWithoutVerification, + ) -> None: + """Attempting to handle an event locks registration even if the parse fails""" + with pytest.raises(ValueError): + handler_without_verification.handle("not json") + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + handler_without_verification.on_v2_core_account_created(Mock()) + + def test_handlers_are_siblings_not_subclasses(self) -> None: + """ + Neither handler is substitutable for the other, so neither should be a + subclass of the other. Each defines its own `handle` over a shared base. + """ + assert not issubclass( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + ) + assert not issubclass( + StripeEventNotificationHandlerWithoutVerification, + StripeEventNotificationHandler, + ) + + def test_client_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + handler = stripe_client.notification_handler_without_verification( + fallback_callback + ) + assert handler is not None + assert hasattr(handler, "handle") + + def test_handles_cloud_provider_envelope( + self, + handler_without_verification, + ) -> None: + """Test that events wrapped in cloud provider envelopes are parsed correctly""" + inner_payload = { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + # AWS EventBridge envelope + eventbridge_payload = json.dumps( + { + "version": "0", + "id": "abc-123", + "source": "aws.partner/stripe.com/ed_xxx", + "detail-type": "event", + "detail": inner_payload, + } + ) + + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(eventbridge_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + )