petter2025 commited on
Commit
dadf87a
·
1 Parent(s): 590ac0a

Upload folder using huggingface_hub

Browse files
app/api/routes_payments.py CHANGED
@@ -5,11 +5,11 @@ Payment endpoints – Stripe Checkout integration.
5
  import logging
6
  import os
7
  import stripe
8
- from fastapi import APIRouter, HTTPException
9
  from pydantic import BaseModel
10
 
11
  from app.core import usage_tracker
12
- from app.core.usage_tracker import Tier
13
 
14
  logger = logging.getLogger(__name__)
15
 
@@ -21,23 +21,34 @@ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
21
 
22
 
23
  class CheckoutRequest(BaseModel):
24
- api_key: str
25
-
26
  success_url: str
27
  cancel_url: str
28
 
29
 
30
  @router.post("/create-checkout-session")
31
- async def create_checkout_session(req: CheckoutRequest):
32
- """Create a Stripe Checkout session for the Pro tier."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  if not stripe.api_key:
34
  raise HTTPException(status_code=500, detail="Stripe not configured")
35
  if not usage_tracker.tracker:
36
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
37
 
38
- # Verify the API key exists and is free tier
39
- tier = usage_tracker.tracker.get_tier(req.api_key)
40
- if tier != Tier.FREE:
41
  raise HTTPException(status_code=400,
42
  detail="Only free tier keys can be upgraded")
43
 
@@ -48,7 +59,7 @@ async def create_checkout_session(req: CheckoutRequest):
48
  # third party with no reason to ever see it. tenant_id is an opaque,
49
  # non-secret row identifier and is exactly what the webhook needs to
50
  # look up which tenant's keys to retier.
51
- tenant_id = usage_tracker.tracker.get_tenant_id(req.api_key)
52
 
53
  try:
54
  checkout_session = stripe.checkout.Session.create(
 
5
  import logging
6
  import os
7
  import stripe
8
+ from fastapi import APIRouter, Depends, HTTPException
9
  from pydantic import BaseModel
10
 
11
  from app.core import usage_tracker
12
+ from app.core.usage_tracker import Tier, resolve_api_key_identity
13
 
14
  logger = logging.getLogger(__name__)
15
 
 
21
 
22
 
23
  class CheckoutRequest(BaseModel):
 
 
24
  success_url: str
25
  cancel_url: str
26
 
27
 
28
  @router.post("/create-checkout-session")
29
+ async def create_checkout_session(
30
+ req: CheckoutRequest,
31
+ identity: dict = Depends(resolve_api_key_identity),
32
+ ):
33
+ """Create a Stripe Checkout session for the Pro tier.
34
+
35
+ Identity comes from `resolve_api_key_identity` (Authorization: Bearer
36
+ header, same pepper-HMAC lookup every other authenticated endpoint
37
+ uses) rather than from a caller-supplied field in the request body --
38
+ previously this endpoint took `api_key` as a JSON field with no
39
+ `Depends()` gate at all, so nothing distinguished "the caller proved
40
+ they hold this key" from "the caller typed this string into a
41
+ request." `enforce_quota` is deliberately not used here: a FREE-tier
42
+ caller who has exhausted their monthly quota must still be able to
43
+ reach this endpoint, since upgrading is often exactly what they are
44
+ trying to do.
45
+ """
46
  if not stripe.api_key:
47
  raise HTTPException(status_code=500, detail="Stripe not configured")
48
  if not usage_tracker.tracker:
49
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
50
 
51
+ if identity["tier"] != Tier.FREE:
 
 
52
  raise HTTPException(status_code=400,
53
  detail="Only free tier keys can be upgraded")
54
 
 
59
  # third party with no reason to ever see it. tenant_id is an opaque,
60
  # non-secret row identifier and is exactly what the webhook needs to
61
  # look up which tenant's keys to retier.
62
+ tenant_id = identity["tenant_id"]
63
 
64
  try:
65
  checkout_session = stripe.checkout.Session.create(
app/core/usage_tracker.py CHANGED
@@ -660,20 +660,30 @@ def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool:
660
  return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
661
 
662
 
663
- async def enforce_quota(request: Request, api_key: str = None):
 
 
 
 
 
 
 
 
 
664
  """
