fix broken webhook signature verification and stale billing tests

Webhook handler called Verifier().verify() with raw bytes instead of a
request object, so signature verification always failed. Replaced with
manual HMAC check matching Paddle's ts=...;h1=... format. Updated tests
to produce correct signature format, mock the SDK instead of httpx for
manage/cancel routes, and expect JSON for overlay checkout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 16:49:23 +01:00
parent 61bf855103
commit 4e61e9b1ab
6 changed files with 85 additions and 68 deletions

View File

@@ -3,6 +3,7 @@ Shared test fixtures for the Padelnomics test suite.
"""
import hashlib
import hmac
import time
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
@@ -118,7 +119,6 @@ def patch_config():
test_values = {
"PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"},
"BASE_URL": "http://localhost:5000",
"DEBUG": True,
}
@@ -160,5 +160,8 @@ def make_webhook_payload(
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
"""Compute HMAC-SHA256 signature for a webhook payload."""
return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
"""Build a Paddle-format signature header: ts=<unix>;h1=<hmac_sha256>."""
ts = str(int(time.time()))
data = f"{ts}:{payload_bytes.decode()}".encode()
h1 = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
return f"ts={ts};h1={h1}"

View File

@@ -1,12 +1,11 @@
"""
Route integration tests for Paddle billing endpoints.
External Paddle API calls mocked with respx.
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
"""
import httpx
import pytest
import respx
from unittest.mock import MagicMock, patch
import pytest
CHECKOUT_METHOD = "POST"
CHECKOUT_PLAN = "starter"
@@ -40,7 +39,7 @@ class TestSuccessPage:
# ════════════════════════════════════════════════════════════
# Checkout
# Checkout (Paddle.js overlay — returns JSON)
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
@@ -48,31 +47,25 @@ class TestCheckoutRoute:
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_creates_checkout_session(self, auth_client, db, test_user):
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(200, json={
"data": {
"checkout": {
"url": "https://checkout.paddle.com/test_123"
}
}
})
async def test_returns_checkout_json(self, auth_client, db, test_user):
# Insert a paddle_products row so get_paddle_price() finds it
await db.execute(
"INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"),
)
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
await db.commit()
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
assert response.status_code == 200
data = await response.get_json()
assert "items" in data
assert data["items"][0]["priceId"] == "pri_starter_123"
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_api_error_propagates(self, auth_client, db, test_user):
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(500, json={"error": "server error"})
)
with pytest.raises(httpx.HTTPStatusError):
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
response = await auth_client.post("/billing/checkout/nonexistent_plan")
assert response.status_code == 400
data = await response.get_json()
assert "error" in data
# ════════════════════════════════════════════════════════════
@@ -88,20 +81,16 @@ class TestManageRoute:
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.get("https://api.paddle.com/subscriptions/sub_test").mock(
return_value=httpx.Response(200, json={
"data": {
"management_urls": {
"update_payment_method": "https://paddle.com/manage/test_123"
}
}
})
)
response = await auth_client.post("/billing/manage", follow_redirects=False)
mock_sub = MagicMock()
mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
mock_client = MagicMock()
mock_client.subscriptions.get.return_value = mock_sub
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@@ -118,15 +107,14 @@ class TestCancelRoute:
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock(
return_value=httpx.Response(200, json={"data": {}})
)
response = await auth_client.post("/billing/cancel", follow_redirects=False)
mock_client = MagicMock()
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_client.subscriptions.cancel.assert_called_once()
# ════════════════════════════════════════════════════════════

View File

@@ -71,12 +71,12 @@ class TestWebhookSignature:
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"},
)
response = await client.post(
WEBHOOK_PATH,
data=b"",
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 400
# ════════════════════════════════════════════════════════════

View File

@@ -691,13 +691,3 @@ class TestSchema:
row = await cur.fetchone()
assert row[0] is None
# ════════════════════════════════════════════════════════════
# Business plan price in config
# ════════════════════════════════════════════════════════════
class TestBusinessPlanConfig:
def test_business_plan_in_paddle_prices(self):
from padelnomics.core import Config
c = Config()
assert "business_plan" in c.PADDLE_PRICES