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:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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='...'`)
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user