File size: 14,544 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
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
"""
Webhook Simulator for testing retry, out-of-order, and burst scenarios.

This module provides tools to simulate real-world webhook delivery patterns
that Stripe warns about: duplicate events, out-of-order delivery, retries
with exponential backoff, and burst delivery.
"""

import asyncio
import hashlib
import hmac
import json
import random
import time
from datetime import datetime
from typing import Any, Dict, List, Optional

import httpx


class WebhookSimulator:
    """
    Simulate webhook delivery with retry, out-of-order, and duplicate scenarios.

    This class helps test webhook endpoint robustness by simulating challenging
    delivery patterns that occur in production.

    Example:
        >>> simulator = WebhookSimulator("http://localhost:8000/api/stripe/webhooks")
        >>> event = {"id": "evt_test", "type": "payment_intent.succeeded"}
        >>> response = simulator.send_event(event)
        >>> print(response.status_code)
        200
    """

    def __init__(self, webhook_endpoint_url: str, webhook_secret: Optional[str] = None):
        """
        Initialize webhook simulator.

        Args:
            webhook_endpoint_url: Target URL for webhook delivery
            webhook_secret: Optional secret for signature generation
        """
        self.webhook_endpoint_url = webhook_endpoint_url
        self.webhook_secret = webhook_secret
        self.client = httpx.Client(timeout=30.0)

    def send_event(
        self, event_data: Dict[str, Any], signature: Optional[str] = None
    ) -> httpx.Response:
        """
        Send a single webhook event to the endpoint.

        Args:
            event_data: Event payload (dict)
            signature: Optional pre-generated signature header

        Returns:
            httpx.Response: Response from webhook endpoint
        """
        headers = {"Content-Type": "application/json"}

        # Generate signature if secret provided and no signature supplied
        if self.webhook_secret and signature is None:
            signature = generate_stripe_signature(
                json.dumps(event_data), self.webhook_secret
            )

        if signature:
            headers["Stripe-Signature"] = signature

        return self.client.post(
            self.webhook_endpoint_url,
            json=event_data,
            headers=headers,
        )

    def send_batch(
        self, events: List[Dict[str, Any]], delay_ms: int = 0
    ) -> List[httpx.Response]:
        """
        Send multiple webhooks with optional delay between each.

        Args:
            events: List of event payloads
            delay_ms: Milliseconds to wait between each webhook

        Returns:
            List of responses from webhook endpoint
        """
        responses = []

        for event in events:
            response = self.send_event(event)
            responses.append(response)

            if delay_ms > 0:
                time.sleep(delay_ms / 1000.0)

        return responses

    def send_out_of_order(self, events: List[Dict[str, Any]]) -> List[httpx.Response]:
        """
        Send webhooks in random order (shuffled).

        Simulates Stripe's out-of-order delivery warning.

        Args:
            events: List of event payloads

        Returns:
            List of responses from webhook endpoint
        """
        shuffled_events = simulate_out_of_order_delivery(events)
        return self.send_batch(shuffled_events)

    def send_with_retry(
        self,
        event: Dict[str, Any],
        max_retries: int = 3,
        base_delay: float = 1.0,
    ) -> httpx.Response:
        """
        Send webhook with exponential backoff retry logic.

        Simulates Stripe's retry behavior: 1s, 2s, 4s delays.

        Args:
            event: Event payload
            max_retries: Maximum retry attempts
            base_delay: Initial delay in seconds

        Returns:
            httpx.Response: Final response (success or last failure)
        """
        for attempt in range(max_retries + 1):
            response = self.send_event(event)

            # Success (2xx status)
            if 200 <= response.status_code < 300:
                return response

            # Don't retry client errors (4xx) - they won't succeed on retry
            if 400 <= response.status_code < 500:
                return response

            # Server error (5xx) - retry with exponential backoff
            if attempt < max_retries:
                delay = base_delay * (2**attempt)
                time.sleep(delay)
            else:
                return response

        return response

    def send_duplicate(
        self, event: Dict[str, Any], count: int = 2
    ) -> List[httpx.Response]:
        """
        Send the same event multiple times to test deduplication.

        Args:
            event: Event payload
            count: Number of times to send the event

        Returns:
            List of responses from webhook endpoint
        """
        responses = []

        for _ in range(count):
            response = self.send_event(event)
            responses.append(response)

        return responses

    def close(self):
        """Close the HTTP client."""
        self.client.close()

    def __enter__(self):
        """Context manager entry."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Context manager exit."""
        self.close()


