""" Integration tests for Paddle webhook handling + Stripe parse_webhook unit tests. Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing. """ import json import pytest from conftest import make_webhook_payload, sign_payload from hypothesis import HealthCheck, given from hypothesis import settings as h_settings from hypothesis import strategies as st from padelnomics.billing.routes import get_subscription from padelnomics.billing.stripe import parse_webhook as stripe_parse_webhook WEBHOOK_PATH = "/billing/webhook/paddle" SIG_HEADER = "Paddle-Signature" # ════════════════════════════════════════════════════════════ # Signature Verification # ════════════════════════════════════════════════════════════ class TestWebhookSignature: async def test_missing_signature_rejected(self, client, db): payload = make_webhook_payload("subscription.activated") payload_bytes = json.dumps(payload).encode() response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={"Content-Type": "application/json"}, ) assert response.status_code in (400, 401) async def test_invalid_signature_rejected(self, client, db): payload = make_webhook_payload("subscription.activated") payload_bytes = json.dumps(payload).encode() response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"}, ) assert response.status_code in (400, 401) async def test_valid_signature_accepted(self, client, db, test_user): payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"])) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) async def test_modified_payload_rejected(self, client, db, test_user): payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"])) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) tampered = payload_bytes + b"extra" response = await client.post( WEBHOOK_PATH, data=tampered, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (400, 401) async def test_empty_payload_rejected(self, client, db): sig = sign_payload(b"") response = await client.post( WEBHOOK_PATH, data=b"", headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code == 400 async def test_null_custom_data_does_not_crash(self, client, db): """Paddle sends null custom_data on lifecycle events like subscription.updated.""" payload = { "event_type": "subscription.updated", "data": { "id": "sub_test456", "status": "active", "customer_id": "ctm_test123", "custom_data": None, "current_billing_period": { "starts_at": "2025-02-01T00:00:00.000000Z", "ends_at": "2025-03-01T00:00:00.000000Z", }, }, } payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code == 200 async def test_null_custom_data_activated_does_not_crash(self, client, db): """subscription.activated with null custom_data must not FK-violate on user_id=0.""" payload = { "event_type": "subscription.activated", "data": { "id": "sub_test456", "status": "active", "customer_id": "ctm_test123", "custom_data": None, "current_billing_period": { "starts_at": "2025-02-01T00:00:00.000000Z", "ends_at": "2025-03-01T00:00:00.000000Z", }, }, } payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code == 200 async def test_null_data_does_not_crash(self, client, db): """Guard against data being null in the event payload.""" payload = { "event_type": "subscription.updated", "data": None, } payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code == 200 # ════════════════════════════════════════════════════════════ # Subscription Lifecycle Events # ════════════════════════════════════════════════════════════ class TestWebhookSubscriptionActivated: async def test_creates_subscription(self, client, db, test_user): payload = make_webhook_payload( "subscription.activated", user_id=str(test_user["id"]), plan="starter", ) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) sub = await get_subscription(test_user["id"]) assert sub is not None assert sub["plan"] == "starter" assert sub["status"] == "active" class TestWebhookSubscriptionUpdated: async def test_updates_subscription_status(self, client, db, test_user, create_subscription): await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456") payload = make_webhook_payload( "subscription.updated", subscription_id="sub_test456", status="paused", ) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) sub = await get_subscription(test_user["id"]) assert sub["status"] == "paused" class TestWebhookSubscriptionCanceled: async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription): await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456") payload = make_webhook_payload( "subscription.canceled", subscription_id="sub_test456", ) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) sub = await get_subscription(test_user["id"]) assert sub["status"] == "cancelled" class TestWebhookSubscriptionPastDue: async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription): await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456") payload = make_webhook_payload( "subscription.past_due", subscription_id="sub_test456", ) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) sub = await get_subscription(test_user["id"]) assert sub["status"] == "past_due" # ════════════════════════════════════════════════════════════ # Parameterized: event -> status transitions # ════════════════════════════════════════════════════════════ @pytest.mark.parametrize("event_type,expected_status", [ ("subscription.activated", "active"), ("subscription.updated", "active"), ("subscription.canceled", "cancelled"), ("subscription.past_due", "past_due"), ]) async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status): if event_type != "subscription.activated": await create_subscription(test_user["id"], provider_subscription_id="sub_test456") payload = make_webhook_payload(event_type, user_id=str(test_user["id"])) payload_bytes = json.dumps(payload).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code in (200, 204) sub = await get_subscription(test_user["id"]) assert sub["status"] == expected_status # ════════════════════════════════════════════════════════════ # Hypothesis: fuzz webhook payloads # ════════════════════════════════════════════════════════════ fuzz_event_type = st.sampled_from([ "subscription.activated", "subscription.updated", "subscription.canceled", "subscription.past_due", ]) fuzz_status = st.sampled_from(["active", "paused", "past_due", "canceled"]) @st.composite def fuzz_payload(draw): event_type = draw(fuzz_event_type) return make_webhook_payload( event_type=event_type, subscription_id=f"sub_{draw(st.integers(min_value=100, max_value=999999))}", user_id=str(draw(st.integers(min_value=1, max_value=999999))), status=draw(fuzz_status), ) class TestWebhookHypothesis: @given(payload_dict=fuzz_payload()) @h_settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture]) async def test_webhook_never_500s(self, client, db, test_user, payload_dict): # Pin user_id to the test user so subscription_created/activated events don't hit FK violations payload_dict["data"]["custom_data"]["user_id"] = str(test_user["id"]) payload_bytes = json.dumps(payload_dict).encode() sig = sign_payload(payload_bytes) response = await client.post( WEBHOOK_PATH, data=payload_bytes, headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) assert response.status_code < 500 # ════════════════════════════════════════════════════════════ # Stripe parse_webhook unit tests # ════════════════════════════════════════════════════════════ def _stripe_event(event_type, obj, metadata=None): """Build a minimal Stripe event payload.""" if metadata: obj["metadata"] = metadata return json.dumps({"type": event_type, "data": {"object": obj}}).encode() class TestStripeParseWebhook: def test_subscription_created_maps_to_activated(self): payload = _stripe_event( "customer.subscription.created", {"id": "sub_123", "customer": "cus_456", "status": "active", "current_period_end": 1740000000, "items": {"data": [{"price": {"id": "price_abc"}}]}}, metadata={"user_id": "42", "plan": "starter"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.activated" assert ev["subscription_id"] == "sub_123" assert ev["customer_id"] == "cus_456" assert ev["user_id"] == "42" assert ev["plan"] == "starter" assert ev["status"] == "active" assert ev["items"] == [{"price": {"id": "price_abc"}}] def test_subscription_updated_maps_correctly(self): payload = _stripe_event( "customer.subscription.updated", {"id": "sub_123", "customer": "cus_456", "status": "past_due", "current_period_end": 1740000000, "items": {"data": [{"price": {"id": "price_abc"}}]}}, metadata={"user_id": "42", "plan": "pro"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.updated" assert ev["status"] == "past_due" def test_subscription_deleted_maps_to_canceled(self): payload = _stripe_event( "customer.subscription.deleted", {"id": "sub_123", "customer": "cus_456", "status": "canceled", "current_period_end": 1740000000, "items": {"data": []}}, metadata={"user_id": "42"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.canceled" assert ev["status"] == "cancelled" def test_invoice_payment_failed_maps_to_past_due(self): payload = _stripe_event( "invoice.payment_failed", {"subscription": "sub_123", "customer": "cus_456"}, metadata={}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.past_due" assert ev["status"] == "past_due" def test_checkout_session_subscription_maps_to_activated(self): payload = _stripe_event( "checkout.session.completed", {"mode": "subscription", "subscription": "sub_123", "customer": "cus_456"}, metadata={"user_id": "42", "plan": "starter"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.activated" assert ev["subscription_id"] == "sub_123" def test_checkout_session_payment_maps_to_transaction(self): payload = _stripe_event( "checkout.session.completed", {"mode": "payment", "customer": "cus_456"}, metadata={"user_id": "42"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "transaction.completed" def test_unknown_event_returns_empty_type(self): payload = _stripe_event("some.unknown.event", {"customer": "cus_456"}) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "" def test_period_end_from_items_fallback(self): """Stripe API 2026-02+ puts current_period_end on items, not subscription.""" payload = _stripe_event( "customer.subscription.created", {"id": "sub_123", "customer": "cus_456", "status": "active", "items": {"data": [{"price": {"id": "price_abc"}, "current_period_end": 1740000000}]}}, metadata={"user_id": "42"}, ) ev = stripe_parse_webhook(payload) assert ev["current_period_end"] is not None assert "2025-02-19" in ev["current_period_end"] def test_trialing_status_maps_to_on_trial(self): payload = _stripe_event( "customer.subscription.created", {"id": "sub_123", "customer": "cus_456", "status": "trialing", "current_period_end": 1740000000, "items": {"data": []}}, metadata={"user_id": "42"}, ) ev = stripe_parse_webhook(payload) assert ev["event_type"] == "subscription.activated" assert ev["status"] == "on_trial"