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

@@ -78,6 +78,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
README for testing email flows without a verified domain README for testing email flows without a verified domain
### Fixed ### Fixed
- **Webhook signature verification broken** — `Verifier().verify()` was called
with raw bytes instead of a request object, causing all signed webhooks to
fail with 400; replaced with manual HMAC verification matching Paddle's
`ts=<unix>;h1=<hmac>` format; also added JSON parse error guard (400 instead
of 500 on malformed payloads)
- **Billing tests stale after SDK migration** — webhook tests used plain
HMAC instead of Paddle's `ts=...;h1=...` signature format; checkout tests
expected redirect instead of JSON overlay response; manage/cancel tests
mocked httpx instead of Paddle SDK; removed stale `PADDLE_PRICES` config
test (prices now in DB)
- **Quote wizard state loss** — `_accumulated` hidden input used `"` attribute - **Quote wizard state loss** — `_accumulated` hidden input used `"` attribute
delimiters which broke on `tojson` output containing literal `"` characters; delimiters which broke on `tojson` output containing literal `"` characters;
switched all 8 step templates to single-quote delimiters (`value='...'`) switched all 8 step templates to single-quote delimiters (`value='...'`)

View File

@@ -3,14 +3,16 @@ Billing domain: checkout, webhooks, subscription management.
Payment provider: paddle Payment provider: paddle
""" """
import hashlib
import hmac
import json import json
import time
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from paddle_billing import Client as PaddleClient from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required from ..auth.routes import login_required
@@ -210,6 +212,29 @@ async def cancel():
return redirect(url_for("dashboard.settings")) return redirect(url_for("dashboard.settings"))
def _verify_paddle_signature(payload: bytes, signature_header: str, secret: str,
max_drift_seconds: int = 300) -> bool:
"""Verify Paddle webhook signature (ts=<unix>;h1=<hmac_sha256>)."""
parts = {}
for kv in signature_header.split(";"):
if "=" in kv:
k, v = kv.split("=", 1)
parts[k] = v
ts = parts.get("ts", "")
h1 = parts.get("h1", "")
if not ts or not h1:
return False
if abs(time.time() - int(ts)) > max_drift_seconds:
return False
expected = hmac.new(
secret.encode(), f"{ts}:{payload.decode()}".encode(), hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, h1)
@bp.route("/webhook/paddle", methods=["POST"]) @bp.route("/webhook/paddle", methods=["POST"])
async def webhook(): async def webhook():
"""Handle Paddle webhooks.""" """Handle Paddle webhooks."""
@@ -217,12 +242,13 @@ async def webhook():
sig = request.headers.get("Paddle-Signature", "") sig = request.headers.get("Paddle-Signature", "")
if config.PADDLE_WEBHOOK_SECRET: if config.PADDLE_WEBHOOK_SECRET:
try: if not _verify_paddle_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
Verifier().verify(payload, Secret(config.PADDLE_WEBHOOK_SECRET), sig)
except Exception:
return jsonify({"error": "Invalid signature"}), 400 return jsonify({"error": "Invalid signature"}), 400
try:
event = json.loads(payload) event = json.loads(payload)
except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid JSON payload"}), 400
event_type = event.get("event_type") event_type = event.get("event_type")
data = event.get("data", {}) data = event.get("data", {})
custom_data = data.get("custom_data", {}) custom_data = data.get("custom_data", {})

View File

@@ -3,6 +3,7 @@ Shared test fixtures for the Padelnomics test suite.
""" """
import hashlib import hashlib
import hmac import hmac
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -118,7 +119,6 @@ def patch_config():
test_values = { test_values = {
"PADDLE_API_KEY": "test_api_key_123", "PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret", "PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"},
"BASE_URL": "http://localhost:5000", "BASE_URL": "http://localhost:5000",
"DEBUG": True, "DEBUG": True,
} }
@@ -160,5 +160,8 @@ def make_webhook_payload(
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str: def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
"""Compute HMAC-SHA256 signature for a webhook payload.""" """Build a Paddle-format signature header: ts=<unix>;h1=<hmac_sha256>."""
return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest() 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. 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 from unittest.mock import MagicMock, patch
import pytest
import respx import pytest
CHECKOUT_METHOD = "POST"
CHECKOUT_PLAN = "starter" CHECKOUT_PLAN = "starter"
@@ -40,7 +39,7 @@ class TestSuccessPage:
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# Checkout # Checkout (Paddle.js overlay — returns JSON)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
class TestCheckoutRoute: class TestCheckoutRoute:
@@ -48,31 +47,25 @@ class TestCheckoutRoute:
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False) response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
@respx.mock async def test_returns_checkout_json(self, auth_client, db, test_user):
async def test_creates_checkout_session(self, auth_client, db, test_user): # Insert a paddle_products row so get_paddle_price() finds it
respx.post("https://api.paddle.com/transactions").mock( await db.execute(
return_value=httpx.Response(200, json={ "INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
"data": { ("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"),
"checkout": {
"url": "https://checkout.paddle.com/test_123"
}
}
})
) )
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False) await db.commit()
assert response.status_code in (302, 303, 307)
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): async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False) response = await auth_client.post("/billing/checkout/nonexistent_plan")
assert response.status_code in (302, 303, 307) assert response.status_code == 400
data = await response.get_json()
@respx.mock assert "error" in data
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}")
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -88,19 +81,15 @@ class TestManageRoute:
response = await auth_client.post("/billing/manage", follow_redirects=False) response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
@respx.mock
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription): 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") await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.get("https://api.paddle.com/subscriptions/sub_test").mock( mock_sub = MagicMock()
return_value=httpx.Response(200, json={ mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
"data": { mock_client = MagicMock()
"management_urls": { mock_client.subscriptions.get.return_value = mock_sub
"update_payment_method": "https://paddle.com/manage/test_123"
} with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
}
})
)
response = await auth_client.post("/billing/manage", follow_redirects=False) response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
@@ -118,15 +107,14 @@ class TestCancelRoute:
response = await auth_client.post("/billing/cancel", follow_redirects=False) response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
@respx.mock
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription): async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test") await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock( mock_client = MagicMock()
return_value=httpx.Response(200, json={"data": {}}) with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
)
response = await auth_client.post("/billing/cancel", follow_redirects=False) response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307) 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): async def test_empty_payload_rejected(self, client, db):
sig = sign_payload(b"") sig = sign_payload(b"")
with pytest.raises(Exception): # JSONDecodeError in TESTING mode response = await client.post(
await client.post(
WEBHOOK_PATH, WEBHOOK_PATH,
data=b"", data=b"",
headers={SIG_HEADER: sig, "Content-Type": "application/json"}, 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() row = await cur.fetchone()
assert row[0] is None 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