665
- FastAPI dependency that enforces quota and attaches tenant_id to request state.
 
 
 
 
 
 
 
666
  """
667
  if tracker is None:
668
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
669
 
670
- if api_key is None:
671
- auth_header = request.headers.get("Authorization")
672
- if auth_header and auth_header.startswith("Bearer "):
673
- api_key = auth_header[7:]
674
- else:
675
- api_key = request.query_params.get("api_key")
676
-
677
  if not api_key:
678
  raise HTTPException(status_code=401, detail="Missing API key")
679
 
@@ -681,11 +691,6 @@ async def enforce_quota(request: Request, api_key: str = None):
681
  if tier is None:
682
  raise HTTPException(status_code=403, detail="Invalid or inactive API key")
683
 
684
- remaining = tracker.get_remaining_quota(api_key, tier)
685
- if remaining is not None and remaining <= 0:
686
- raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
687
-
688
- # Retrieve tenant_id
689
  tenant_id = tracker.get_tenant_id(api_key)
690
  if not tenant_id:
691
  raise HTTPException(status_code=403, detail="API key not associated with a tenant")
@@ -694,4 +699,18 @@ async def enforce_quota(request: Request, api_key: str = None):
694
  request.state.tier = tier
695
  request.state.tenant_id = tenant_id
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
 
660
  return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
661
 
662
 
663
+ def _extract_api_key(request: Request, api_key: str = None) -> str:
664
+ if api_key:
665
+ return api_key
666
+ auth_header = request.headers.get("Authorization")
667
+ if auth_header and auth_header.startswith("Bearer "):
668
+ return auth_header[7:]
669
+ return request.query_params.get("api_key")
670
+
671
+
672
+ async def resolve_api_key_identity(request: Request, api_key: str = None):
673
  """
