- Fix pipeline granularity: add market_year to cleaned/serving SQL models - Add DuckDB data access layer with async query functions (analytics.py) - Build Chart.js dashboard: supply/demand, STU ratio, top producers, YoY table - Add country comparison page with multi-select picker - Replace items CRUD with read-only commodity API (list, metrics, countries, CSV) - Configure BeanFlows plan tiers (Free/Starter/Pro) with feature gating - Rewrite public pages for coffee market intelligence positioning - Remove boilerplate items schema, update health check for DuckDB - Add test suite: 139 tests passing (dashboard, API, billing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
275 lines
9.7 KiB
Python
275 lines
9.7 KiB
Python
"""
|
|
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
|