Spaces:
Build error
Build error
Commit ·
390a689
1
Parent(s): f4fbb05
Upload folder using huggingface_hub
Browse files- app/api/routes_payments.py +16 -5
- app/api/webhooks.py +34 -13
- app/core/usage_tracker.py +56 -3
- tests/test_payments.py +15 -10
- tests/test_webhooks.py +95 -26
app/api/routes_payments.py
CHANGED
|
@@ -31,13 +31,24 @@ async def create_checkout_session(req: CheckoutRequest):
|
|
| 31 |
"""Create a Stripe Checkout session for the Pro tier."""
|
| 32 |
if not stripe.api_key:
|
| 33 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
|
|
|
|
|
|
| 34 |
|
| 35 |
# Verify the API key exists and is free tier
|
| 36 |
-
tier = tracker.get_tier(req.api_key)
|
| 37 |
if tier != Tier.FREE:
|
| 38 |
raise HTTPException(status_code=400,
|
| 39 |
detail="Only free tier keys can be upgraded")
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
try:
|
| 42 |
checkout_session = stripe.checkout.Session.create(
|
| 43 |
payment_method_types=["card"],
|
|
@@ -51,15 +62,15 @@ async def create_checkout_session(req: CheckoutRequest):
|
|
| 51 |
mode="subscription",
|
| 52 |
success_url=req.success_url,
|
| 53 |
cancel_url=req.cancel_url,
|
| 54 |
-
metadata={"
|
| 55 |
-
client_reference_id=
|
| 56 |
# checkout.session.completed carries this metadata via
|
| 57 |
# session.metadata (handled below), but customer.subscription.*
|
| 58 |
# events only carry the *subscription's own* metadata -- Stripe
|
| 59 |
# does not copy Session.metadata onto the Subscription it
|
| 60 |
# creates. Without this, cancellations can't be traced back to
|
| 61 |
-
#
|
| 62 |
-
subscription_data={"metadata": {"
|
| 63 |
)
|
| 64 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 65 |
except Exception:
|
|
|
|
| 31 |
"""Create a Stripe Checkout session for the Pro tier."""
|
| 32 |
if not stripe.api_key:
|
| 33 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
| 34 |
+
if not tracker:
|
| 35 |
+
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 36 |
|
| 37 |
# Verify the API key exists and is free tier
|
| 38 |
+
tier = tracker.get_tier(req.api_key)
|
| 39 |
if tier != Tier.FREE:
|
| 40 |
raise HTTPException(status_code=400,
|
| 41 |
detail="Only free tier keys can be upgraded")
|
| 42 |
|
| 43 |
+
# tenant_id, not the raw api_key, is what travels to Stripe from here
|
| 44 |
+
# on. api_key is a bearer secret -- usage_tracker.py exists specifically
|
| 45 |
+
# to never store it in plaintext (pepper-HMAC lookup + salted
|
| 46 |
+
# verification hash), and Stripe's dashboard/webhook logs/API are a
|
| 47 |
+
# third party with no reason to ever see it. tenant_id is an opaque,
|
| 48 |
+
# non-secret row identifier and is exactly what the webhook needs to
|
| 49 |
+
# look up which tenant's keys to retier.
|
| 50 |
+
tenant_id = tracker.get_tenant_id(req.api_key)
|
| 51 |
+
|
| 52 |
try:
|
| 53 |
checkout_session = stripe.checkout.Session.create(
|
| 54 |
payment_method_types=["card"],
|
|
|
|
| 62 |
mode="subscription",
|
| 63 |
success_url=req.success_url,
|
| 64 |
cancel_url=req.cancel_url,
|
| 65 |
+
metadata={"tenant_id": tenant_id},
|
| 66 |
+
client_reference_id=tenant_id,
|
| 67 |
# checkout.session.completed carries this metadata via
|
| 68 |
# session.metadata (handled below), but customer.subscription.*
|
| 69 |
# events only carry the *subscription's own* metadata -- Stripe
|
| 70 |
# does not copy Session.metadata onto the Subscription it
|
| 71 |
# creates. Without this, cancellations can't be traced back to
|
| 72 |
+
# a tenant and PRO tier never downgrades.
|
| 73 |
+
subscription_data={"metadata": {"tenant_id": tenant_id}},
|
| 74 |
)
|
| 75 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 76 |
except Exception:
|
app/api/webhooks.py
CHANGED
|
@@ -2,16 +2,24 @@
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
from fastapi import APIRouter, Request, HTTPException
|
| 8 |
-
from app.core.usage_tracker import
|
|
|
|
|
|
|
| 9 |
|
| 10 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 11 |
|
| 12 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 13 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@router.post("/stripe")
|
| 17 |
async def stripe_webhook(request: Request):
|
|
@@ -30,20 +38,33 @@ async def stripe_webhook(request: Request):
|
|
| 30 |
except stripe.error.SignatureVerificationError:
|
| 31 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 32 |
|
| 33 |
-
#
|
|
|
|
|
|
|
| 34 |
if event["type"] == "checkout.session.completed":
|
| 35 |
session = event["data"]["object"]
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
subscription = event["data"]["object"]
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
return {"status": "ok"}
|
|
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
from fastapi import APIRouter, Request, HTTPException
|
| 9 |
+
from app.core.usage_tracker import update_key_tier_by_tenant_id, Tier
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 14 |
|
| 15 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 16 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 17 |
|
| 18 |
+
# Subscription statuses that mean "not entitled to Pro anymore". Deliberately
|
| 19 |
+
# does NOT include "past_due" -- whether a failed-payment grace period keeps
|
| 20 |
+
# Pro access is a dunning-policy decision, not a bug, and isn't decided here.
|
| 21 |
+
_DOWNGRADE_STATUSES = {"canceled", "unpaid", "incomplete_expired"}
|
| 22 |
+
|
| 23 |
|
| 24 |
@router.post("/stripe")
|
| 25 |
async def stripe_webhook(request: Request):
|
|
|
|
| 38 |
except stripe.error.SignatureVerificationError:
|
| 39 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 40 |
|
| 41 |
+
# tenant_id (not api_key) is what Checkout was given -- see
|
| 42 |
+
# routes_payments.py's create_checkout_session for why the raw bearer
|
| 43 |
+
# key must never round-trip through a third party.
|
| 44 |
if event["type"] == "checkout.session.completed":
|
| 45 |
session = event["data"]["object"]
|
| 46 |
+
# For card payments this is already "paid" by the time this event
|
| 47 |
+
# fires; for delayed-notification payment methods (bank debits,
|
| 48 |
+
# etc.) it can still be "unpaid" here. Upgrading on an unpaid
|
| 49 |
+
# session would grant Pro access before payment actually clears.
|
| 50 |
+
if session.get("payment_status") != "paid":
|
| 51 |
+
logger.info(
|
| 52 |
+
"checkout.session.completed with payment_status=%r; not upgrading yet",
|
| 53 |
+
session.get("payment_status"),
|
| 54 |
+
)
|
| 55 |
+
return {"status": "ok"}
|
| 56 |
+
tenant_id = session.get("client_reference_id") or session.get(
|
| 57 |
+
"metadata", {}).get("tenant_id")
|
| 58 |
+
if tenant_id:
|
| 59 |
+
update_key_tier_by_tenant_id(tenant_id, Tier.PRO)
|
| 60 |
+
elif event["type"] in ("customer.subscription.deleted", "customer.subscription.updated"):
|
| 61 |
subscription = event["data"]["object"]
|
| 62 |
+
tenant_id = subscription.get("metadata", {}).get("tenant_id")
|
| 63 |
+
if not tenant_id:
|
| 64 |
+
return {"status": "ok"}
|
| 65 |
+
if event["type"] == "customer.subscription.deleted" or (
|
| 66 |
+
subscription.get("status") in _DOWNGRADE_STATUSES
|
| 67 |
+
):
|
| 68 |
+
update_key_tier_by_tenant_id(tenant_id, Tier.FREE)
|
| 69 |
|
| 70 |
return {"status": "ok"}
|
app/core/usage_tracker.py
CHANGED
|
@@ -90,6 +90,14 @@ class UsageRecord:
|
|
| 90 |
processing_ms: Optional[float] = None
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
class UsageTracker:
|
| 94 |
"""
|
| 95 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
|
@@ -171,10 +179,30 @@ class UsageTracker:
|
|
| 171 |
def _get_pg_conn(self):
|
| 172 |
"""Get a thread-local Postgres connection for the api_keys table.
|
| 173 |
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 174 |
-
matching the sqlite3.Row access pattern used elsewhere in this file.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
yield self._local.pg_conn
|
| 179 |
|
| 180 |
@staticmethod
|
|
@@ -327,6 +355,25 @@ class UsageTracker:
|
|
| 327 |
conn.commit()
|
| 328 |
return True
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
# --------------------------------------------------------------------------
|
| 331 |
# Atomic quota consumption (unchanged, but uses api_key which links to tenant)
|
| 332 |
# --------------------------------------------------------------------------
|
|
@@ -524,6 +571,12 @@ def update_key_tier(api_key: str, new_tier: Tier) -> bool:
|
|
| 524 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 525 |
|
| 526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
async def enforce_quota(request: Request, api_key: str = None):
|
| 528 |
"""
|
| 529 |
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
|
|
|
| 90 |
processing_ms: Optional[float] = None
|
| 91 |
|
| 92 |
|
| 93 |
+
# Bounded retry for the initial Postgres connect -- see _get_pg_conn's
|
| 94 |
+
# docstring. 5 attempts with exponential backoff (1+2+4+8 = 15s of sleep,
|
| 95 |
+
# worst case) comfortably fits inside a container's normal boot window
|
| 96 |
+
# without turning a real outage into a long hang.
|
| 97 |
+
_PG_CONNECT_MAX_ATTEMPTS = 5
|
| 98 |
+
_PG_CONNECT_BACKOFF_BASE = 1.0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
class UsageTracker:
|
| 102 |
"""
|
| 103 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
|
|
|
| 179 |
def _get_pg_conn(self):
|
| 180 |
"""Get a thread-local Postgres connection for the api_keys table.
|
| 181 |
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 182 |
+
matching the sqlite3.Row access pattern used elsewhere in this file.
|
| 183 |
+
|
| 184 |
+
Retries the initial connect with backoff -- Render's internal
|
| 185 |
+
"dpg-*" hostname has been observed unresolvable for the first
|
| 186 |
+
couple of seconds after a cold container boot (2026-08-25 incident:
|
| 187 |
+
two consecutive "could not translate host name" crashes, then a
|
| 188 |
+
clean connect on the third restart with no config change). A DNS
|
| 189 |
+
race should not require a crash loop to self-heal; other genuine
|
| 190 |
+
connection errors (bad credentials, wrong host) still raise after
|
| 191 |
+
the attempts below are exhausted, preserving fail-closed."""
|
| 192 |
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 193 |
+
last_exc: Optional[psycopg2.OperationalError] = None
|
| 194 |
+
for attempt in range(_PG_CONNECT_MAX_ATTEMPTS):
|
| 195 |
+
try:
|
| 196 |
+
self._local.pg_conn = psycopg2.connect(
|
| 197 |
+
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 198 |
+
last_exc = None
|
| 199 |
+
break
|
| 200 |
+
except psycopg2.OperationalError as exc:
|
| 201 |
+
last_exc = exc
|
| 202 |
+
if attempt < _PG_CONNECT_MAX_ATTEMPTS - 1:
|
| 203 |
+
time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
|
| 204 |
+
if last_exc is not None:
|
| 205 |
+
raise last_exc
|
| 206 |
yield self._local.pg_conn
|
| 207 |
|
| 208 |
@staticmethod
|
|
|
|
| 355 |
conn.commit()
|
| 356 |
return True
|
| 357 |
|
| 358 |
+
def update_tier_by_tenant_id(self, tenant_id: str, new_tier: Tier) -> bool:
|
| 359 |
+
"""Update the tier of every active API key belonging to tenant_id.
|
| 360 |
+
|
| 361 |
+
Used by the Stripe webhook, which must never handle a raw API key
|
| 362 |
+
(Stripe's own systems -- dashboard, logs, webhook payloads -- are a
|
| 363 |
+
third party; the plaintext bearer secret has no business being
|
| 364 |
+
stored there, which is exactly what passing it as Checkout
|
| 365 |
+
metadata used to do). tenant_id is not a secret -- it's an opaque
|
| 366 |
+
row identifier -- so it's safe to round-trip through Stripe."""
|
| 367 |
+
with self._get_pg_conn() as conn:
|
| 368 |
+
cur = self._pg_execute(
|
| 369 |
+
conn,
|
| 370 |
+
"UPDATE api_keys SET tier = %s WHERE tenant_id = %s AND is_active = true",
|
| 371 |
+
(new_tier.value, tenant_id),
|
| 372 |
+
)
|
| 373 |
+
updated = cur.rowcount > 0
|
| 374 |
+
conn.commit()
|
| 375 |
+
return updated
|
| 376 |
+
|
| 377 |
# --------------------------------------------------------------------------
|
| 378 |
# Atomic quota consumption (unchanged, but uses api_key which links to tenant)
|
| 379 |
# --------------------------------------------------------------------------
|
|
|
|
| 571 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 572 |
|
| 573 |
|
| 574 |
+
def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool:
|
| 575 |
+
if tracker is None:
|
| 576 |
+
return False
|
| 577 |
+
return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
|
| 578 |
+
|
| 579 |
+
|
| 580 |
async def enforce_quota(request: Request, api_key: str = None):
|
| 581 |
"""
|
| 582 |
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
tests/test_payments.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
-
import os
|
| 2 |
import pytest
|
| 3 |
from unittest.mock import patch, MagicMock
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from app.main import app
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
client = TestClient(app)
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"Stripe not configured – skipping payment tests",
|
| 14 |
-
allow_module_level=True)
|
| 15 |
|
| 16 |
|
| 17 |
@pytest.fixture
|
|
@@ -21,7 +23,9 @@ def mock_stripe():
|
|
| 21 |
|
| 22 |
|
| 23 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
response = client.post(
|
| 26 |
"/api/v1/payments/create-checkout-session",
|
| 27 |
json={
|
|
@@ -34,8 +38,9 @@ def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
|
| 34 |
|
| 35 |
def test_create_checkout_session_free_key(mock_stripe):
|
| 36 |
# Mock tracker.get_tier to return Tier.FREE
|
| 37 |
-
with patch("app.
|
| 38 |
mock_tracker.get_tier.return_value = "free"
|
|
|
|
| 39 |
mock_stripe.return_value = MagicMock(
|
| 40 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 41 |
response = client.post(
|
|
@@ -51,7 +56,7 @@ def test_create_checkout_session_free_key(mock_stripe):
|
|
| 51 |
|
| 52 |
|
| 53 |
def test_create_checkout_session_pro_key():
|
| 54 |
-
with patch("app.
|
| 55 |
mock_tracker.get_tier.return_value = "pro"
|
| 56 |
response = client.post(
|
| 57 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from unittest.mock import patch, MagicMock
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
+
# Every Stripe call in this module is mocked -- nothing here reaches the
|
| 7 |
+
# network, and no real credentials are involved. This module used to skip
|
| 8 |
+
# entirely whenever STRIPE_SECRET_KEY was unset (always, in CI), so none of
|
| 9 |
+
# it ran; stripe.api_key is patched below instead of relying on env vars,
|
| 10 |
+
# since routes_payments.py reads it at import time.
|
| 11 |
client = TestClient(app)
|
| 12 |
|
| 13 |
+
|
| 14 |
+
@pytest.fixture(autouse=True)
|
| 15 |
+
def stripe_configured(monkeypatch):
|
| 16 |
+
monkeypatch.setattr("stripe.api_key", "sk_test_fake")
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@pytest.fixture
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 26 |
+
# routes_payments.py sets stripe.api_key from the environment at import
|
| 27 |
+
# time and checks `stripe.api_key`, so setenv here would be a no-op.
|
| 28 |
+
monkeypatch.setattr("stripe.api_key", None)
|
| 29 |
response = client.post(
|
| 30 |
"/api/v1/payments/create-checkout-session",
|
| 31 |
json={
|
|
|
|
| 38 |
|
| 39 |
def test_create_checkout_session_free_key(mock_stripe):
|
| 40 |
# Mock tracker.get_tier to return Tier.FREE
|
| 41 |
+
with patch("app.api.routes_payments.tracker") as mock_tracker:
|
| 42 |
mock_tracker.get_tier.return_value = "free"
|
| 43 |
+
mock_tracker.get_tenant_id.return_value = "tenant_test_123"
|
| 44 |
mock_stripe.return_value = MagicMock(
|
| 45 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 46 |
response = client.post(
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
def test_create_checkout_session_pro_key():
|
| 59 |
+
with patch("app.api.routes_payments.tracker") as mock_tracker:
|
| 60 |
mock_tracker.get_tier.return_value = "pro"
|
| 61 |
response = client.post(
|
| 62 |
"/api/v1/payments/create-checkout-session",
|
tests/test_webhooks.py
CHANGED
|
@@ -1,17 +1,25 @@
|
|
| 1 |
-
import os
|
| 2 |
import pytest
|
| 3 |
from unittest.mock import patch
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from app.main import app
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
client = TestClient(app)
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
allow_module_level=True)
|
| 15 |
|
| 16 |
|
| 17 |
@pytest.fixture
|
|
@@ -21,7 +29,9 @@ def mock_stripe_webhook():
|
|
| 21 |
|
| 22 |
|
| 23 |
def test_webhook_missing_secret(monkeypatch):
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
response = client.post(
|
| 26 |
"/webhooks/stripe",
|
| 27 |
json={},
|
|
@@ -31,8 +41,7 @@ def test_webhook_missing_secret(monkeypatch):
|
|
| 31 |
assert "Stripe not configured" in response.json()["detail"]
|
| 32 |
|
| 33 |
|
| 34 |
-
def test_webhook_invalid_payload(mock_stripe_webhook
|
| 35 |
-
monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
|
| 36 |
mock_stripe_webhook.side_effect = ValueError("Invalid payload")
|
| 37 |
response = client.post(
|
| 38 |
"/webhooks/stripe",
|
|
@@ -43,9 +52,12 @@ def test_webhook_invalid_payload(mock_stripe_webhook, monkeypatch):
|
|
| 43 |
assert "Invalid payload" in response.json()["detail"]
|
| 44 |
|
| 45 |
|
| 46 |
-
def test_webhook_invalid_signature(mock_stripe_webhook
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
response = client.post(
|
| 50 |
"/webhooks/stripe",
|
| 51 |
json={},
|
|
@@ -55,35 +67,92 @@ def test_webhook_invalid_signature(mock_stripe_webhook, monkeypatch):
|
|
| 55 |
assert "Invalid signature" in response.json()["detail"]
|
| 56 |
|
| 57 |
|
| 58 |
-
def test_webhook_checkout_completed(
|
| 59 |
-
monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
|
| 60 |
-
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test")
|
| 61 |
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 62 |
-
patch("app.
|
| 63 |
mock_construct.return_value = {
|
| 64 |
"type": "checkout.session.completed",
|
| 65 |
"data": {
|
| 66 |
"object": {
|
| 67 |
-
"client_reference_id": "
|
|
|
|
| 68 |
"metadata": {
|
| 69 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
response = client.post(
|
| 71 |
"/webhooks/stripe",
|
| 72 |
json={},
|
| 73 |
headers={"stripe-signature": "test"}
|
| 74 |
)
|
| 75 |
assert response.status_code == 200
|
| 76 |
-
mock_update.
|
| 77 |
|
| 78 |
|
| 79 |
-
def test_webhook_subscription_deleted(
|
| 80 |
-
monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
|
| 81 |
-
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test")
|
| 82 |
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 83 |
-
patch("app.
|
| 84 |
mock_construct.return_value = {
|
| 85 |
"type": "customer.subscription.deleted",
|
| 86 |
-
"data": {"object": {"metadata": {"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
response = client.post(
|
| 89 |
"/webhooks/stripe",
|
|
@@ -91,4 +160,4 @@ def test_webhook_subscription_deleted(monkeypatch):
|
|
| 91 |
headers={"stripe-signature": "test"}
|
| 92 |
)
|
| 93 |
assert response.status_code == 200
|
| 94 |
-
mock_update.
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from unittest.mock import patch
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
+
# Every Stripe call in this module is mocked -- nothing here reaches the
|
| 7 |
+
# network, and no real credentials are involved.
|
| 8 |
+
#
|
| 9 |
+
# This module used to skip entirely (pytest.skip(allow_module_level=True))
|
| 10 |
+
# whenever STRIPE_* env vars were unset, which is always in CI -- so none of
|
| 11 |
+
# it was ever exercised anywhere. webhooks.py reads its config into
|
| 12 |
+
# module-level globals at import time, so env vars set in conftest.py can't
|
| 13 |
+
# help (conftest's imports run before its os.environ assignments); the
|
| 14 |
+
# fixture below patches those globals directly instead, which works
|
| 15 |
+
# regardless of import order.
|
| 16 |
client = TestClient(app)
|
| 17 |
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(autouse=True)
|
| 20 |
+
def stripe_configured(monkeypatch):
|
| 21 |
+
monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "whsec_test_fake")
|
| 22 |
+
monkeypatch.setattr("stripe.api_key", "sk_test_fake")
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
@pytest.fixture
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def test_webhook_missing_secret(monkeypatch):
|
| 32 |
+
# webhooks.py reads STRIPE_WEBHOOK_SECRET into a module-level global at
|
| 33 |
+
# import time, so setenv here would be a no-op -- patch the global.
|
| 34 |
+
monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "")
|
| 35 |
response = client.post(
|
| 36 |
"/webhooks/stripe",
|
| 37 |
json={},
|
|
|
|
| 41 |
assert "Stripe not configured" in response.json()["detail"]
|
| 42 |
|
| 43 |
|
| 44 |
+
def test_webhook_invalid_payload(mock_stripe_webhook):
|
|
|
|
| 45 |
mock_stripe_webhook.side_effect = ValueError("Invalid payload")
|
| 46 |
response = client.post(
|
| 47 |
"/webhooks/stripe",
|
|
|
|
| 52 |
assert "Invalid payload" in response.json()["detail"]
|
| 53 |
|
| 54 |
|
| 55 |
+
def test_webhook_invalid_signature(mock_stripe_webhook):
|
| 56 |
+
import stripe
|
| 57 |
+
# Must be the real exception type the route catches -- a bare Exception
|
| 58 |
+
# would propagate as a 500 instead of the 400 this asserts.
|
| 59 |
+
mock_stripe_webhook.side_effect = stripe.error.SignatureVerificationError(
|
| 60 |
+
"Invalid signature", "sig_header")
|
| 61 |
response = client.post(
|
| 62 |
"/webhooks/stripe",
|
| 63 |
json={},
|
|
|
|
| 67 |
assert "Invalid signature" in response.json()["detail"]
|
| 68 |
|
| 69 |
|
| 70 |
+
def test_webhook_checkout_completed():
|
|
|
|
|
|
|
| 71 |
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 72 |
+
patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
|
| 73 |
mock_construct.return_value = {
|
| 74 |
"type": "checkout.session.completed",
|
| 75 |
"data": {
|
| 76 |
"object": {
|
| 77 |
+
"client_reference_id": "tenant_123",
|
| 78 |
+
"payment_status": "paid",
|
| 79 |
"metadata": {
|
| 80 |
+
"tenant_id": "tenant_123"}}}}
|
| 81 |
+
response = client.post(
|
| 82 |
+
"/webhooks/stripe",
|
| 83 |
+
json={},
|
| 84 |
+
headers={"stripe-signature": "test"}
|
| 85 |
+
)
|
| 86 |
+
assert response.status_code == 200
|
| 87 |
+
mock_update.assert_called_once_with("tenant_123", "pro")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_webhook_checkout_completed_not_yet_paid_does_not_upgrade():
|
| 91 |
+
"""checkout.session.completed can fire before payment actually clears
|
| 92 |
+
for delayed-notification payment methods -- must not grant Pro yet."""
|
| 93 |
+
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 94 |
+
patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
|
| 95 |
+
mock_construct.return_value = {
|
| 96 |
+
"type": "checkout.session.completed",
|
| 97 |
+
"data": {
|
| 98 |
+
"object": {
|
| 99 |
+
"client_reference_id": "tenant_123",
|
| 100 |
+
"payment_status": "unpaid",
|
| 101 |
+
"metadata": {"tenant_id": "tenant_123"}}}}
|
| 102 |
response = client.post(
|
| 103 |
"/webhooks/stripe",
|
| 104 |
json={},
|
| 105 |
headers={"stripe-signature": "test"}
|
| 106 |
)
|
| 107 |
assert response.status_code == 200
|
| 108 |
+
mock_update.assert_not_called()
|
| 109 |
|
| 110 |
|
| 111 |
+
def test_webhook_subscription_deleted():
|
|
|
|
|
|
|
| 112 |
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 113 |
+
patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
|
| 114 |
mock_construct.return_value = {
|
| 115 |
"type": "customer.subscription.deleted",
|
| 116 |
+
"data": {"object": {"metadata": {"tenant_id": "tenant_123"}}}
|
| 117 |
+
}
|
| 118 |
+
response = client.post(
|
| 119 |
+
"/webhooks/stripe",
|
| 120 |
+
json={},
|
| 121 |
+
headers={"stripe-signature": "test"}
|
| 122 |
+
)
|
| 123 |
+
assert response.status_code == 200
|
| 124 |
+
mock_update.assert_called_once_with("tenant_123", "free")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_webhook_subscription_updated_canceled_downgrades():
|
| 128 |
+
"""customer.subscription.updated (not just .deleted) must also
|
| 129 |
+
downgrade -- a status transition to canceled/unpaid can arrive this
|
| 130 |
+
way, and .deleted is not the only terminal event Stripe sends."""
|
| 131 |
+
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 132 |
+
patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
|
| 133 |
+
mock_construct.return_value = {
|
| 134 |
+
"type": "customer.subscription.updated",
|
| 135 |
+
"data": {"object": {
|
| 136 |
+
"status": "canceled",
|
| 137 |
+
"metadata": {"tenant_id": "tenant_123"}}}
|
| 138 |
+
}
|
| 139 |
+
response = client.post(
|
| 140 |
+
"/webhooks/stripe",
|
| 141 |
+
json={},
|
| 142 |
+
headers={"stripe-signature": "test"}
|
| 143 |
+
)
|
| 144 |
+
assert response.status_code == 200
|
| 145 |
+
mock_update.assert_called_once_with("tenant_123", "free")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_webhook_subscription_updated_active_does_not_downgrade():
|
| 149 |
+
with patch("stripe.Webhook.construct_event") as mock_construct, \
|
| 150 |
+
patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
|
| 151 |
+
mock_construct.return_value = {
|
| 152 |
+
"type": "customer.subscription.updated",
|
| 153 |
+
"data": {"object": {
|
| 154 |
+
"status": "active",
|
| 155 |
+
"metadata": {"tenant_id": "tenant_123"}}}
|
| 156 |
}
|
| 157 |
response = client.post(
|
| 158 |
"/webhooks/stripe",
|
|
|
|
| 160 |
headers={"stripe-signature": "test"}
|
| 161 |
)
|
| 162 |
assert response.status_code == 200
|
| 163 |
+
mock_update.assert_not_called()
|