674
+ FastAPI dependency that authenticates an API key and attaches tenant_id
675
+ to request state, without enforcing monthly quota.
676
+
677
+ Deliberately separate from `enforce_quota`: a caller whose quota is
678
+ already exhausted must still be able to reach an endpoint like
679
+ `/payments/create-checkout-session` (upgrading tier is often exactly
680
+ what a rate-limited caller is trying to do) -- gating that path behind
681
+ `enforce_quota` would 429 the one action that lets them fix it.
682
  """
683
  if tracker is None:
684
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
685
 
686
+ api_key = _extract_api_key(request, api_key)
 
 
 
 
 
 
687
  if not api_key:
688
  raise HTTPException(status_code=401, detail="Missing API key")
689
 
 
691
  if tier is None:
692
  raise HTTPException(status_code=403, detail="Invalid or inactive API key")
693
 
 
 
 
 
 
694
  tenant_id = tracker.get_tenant_id(api_key)
695
  if not tenant_id:
696
  raise HTTPException(status_code=403, detail="API key not associated with a tenant")
 
699
  request.state.tier = tier
700
  request.state.tenant_id = tenant_id
701
 
702
+ return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id}
703
+
704
+
705
+ async def enforce_quota(request: Request, api_key: str = None):
706
+ """
707
+ FastAPI dependency that enforces quota and attaches tenant_id to request state.
708
+ """
709
+ identity = await resolve_api_key_identity(request, api_key)
710
+ api_key, tier, tenant_id = identity["api_key"], identity["tier"], identity["tenant_id"]
711
+
712
+ remaining = tracker.get_remaining_quota(api_key, tier)
713
+ if remaining is not None and remaining <= 0:
714
+ raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
715
+
716
  return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
tests/conftest.py CHANGED
@@ -46,6 +46,9 @@ class MockTracker:
46
 
47
  return 1000
48
 
 
 
 
49
  def consume_quota_and_log(self, record, idempotency_key=None):
50
 
51
  return (True, None)
 
46
 
47
  return 1000
48
 
49
+ def get_tenant_id(self, api_key):
50
+ return "test-tenant"
51
+
52
  def consume_quota_and_log(self, record, idempotency_key=None):
53
 
54
  return (True, None)
tests/test_payments.py CHANGED
@@ -28,8 +28,8 @@ def test_create_checkout_session_missing_stripe_key(monkeypatch):
28
  monkeypatch.setattr("stripe.api_key", None)
29
  response = client.post(
30
  "/api/v1/payments/create-checkout-session",
 
31
  json={
32
- "api_key": "test_key",
33
  "success_url": "https://example.com/success",
34
  "cancel_url": "https://example.com/cancel"})
35
  assert response.status_code == 500
@@ -45,8 +45,8 @@ def test_create_checkout_session_free_key(mock_stripe):
45
  id="cs_test_123", url="https://checkout.stripe.com/pay")
46
  response = client.post(
47
  "/api/v1/payments/create-checkout-session",
 
48
  json={
49
- "api_key": "test_key",
50
  "success_url": "https://example.com/success",
51
  "cancel_url": "https://example.com/cancel"})
52
  assert response.status_code == 200
@@ -60,10 +60,39 @@ def test_create_checkout_session_pro_key():
60
  mock_tracker.get_tier.return_value = "pro"
61
  response = client.post(
62
  "/api/v1/payments/create-checkout-session",
 
63
  json={
64
- "api_key": "test_key",
65
  "success_url": "https://example.com/success",
66
  "cancel_url": "https://example.com/cancel"})
67
  assert response.status_code == 400
68
  assert "Only free tier keys can be upgraded" in response.json()[
69
  "detail"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  monkeypatch.setattr("stripe.api_key", None)
29
  response = client.post(
30
  "/api/v1/payments/create-checkout-session",
31
+ headers={"Authorization": "Bearer test_key"},
32
  json={
 
33
  "success_url": "https://example.com/success",
34
  "cancel_url": "https://example.com/cancel"})
35
  assert response.status_code == 500
 
45
  id="cs_test_123", url="https://checkout.stripe.com/pay")
46
  response = client.post(
47
  "/api/v1/payments/create-checkout-session",
48
+ headers={"Authorization": "Bearer test_key"},
49
  json={
 
50
  "success_url": "https://example.com/success",
51
  "cancel_url": "https://example.com/cancel"})
52
  assert response.status_code == 200
 
60
  mock_tracker.get_tier.return_value = "pro"
61
  response = client.post(
62
  "/api/v1/payments/create-checkout-session",
63
+ headers={"Authorization": "Bearer test_key"},
64
  json={
 
65
  "success_url": "https://example.com/success",
66
  "cancel_url": "https://example.com/cancel"})
67
  assert response.status_code == 400
68
  assert "Only free tier keys can be upgraded" in response.json()[
69
  "detail"]
70
+
71
+
72
+ def test_create_checkout_session_requires_authentication(mock_stripe):
73
+ # Regression test: this endpoint used to take `api_key` as a plain JSON
74
+ # body field with no Depends() gate at all, so any caller could request
75
+ # a session for any tenant_id-bearing key string without proving they
76
+ # held it. No Authorization header (and no api_key in the body -- the
77
+ # field no longer exists on CheckoutRequest) must be rejected before
78
+ # ever reaching Stripe.
79
+ response = client.post(
80
+ "/api/v1/payments/create-checkout-session",
81
+ json={
82
+ "success_url": "https://example.com/success",
83
+ "cancel_url": "https://example.com/cancel"})
84
+ assert response.status_code == 401
85
+ mock_stripe.assert_not_called()
86
+
87
+
88
+ def test_create_checkout_session_rejects_invalid_key(mock_stripe):
89
+ with patch("app.core.usage_tracker.tracker") as mock_tracker:
90
+ mock_tracker.get_tier.return_value = None
91
+ response = client.post(
92
+ "/api/v1/payments/create-checkout-session",
93
+ headers={"Authorization": "Bearer not-a-real-key"},
94
+ json={
95
+ "success_url": "https://example.com/success",
96
+ "cancel_url": "https://example.com/cancel"})
97
+ assert response.status_code == 403
98
+ mock_stripe.assert_not_called()