class WebhookEventBuilder:
    """
    Build Stripe webhook events with realistic structure.

    Creates properly formatted webhook events for various Stripe event types.

    Example:
        >>> builder = WebhookEventBuilder()
        >>> event = builder.create_payment_succeeded("pi_123", 1000)
        >>> print(event["type"])
        'payment_intent.succeeded'
    """

    def create_payment_succeeded(
        self, payment_id: str, amount: int, currency: str = "usd"
    ) -> Dict[str, Any]:
        """
        Build a payment_intent.succeeded event.

        Args:
            payment_id: Payment intent ID
            amount: Amount in cents
            currency: Currency code (default: usd)

        Returns:
            Webhook event dictionary
        """
        return {
            "id": f"evt_{generate_event_id()}",
            "object": "event",
            "api_version": "2023-10-16",
            "created": int(datetime.now().timestamp()),
            "type": "payment_intent.succeeded",
            "data": {
                "object": {
                    "id": payment_id,
                    "object": "payment_intent",
                    "amount": amount,
                    "currency": currency,
                    "status": "succeeded",
                    "customer": f"cus_{generate_event_id()}",
                },
                "previous_attributes": None,
            },
            "livemode": False,
            "pending_webhooks": 0,
            "request": None,
            "metadata": {},
        }

    def create_payment_failed(
        self, payment_id: str, error_code: str, error_message: str = "Card declined"
    ) -> Dict[str, Any]:
        """
        Build a payment_intent.payment_failed event.

        Args:
            payment_id: Payment intent ID
            error_code: Stripe error code (e.g., card_declined)
            error_message: Human-readable error message

        Returns:
            Webhook event dictionary
        """
        return {
            "id": f"evt_{generate_event_id()}",
            "object": "event",
            "api_version": "2023-10-16",
            "created": int(datetime.now().timestamp()),
            "type": "payment_intent.payment_failed",
            "data": {
                "object": {
                    "id": payment_id,
                    "object": "payment_intent",
                    "status": "requires_payment_method",
                    "last_payment_error": {
                        "code": error_code,
                        "message": error_message,
                        "type": "card_error",
                    },
                },
                "previous_attributes": None,
            },
            "livemode": False,
            "pending_webhooks": 0,
            "request": None,
            "metadata": {},
        }

    def create_subscription_created(self, subscription_id: str) -> Dict[str, Any]:
        """
        Build a customer.subscription.created event.

        Args:
            subscription_id: Subscription ID

        Returns:
            Webhook event dictionary
        """
        return {
            "id": f"evt_{generate_event_id()}",
            "object": "event",
            "api_version": "2023-10-16",
            "created": int(datetime.now().timestamp()),
            "type": "customer.subscription.created",
            "data": {
                "object": {
                    "id": subscription_id,
                    "object": "subscription",
                    "status": "active",
                    "customer": f"cus_{generate_event_id()}",
                    "current_period_start": int(datetime.now().timestamp()),
                    "current_period_end": int(
                        (datetime.now().timestamp() + 30 * 24 * 60 * 60)
                    ),
                },
                "previous_attributes": None,
            },
            "livemode": False,
            "pending_webhooks": 0,
            "request": None,
            "metadata": {},
        }

    def create_invoice_paid(
        self, invoice_id: str, amount: int, currency: str = "usd"
    ) -> Dict[str, Any]:
        """
        Build an invoice.payment_succeeded event.

        Args:
            invoice_id: Invoice ID
            amount: Amount in cents
            currency: Currency code (default: usd)

        Returns:
            Webhook event dictionary
        """
        return {
            "id": f"evt_{generate_event_id()}",
            "object": "event",
            "api_version": "2023-10-16",
            "created": int(datetime.now().timestamp()),
            "type": "invoice.payment_succeeded",
            "data": {
                "object": {
                    "id": invoice_id,
                    "object": "invoice",
                    "amount_paid": amount,
                    "amount_due": amount,
                    "currency": currency,
                    "status": "paid",
                    "customer": f"cus_{generate_event_id()}",
                },
                "previous_attributes": None,
            },
            "livemode": False,
            "pending_webhooks": 0,
            "request": None,
            "metadata": {},
        }

    def with_signature(
        self, payload: Dict[str, Any], secret: str
    ) -> tuple[Dict[str, Any], str]:
        """
        Add Stripe signature to event payload.

        Args:
            payload: Event payload
            secret: Webhook secret for signature

        Returns:
            Tuple of (payload, signature_header)
        """
        signature = generate_stripe_signature(json.dumps(payload), secret)
        return payload, signature


