| """ |
| Comprehensive webhook processing tests covering signature verification, |
| deduplication, out-of-order delivery, retries, timeouts, and event types. |
| |
| Tests use stripe-mock server, Factory Boy factories, and webhook simulator |
| for deterministic testing without real API calls. |
| """ |
|
|
| import asyncio |
| import json |
| import os |
| from datetime import datetime, timedelta |
| from typing import Dict, Any |
| from unittest.mock import AsyncMock, MagicMock, Mock |
|
|
| import pytest |
| from freezegun import freeze_time |
|
|
| from integrations.stripe_routes import ( |
| PROCESSED_WEBHOOK_EVENTS, |
| cleanup_processed_events, |
| is_duplicate_event, |
| mark_event_processed, |
| verify_webhook_signature, |
| ) |
| from tests.fixtures.payment_fixtures import ( |
| StripeWebhookEventFactory, |
| StripePaymentIntentFactory, |
| StripeSubscriptionFactory, |
| StripeInvoiceFactory, |
| ) |
| from tests.mocks.webhook_simulator import ( |
| WebhookSimulator, |
| WebhookEventBuilder, |
| generate_stripe_signature, |
| simulate_out_of_order_delivery, |
| simulate_retry_scenario, |
| ) |
|
|
|
|
| class TestWebhookSignatureVerification: |
| """Tests for webhook signature verification (timing-safe comparison).""" |
|
|
| def test_generate_signature_format(self): |
| """Signature generator should produce Stripe-format headers.""" |
| payload = '{"id": "evt_test_123"}' |
| secret = "whsec_test_secret" |
|
|
| signature = generate_stripe_signature(payload, secret) |
|
|
| |
| assert "t=" in signature |
| assert "v1=" in signature |
|
|
| |
| signature2 = generate_stripe_signature(payload, secret) |
| assert signature == signature2 |
|
|
| def test_signature_different_payloads(self): |
| """Different payloads should produce different signatures.""" |
| secret = "whsec_test_secret" |
|
|
| sig1 = generate_stripe_signature('{"id": "evt_1"}', secret) |
| sig2 = generate_stripe_signature('{"id": "evt_2"}', secret) |
|
|
| assert sig1 != sig2 |
|
|
| def test_signature_different_secrets(self): |
| """Different secrets should produce different signatures.""" |
| payload = '{"id": "evt_test"}' |
|
|
| sig1 = generate_stripe_signature(payload, "whsec_secret1") |
| sig2 = generate_stripe_signature(payload, "whsec_secret2") |
|
|
| assert sig1 != sig2 |
|
|
| def test_verify_webhook_signature_no_secret(self): |
| """ |
| Without webhook secret configured, verification should skip |
| and still parse payload (for testing). |
| """ |
| payload = b'{"id": "evt_test", "type": "payment_intent.succeeded"}' |
| signature = "t=123,v1=abc" |
|
|
| |
| event = verify_webhook_signature(payload, signature, "") |
| assert event["id"] == "evt_test" |
|
|
| def test_webhook_simulator_sends_events(self): |
| """WebhookSimulator should send events to endpoints.""" |
| simulator = WebhookSimulator("http://localhost:12111/webhook") |
| event_builder = WebhookEventBuilder() |
| event = event_builder.create_payment_succeeded("pi_test", 1000) |
|
|
| |
| |
| try: |
| response = simulator.send_event(event) |
| except Exception: |
| pass |
|
|
| simulator.close() |
|
|
|
|
| class TestWebhookDeduplication: |
| """Tests for webhook event deduplication (prevent double-processing).""" |
|
|
| @pytest.fixture(autouse=True) |
| def clear_cache(self): |
| """Clear deduplication cache before each test.""" |
| PROCESSED_WEBHOOK_EVENTS.clear() |
| yield |
| PROCESSED_WEBHOOK_EVENTS.clear() |
|
|
| def test_mark_and_check_duplicate(self): |
| """mark_event_processed should make is_duplicate_event return True.""" |
| event_id = "evt_test_duplicate" |
|
|
| |
| assert not is_duplicate_event(event_id) |
|
|
| |
| mark_event_processed(event_id) |
|
|
| |
| assert is_duplicate_event(event_id) |
|
|
| def test_different_events_not_duplicates(self): |
| """Different event IDs should not be considered duplicates.""" |
| event1 = "evt_test_1" |
| event2 = "evt_test_2" |
|
|
| mark_event_processed(event1) |
|
|
| assert is_duplicate_event(event1) |
| assert not is_duplicate_event(event2) |
|
|
| def test_cleanup_old_events(self): |
| """cleanup_processed_events should remove events older than 24h.""" |
| |
| old_timestamp = int((datetime.now() - timedelta(hours=25)).timestamp()) |
| PROCESSED_WEBHOOK_EVENTS["evt_old"] = old_timestamp |
|
|
| |
| recent_timestamp = int((datetime.now() - timedelta(hours=1)).timestamp()) |
| PROCESSED_WEBHOOK_EVENTS["evt_recent"] = recent_timestamp |
|
|
| |
| cleanup_processed_events() |
|
|
| |
| assert "evt_old" not in PROCESSED_WEBHOOK_EVENTS |
|
|
| |
| assert "evt_recent" in PROCESSED_WEBHOOK_EVENTS |
|
|
| def test_cleanup_preserves_recent_events(self): |
| """Cleanup should not remove events from last 24 hours.""" |
| |
| now = int(datetime.now().timestamp()) |
|
|
| PROCESSED_WEBHOOK_EVENTS["evt_1h_ago"] = now - 3600 |
| PROCESSED_WEBHOOK_EVENTS["evt_12h_ago"] = now - 43200 |
| PROCESSED_WEBHOOK_EVENTS["evt_23h_ago"] = now - 82800 |
|
|
| cleanup_processed_events() |
|
|
| |
| assert "evt_1h_ago" in PROCESSED_WEBHOOK_EVENTS |
| assert "evt_12h_ago" in PROCESSED_WEBHOOK_EVENTS |
| assert "evt_23h_ago" in PROCESSED_WEBHOOK_EVENTS |
|
|
|
|
| class TestWebhookOutOfOrderDelivery: |
| """Tests for out-of-order webhook delivery handling.""" |
|
|
| @pytest.fixture(autouse=True) |
| def clear_cache(self): |
| """Clear deduplication cache before each test.""" |
| PROCESSED_WEBHOOK_EVENTS.clear() |
| yield |
| PROCESSED_WEBHOOK_EVENTS.clear() |
|
|
| def test_simulate_out_of_order_delivery(self): |
| """simulate_out_of_order_delivery should shuffle events.""" |
| events = [ |
| {"id": "evt_1", "type": "event.1"}, |
| {"id": "evt_2", "type": "event.2"}, |
| {"id": "evt_3", "type": "event.3"}, |
| ] |
|
|
| shuffled = simulate_out_of_order_delivery(events) |
|
|
| |
| assert len(shuffled) == len(events) |
|
|
| |
| assert {e["id"] for e in shuffled} == {e["id"] for e in events} |
|
|
| def test_out_of_order_events_still_processed(self): |
| """ |
| Events processed out of order should all be handled correctly. |
| Deduplication prevents double-processing. |
| """ |
| |
| event_payment = StripeWebhookEventFactory( |
| type="payment_intent.succeeded" |
| ) |
| event_invoice = StripeWebhookEventFactory( |
| type="invoice.payment_succeeded" |
| ) |
|
|
| |
| payment_id_1 = event_payment["id"] |
| invoice_id = event_invoice["id"] |
|
|
| |
| mark_event_processed(invoice_id) |
|
|
| |
| mark_event_processed(payment_id_1) |
|
|
| |
| assert is_duplicate_event(invoice_id) |
| assert is_duplicate_event(payment_id_1) |
|
|
| def test_random_shuffle_doesnt_lose_events(self): |
| """Random shuffling should preserve all events.""" |
| import random |
|
|
| events = [ |
| StripeWebhookEventFactory(type="payment_intent.succeeded") |
| for _ in range(20) |
| ] |
|
|
| event_ids = {e["id"] for e in events} |
|
|
| |
| for _ in range(10): |
| shuffled = simulate_out_of_order_delivery(events) |
| shuffled_ids = {e["id"] for e in shuffled} |
| assert shuffled_ids == event_ids |
|
|
|
|
| class TestWebhookRetries: |
| """Tests for webhook retry behavior with exponential backoff.""" |
|
|
| def test_exponential_backoff_delays(self): |
| """ |
| Retry delays should follow exponential backoff: 1s, 2s, 4s. |
| Uses freezegun to verify timing without actual delays. |
| """ |
| scenario = simulate_retry_scenario(2) |
|
|
| |
| assert scenario["fail_attempts"] == [1, 2] |
|
|
| |
| assert scenario["succeed_on"] == 3 |
|
|
| |
| assert scenario["retry_delays"] == [1.0, 2.0, 4.0] |
|
|
| def test_retry_scenario_configuration(self): |
| """simulate_retry_scenario should return valid configuration.""" |
| scenario_1 = simulate_retry_scenario(1) |
| scenario_3 = simulate_retry_scenario(3) |
|
|
| assert scenario_1["succeed_on"] == 2 |
| assert scenario_3["succeed_on"] == 4 |
|
|
| def test_webhook_simulator_retry_logic(self): |
| """ |
| WebhookSimulator.send_with_retry should implement exponential backoff. |
| """ |
| |
| simulator = WebhookSimulator("http://localhost:9999/webhook") |
| event = {"id": "evt_test", "type": "test"} |
|
|
| |
| call_count = [0] |
|
|
| def mock_post(*args, **kwargs): |
| call_count[0] += 1 |
| if call_count[0] <= 2: |
| return MagicMock(status_code=503) |
| return MagicMock(status_code=200, json=lambda: {"status": "ok"}) |
|
|
| with pytest.MonkeyPatch.context() as m: |
| m.setattr(simulator.client, "post", mock_post) |
|
|
| |
| try: |
| simulator.send_with_retry(event, max_retries=3) |
| except Exception: |
| pass |
|
|
| |
| assert call_count[0] == 3 |
|
|
|
|
| class TestWebhookTimeouts: |
| """Tests for webhook timeout scenarios.""" |
|
|
| def test_burst_delivery_tagging(self): |
| """ |
| simulate_burst_delivery should tag events with timing information. |
| """ |
| from tests.mocks.webhook_simulator import simulate_burst_delivery |
|
|
| events = [ |
| {"id": f"evt_{i}"} |
| for i in range(100) |
| ] |
|
|
| tagged = simulate_burst_delivery(events, events_per_second=100) |
|
|
| |
| assert len(tagged) == 100 |
|
|
| |
| assert "_simulated_delay_ms" in tagged[0] |
|
|
| |
| assert tagged[0]["_simulated_delay_ms"] == 0 |
|
|
| |
| assert tagged[-1]["_simulated_delay_ms"] >= 990 |
|
|
| def test_burst_delivery_different_rates(self): |
| """ |
| Different event rates should produce different delays. |
| """ |
| from tests.mocks.webhook_simulator import simulate_burst_delivery |
|
|
| events = [{"id": "evt_1"}, {"id": "evt_2"}] |
|
|
| |
| tagged_slow = simulate_burst_delivery(events, events_per_second=10) |
|
|
| |
| tagged_fast = simulate_burst_delivery(events, events_per_second=100) |
|
|
| |
| assert tagged_fast[1]["_simulated_delay_ms"] < tagged_slow[1]["_simulated_delay_ms"] |
|
|
|
|
| class TestWebhookEventTypes: |
| """Tests for different Stripe webhook event types.""" |
|
|
| @pytest.fixture(autouse=True) |
| def clear_cache(self): |
| """Clear deduplication cache before each test.""" |
| PROCESSED_WEBHOOK_EVENTS.clear() |
| yield |
| PROCESSED_WEBHOOK_EVENTS.clear() |
|
|
| def test_webhook_event_builder_creates_valid_events(self): |
| """WebhookEventBuilder should create properly formatted events.""" |
| builder = WebhookEventBuilder() |
|
|
| |
| event1 = builder.create_payment_succeeded("pi_test", 1000) |
| assert event1["type"] == "payment_intent.succeeded" |
| assert event1["data"]["object"]["id"] == "pi_test" |
| assert event1["data"]["object"]["amount"] == 1000 |
|
|
| |
| event2 = builder.create_payment_failed("pi_test", "card_declined") |
| assert event2["type"] == "payment_intent.payment_failed" |
| assert event2["data"]["object"]["last_payment_error"]["code"] == "card_declined" |
|
|
| |
| event3 = builder.create_subscription_created("sub_test") |
| assert event3["type"] == "customer.subscription.created" |
| assert event3["data"]["object"]["id"] == "sub_test" |
|
|
| |
| event4 = builder.create_invoice_paid("in_test", 2000) |
| assert event4["type"] == "invoice.payment_succeeded" |
| assert event4["data"]["object"]["amount_paid"] == 2000 |
|
|
| def test_factory_creates_webhook_events(self): |
| """StripeWebhookEventFactory should create valid events.""" |
| event = StripeWebhookEventFactory(type="payment_intent.succeeded") |
|
|
| assert "id" in event |
| assert event["type"] == "payment_intent.succeeded" |
| assert "data" in event |
| assert "object" in event["data"] |
|
|
| def test_all_event_types_have_ids(self): |
| """All webhook events should have unique IDs.""" |
| builder = WebhookEventBuilder() |
|
|
| events = [ |
| builder.create_payment_succeeded("pi_1", 1000), |
| builder.create_payment_failed("pi_2", "card_declined"), |
| builder.create_subscription_created("sub_1"), |
| builder.create_invoice_paid("in_1", 1000), |
| ] |
|
|
| |
| event_ids = [e["id"] for e in events] |
| assert len(event_ids) == len(set(event_ids)) |
|
|
| def test_deduplication_prevents_double_processing(self): |
| """ |
| Deduplication cache should prevent same event from being processed twice. |
| """ |
| event_id = "evt_test_dedup" |
|
|
| |
| assert not is_duplicate_event(event_id) |
|
|
| |
| mark_event_processed(event_id) |
|
|
| |
| assert is_duplicate_event(event_id) |
|
|
|
|
| class TestWebhookSimulator: |
| """Tests for WebhookSimulator functionality.""" |
|
|
| def test_webhook_simulator_initialization(self): |
| """WebhookSimulator should initialize with endpoint URL.""" |
| simulator = WebhookSimulator("http://localhost:8000/webhook") |
|
|
| assert simulator.webhook_endpoint_url == "http://localhost:8000/webhook" |
| assert simulator.client is not None |
|
|
| simulator.close() |
|
|
| def test_webhook_simulator_context_manager(self): |
| """WebhookSimulator should work as context manager.""" |
| with WebhookSimulator("http://localhost:8000/webhook") as simulator: |
| assert simulator.webhook_endpoint_url == "http://localhost:8000/webhook" |
| |
|
|
| def test_send_event_without_server(self): |
| """Sending event without server should handle gracefully.""" |
| simulator = WebhookSimulator("http://localhost:9999/no-server") |
|
|
| event_builder = WebhookEventBuilder() |
| event = event_builder.create_payment_succeeded("pi_test", 1000) |
|
|
| |
| with pytest.raises(Exception): |
| simulator.send_event(event) |
|
|
| simulator.close() |
|
|
| def test_send_duplicate_events(self): |
| """send_duplicate should send same event multiple times.""" |
| |
| simulator = WebhookSimulator("http://localhost:9999/webhook") |
| event = {"id": "evt_test"} |
|
|
| |
| with pytest.MonkeyPatch.context() as m: |
| mock_responses = [ |
| MagicMock(status_code=200), |
| MagicMock(status_code=200), |
| ] |
|
|
| call_count = [0] |
|
|
| def mock_post(*args, **kwargs): |
| response = mock_responses[call_count[0]] |
| call_count[0] += 1 |
| return response |
|
|
| m.setattr(simulator.client, "post", mock_post) |
|
|
| responses = simulator.send_duplicate(event, count=2) |
|
|
| assert len(responses) == 2 |
|
|
| simulator.close() |
|
|