File size: 16,478 Bytes
aef804e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
"""
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)

        # Should have timestamp and v1 hash
        assert "t=" in signature
        assert "v1=" in signature

        # Should be deterministic for same payload
        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"

        # No secret configured - should parse anyway
        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)

        # Test that send_event doesn't crash
        # (Will fail to connect, but that's OK for this test)
        try:
            response = simulator.send_event(event)
        except Exception:
            pass  # Expected - no server running

        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"

        # Initially not a duplicate
        assert not is_duplicate_event(event_id)

        # Mark as processed
        mark_event_processed(event_id)

        # Now it's a duplicate
        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."""
        # Add event with old timestamp (>24 hours ago)
        old_timestamp = int((datetime.now() - timedelta(hours=25)).timestamp())
        PROCESSED_WEBHOOK_EVENTS["evt_old"] = old_timestamp

        # Add recent event
        recent_timestamp = int((datetime.now() - timedelta(hours=1)).timestamp())
        PROCESSED_WEBHOOK_EVENTS["evt_recent"] = recent_timestamp

        # Run cleanup
        cleanup_processed_events()

        # Old event should be removed
        assert "evt_old" not in PROCESSED_WEBHOOK_EVENTS

        # Recent event should remain
        assert "evt_recent" in PROCESSED_WEBHOOK_EVENTS

    def test_cleanup_preserves_recent_events(self):
        """Cleanup should not remove events from last 24 hours."""
        # Add events at various ages
        now = int(datetime.now().timestamp())

        PROCESSED_WEBHOOK_EVENTS["evt_1h_ago"] = now - 3600  # 1 hour ago
        PROCESSED_WEBHOOK_EVENTS["evt_12h_ago"] = now - 43200  # 12 hours ago
        PROCESSED_WEBHOOK_EVENTS["evt_23h_ago"] = now - 82800  # 23 hours ago

        cleanup_processed_events()

        # All should still be there
        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)

        # Should have same number of events
        assert len(shuffled) == len(events)

        # Should have all same IDs
        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.
        """
        # Create events in chronological order using factories
        event_payment = StripeWebhookEventFactory(
            type="payment_intent.succeeded"
        )
        event_invoice = StripeWebhookEventFactory(
            type="invoice.payment_succeeded"
        )

        # Process out of order: invoice first
        payment_id_1 = event_payment["id"]
        invoice_id = event_invoice["id"]

        # Mark invoice as processed first
        mark_event_processed(invoice_id)

        # Then payment
        mark_event_processed(payment_id_1)

        # Both should be in cache
        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}

        # Shuffle multiple times
        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)

        # Should fail on attempts 1 and 2
        assert scenario["fail_attempts"] == [1, 2]

        # Should succeed on attempt 3
        assert scenario["succeed_on"] == 3

        # Delays should be exponential
        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.
        """
        # Create simulator
        simulator = WebhookSimulator("http://localhost:9999/webhook")
        event = {"id": "evt_test", "type": "test"}

        # Mock responses: fail twice, then succeed
        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)

            # This will fail to connect, but tests the logic
            try:
                simulator.send_with_retry(event, max_retries=3)
            except Exception:
                pass

        # Should have attempted 3 times
        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)

        # Should have all events
        assert len(tagged) == 100

        # Should have timing tags
        assert "_simulated_delay_ms" in tagged[0]

        # First event should have minimal delay
        assert tagged[0]["_simulated_delay_ms"] == 0

        # Last event should have delay (~990ms for 100 events at 100/sec)
        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"}]

        # 10 events per second = 100ms between events
        tagged_slow = simulate_burst_delivery(events, events_per_second=10)

        # 100 events per second = 10ms between events
        tagged_fast = simulate_burst_delivery(events, events_per_second=100)

        # Fast rate should have smaller delays
        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()

        # Test payment succeeded
        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

        # Test payment failed
        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"

        # Test subscription created
        event3 = builder.create_subscription_created("sub_test")
        assert event3["type"] == "customer.subscription.created"
        assert event3["data"]["object"]["id"] == "sub_test"

        # Test invoice paid
        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),
        ]

        # All should have unique event IDs
        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"

        # First time - not a duplicate
        assert not is_duplicate_event(event_id)

        # Mark as processed
        mark_event_processed(event_id)

        # Second time - duplicate detected
        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"
        # Should close automatically

    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)

        # Should raise connection error (expected)
        with pytest.raises(Exception):
            simulator.send_event(event)

        simulator.close()

    def test_send_duplicate_events(self):
        """send_duplicate should send same event multiple times."""
        # This test validates the method exists and has correct signature
        simulator = WebhookSimulator("http://localhost:9999/webhook")
        event = {"id": "evt_test"}

        # Mock to avoid connection error
        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()