fix Bazaar purchase validation URLs and add the new pishkhan-token method
Read against Bazaar's own docs (wp-json of developers.cafebazaar.ir): both validation URLs were wrong, so no real purchase could ever verify. In-app is now api/validate/<pkg>/inapp/<sku>/purchases/<token>/, and subscriptions add the missing /purchases/ and judge validUntil, since that response has no purchaseState. Only 404 means "not purchased"; 401/403/5xx now raise 503 so our own key or quota trouble cannot mark a paid order failed. The client token is URL-quoted so it cannot steer the request to another API path. BAZAAR_PISHKHAN_API_SECRET (the documented new method, a static header token with no OAuth) takes precedence when set; /api/health gains integrations.bazaar. Not yet exercised by a real purchase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
@@ -220,49 +220,85 @@ def _get_access_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""روشِ جدید (توکنِ ثابتِ پیشخان) یا روشِ قدیمی (refresh tokenِ OAuth)."""
|
||||
return bool(settings.BAZAAR_PISHKHAN_API_SECRET or get_refresh_token())
|
||||
|
||||
|
||||
def _auth_headers() -> dict:
|
||||
"""
|
||||
روشِ جدیدِ بازار (سندِ «راه اندازی API (روش جدید)»، مهر ۱۴۰۳): یک توکنِ
|
||||
ثابت از پیشخان ← «API پیشخان بازار» ← «دریافت توکن جدید»، در هدرِ
|
||||
CAFEBAZAAR-PISHKHAN-API-SECRET؛ نه redirect دارد نه انقضا. اگر ست باشد
|
||||
همان به کار میرود، وگرنه access tokenِ روشِ قدیمیِ OAuth.
|
||||
"""
|
||||
if settings.BAZAAR_PISHKHAN_API_SECRET:
|
||||
return {'CAFEBAZAAR-PISHKHAN-API-SECRET': settings.BAZAAR_PISHKHAN_API_SECRET}
|
||||
return {'Authorization': f'Bearer {_get_access_token()}'}
|
||||
|
||||
|
||||
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
|
||||
"""
|
||||
صحتسنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
|
||||
is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید
|
||||
تکمحصولی)، طبق سند رسمی.
|
||||
|
||||
آدرسها از سندِ خودِ بازار (خواندهشده ۲۰۲۶-۰۹-۲۶ از wp-jsonِ
|
||||
developers.cafebazaar.ir؛ پیش از آن هر دو غلط بودند و هیچ خریدِ واقعیای
|
||||
تأیید نمیشد):
|
||||
تکخرید: api/validate/<pkg>/inapp/<sku>/purchases/<token>/
|
||||
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
|
||||
به گفتهی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
|
||||
فقط ۴۰۴ نامعتبر است و بقیهی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
|
||||
تا یک خریدِ واقعی بهخاطرِ خطای ما failed نشود.
|
||||
"""
|
||||
if not get_refresh_token():
|
||||
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
|
||||
# بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود
|
||||
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت
|
||||
# مستقیماً valid فرض میشد (باگ fail-open) که یعنی یک کلاینت
|
||||
# میتونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو
|
||||
# paid کنه. حالا در نبودِ کلیدهای بازار، هیچوقت به گفتهی کلاینت
|
||||
# اعتماد نمیکنیم و صریحاً invalid برمیگردونیم.
|
||||
if not is_configured():
|
||||
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچوقت به گفتهی کلاینت اعتماد
|
||||
# نمیکنیم (قبلاً هر purchaseTokenِ غیرخالی valid فرض میشد).
|
||||
return BazaarPurchaseResult(
|
||||
is_valid=False,
|
||||
raw={'error': 'bazaar_credentials_not_configured'},
|
||||
)
|
||||
|
||||
access_token = _get_access_token()
|
||||
kind = 'subscriptions' if is_subscription else 'purchases'
|
||||
url = (
|
||||
f'{settings.BAZAAR_API_BASE}/api/applications/'
|
||||
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/'
|
||||
)
|
||||
# token از کلاینت میآید: quote با safe='' تا `/` یا `..` در آن مسیرِ
|
||||
# دیگری از API را با کلیدِ ما صدا نزند.
|
||||
pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
|
||||
sku = quote(product_sku, safe='')
|
||||
token = quote(purchase_token, safe='')
|
||||
if is_subscription:
|
||||
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}'
|
||||
else:
|
||||
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10)
|
||||
resp = requests.get(url, headers=_auth_headers(), timeout=10)
|
||||
except requests.RequestException:
|
||||
logger.exception('Bazaar verify-purchase request failed')
|
||||
raise BazaarAPIError()
|
||||
|
||||
if resp.status_code != 200:
|
||||
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
logger.error('Bazaar verify-purchase response malformed: status=%s', resp.status_code)
|
||||
data = None
|
||||
|
||||
if resp.status_code == 404:
|
||||
return BazaarPurchaseResult(is_valid=False, raw=data if isinstance(data, dict) else {'status_code': 404})
|
||||
if resp.status_code != 200 or not isinstance(data, dict):
|
||||
if resp.status_code == 401:
|
||||
cache.delete(ACCESS_TOKEN_CACHE_KEY)
|
||||
logger.error('Bazaar verify-purchase failed: status=%s error=%s', resp.status_code,
|
||||
data.get('error') if isinstance(data, dict) else None)
|
||||
raise BazaarAPIError()
|
||||
|
||||
purchase_state = data.get('purchaseState')
|
||||
if is_subscription:
|
||||
# پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمیگرداند؛
|
||||
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
||||
valid_until = data.get('validUntilTimestampMsec') or 0
|
||||
is_valid = valid_until > timezone.now().timestamp() * 1000
|
||||
purchase_state = None
|
||||
else:
|
||||
purchase_state = data.get('purchaseState')
|
||||
is_valid = purchase_state == 0 # ۰ یعنی خریداریشده؛ ۱ یعنی refund
|
||||
return BazaarPurchaseResult(
|
||||
is_valid=purchase_state == 0, # ۰ یعنی خریداریشده و معتبر
|
||||
is_valid=is_valid,
|
||||
purchase_state=purchase_state,
|
||||
consumption_state=data.get('consumptionState'),
|
||||
developer_payload=data.get('developerPayload', ''),
|
||||
|
||||
@@ -122,3 +122,68 @@ class BazaarOAuthCallbackTests(TestCase):
|
||||
|
||||
self.assertTrue(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
|
||||
self.assertEqual(mock_post.call_args.kwargs['data']['refresh_token'], 'db-ref')
|
||||
|
||||
|
||||
@override_settings(
|
||||
BAZAAR_PISHKHAN_API_SECRET='pishkhan-secret', BAZAAR_REFRESH_TOKEN='',
|
||||
BAZAAR_PACKAGE_NAME='ir.example.app',
|
||||
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
|
||||
)
|
||||
class BazaarValidateAgainstDocsTests(TestCase):
|
||||
"""آدرسها و معنای پاسخها طبقِ سندِ بازار (خواندهشده ۲۰۲۶-۰۹-۲۶)."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def _resp(self, mock_get, status, body):
|
||||
mock_get.return_value.status_code = status
|
||||
mock_get.return_value.json.return_value = body
|
||||
|
||||
@patch('payment.bazaar.requests.post')
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_inapp_url_and_new_method_header(self, mock_get, mock_post):
|
||||
self._resp(mock_get, 200, {'purchaseState': 0, 'consumptionState': 1})
|
||||
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='tok').is_valid)
|
||||
self.assertEqual(
|
||||
mock_get.call_args.args[0],
|
||||
'https://pardakht.cafebazaar.ir/devapi/v2/api/validate/ir.example.app/inapp/monthly/purchases/tok/',
|
||||
)
|
||||
self.assertEqual(mock_get.call_args.kwargs['headers'],
|
||||
{'CAFEBAZAAR-PISHKHAN-API-SECRET': 'pishkhan-secret'})
|
||||
mock_post.assert_not_called() # روشِ جدید OAuth نمیخواهد
|
||||
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_refunded_is_invalid(self, mock_get):
|
||||
self._resp(mock_get, 200, {'purchaseState': 1})
|
||||
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
|
||||
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_not_found_is_invalid(self, mock_get):
|
||||
self._resp(mock_get, 404, {'error': 'not_found'})
|
||||
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='forged').is_valid)
|
||||
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_our_credential_error_is_503_not_invalid(self, mock_get):
|
||||
# کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند.
|
||||
for status in (401, 403, 500):
|
||||
self._resp(mock_get, status, {'error': 'invalid_credentials'})
|
||||
with self.assertRaises(bazaar.BazaarAPIError):
|
||||
bazaar.verify_purchase(product_sku='s', purchase_token='t')
|
||||
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_client_token_cannot_escape_the_path(self, mock_get):
|
||||
self._resp(mock_get, 404, {'error': 'not_found'})
|
||||
bazaar.verify_purchase(product_sku='s', purchase_token='../../applications/x')
|
||||
self.assertIn('/purchases/..%2F..%2Fapplications%2Fx/', mock_get.call_args.args[0])
|
||||
|
||||
@patch('payment.bazaar.requests.get')
|
||||
def test_subscription_url_and_expiry(self, mock_get):
|
||||
now_ms = timezone.now().timestamp() * 1000
|
||||
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms + 86_400_000})
|
||||
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
||||
self.assertEqual(
|
||||
mock_get.call_args.args[0],
|
||||
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t',
|
||||
)
|
||||
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
|
||||
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
||||
|
||||
Reference in New Issue
Block a user