""" Integration tests for Paddle webhook handling. Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing. """ import json import pytest from hypothesis import HealthCheck, given from hypothesis import settings as h_settings from hypothesis import strategies as st from beanflows.billing.routes import get_subscription from conftest import make_webhook_payload, sign_payload 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" # Paddle/LemonSqueezy: HMAC signature verification fails before JSON parsing 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"") with pytest.raises(Exception): # JSONDecodeError in TESTING mode await client.post( WEBHOOK_PATH, data=b"", headers={SIG_HEADER: sig, "Content-Type": "application/json"}, ) # ════════════════════════════════════════════════════════════ # 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", paddle_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", paddle_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", paddle_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"], paddle_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