def generate_stripe_signature(payload: str, secret: str) -> str:
    """
    Generate realistic Stripe webhook signature header.

    Stripe signatures use HMAC-SHA256 with timestamp for replay attack prevention.
    Format: t={timestamp},v1={hmac_signature}

    Args:
        payload: JSON payload as string
        secret: Webhook signing secret

    Returns:
        Signature header value

    Example:
        >>> sig = generate_stripe_signature('{"id": "evt_123"}', 'whsec_abc')
        >>> print(sig)
        't=1234567890,v1=abc123...'
    """
    timestamp = int(datetime.now().timestamp())

    # Construct signed payload
    signed_payload = f"{timestamp}.{payload}"

    # Generate HMAC-SHA256 signature
    signature = hmac.new(
        secret.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    # Return Stripe-format signature header
    return f"t={timestamp},v1={signature}"


def simulate_out_of_order_delivery(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    Shuffle events to simulate out-of-order webhook delivery.

    Stripe explicitly warns webhooks may not arrive in chronological order.

    Args:
        events: List of events in correct order

    Returns:
        Shuffled list of events

    Example:
        >>> events = [{"id": "evt_1"}, {"id": "evt_2"}, {"id": "evt_3"}]
        >>> shuffled = simulate_out_of_order_delivery(events)
        >>> print(shuffled[0]["id"])
        'evt_3'  # Random order
    """
    shuffled = events.copy()
    random.shuffle(shuffled)
    return shuffled


def simulate_burst_delivery(
    events: List[Dict[str, Any]], events_per_second: int = 100
) -> List[Dict[str, Any]]:
    """
    Generate events in bursts (e.g., 100 events in 1 second).

    Simulates high-volume webhook delivery scenarios.

    Args:
        events: List of events
        events_per_second: Target delivery rate

    Returns:
        Events tagged with timing information for burst simulation
    """
    delay_ms = 1000 / events_per_second

    # Tag events with intended delivery time
    tagged_events = []
    for i, event in enumerate(events):
        event_with_timing = {
            **event,
            "_simulated_delay_ms": int(i * delay_ms),
        }
        tagged_events.append(event_with_timing)

    return tagged_events


def simulate_retry_scenario(failure_count: int = 2) -> Dict[str, Any]:
    """
    Generate scenario configuration for retry testing.

    Creates a dict specifying which attempts should fail.

    Args:
        failure_count: Number of initial attempts to fail

    Returns:
        Scenario configuration dict

    Example:
        >>> scenario = simulate_retry_scenario(2)
        >>> print(scenario)
        {'fail_attempts': [1, 2], 'succeed_on': 3}
    """
    return {
        "fail_attempts": list(range(1, failure_count + 1)),
        "succeed_on": failure_count + 1,
        "retry_delays": [1.0, 2.0, 4.0],  # Stripe's exponential backoff
    }


def generate_event_id() -> str:
    """
    Generate unique event ID for testing.

    Returns:
        Random event ID string
    """
    import uuid

    return str(uuid.uuid4())[:8]


__all__ = [
    "WebhookSimulator",
    "WebhookEventBuilder",
    "generate_stripe_signature",
    "simulate_out_of_order_delivery",
    "simulate_burst_delivery",
    "simulate_retry_scenario",
]