Files
MathApp/payment/tests.py
Zareian 147af3ac10 send the student back to the web app after paying, not to a raw JSON page
ZARINPAL_REDIRECT_URL was empty on the server, so after a real (non-sandbox)
payment the callback answered with {"orderId","status"} on the API domain and
the student was stranded there. It now defaults to FRONTEND_URL
(https://math.tadrisino2.ir/) with ?orderId=&status=; reopening a finished
order's pay link goes back to the app the same way. "-" keeps the old JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 03:46:41 +03:30

699 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import importlib
import os
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient, APIRequestFactory
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .services import call_payment_gateway
class SecretDefaultsFailClosedTests(TestCase):
"""
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY و PAYMENT_WEBHOOK_SECRET
دیگه در پروداکشن (DEBUG=False) دیفالتِ قابل‌حدس‌زدن ندارن. اگه env تنظیم
نشده باشه، مقدار خالی می‌مونه و پرمیشن‌های مربوطه صراحتاً رد می‌کنن
(fail closed)، نه اینکه با یک مقدارِ شناخته‌شده قبول کنن.
"""
def setUp(self):
self.factory = APIRequestFactory()
@override_settings(DEBUG=False, INTERNAL_SERVICE_KEY='')
def test_production_missing_internal_key_rejects(self):
request = self.factory.post('/api/payment/orders')
# حتی اگه کلاینت هیچ هدری هم نفرسته یا مقدارِ خالی بفرسته، چون
# settings.INTERNAL_SERVICE_KEY خودش خالیه، درخواست باید رد بشه.
self.assertFalse(HasInternalServiceKey().has_permission(request, None))
@override_settings(DEBUG=False, PAYMENT_WEBHOOK_SECRET='')
def test_production_missing_webhook_secret_rejects(self):
request = self.factory.post('/api/payment/webhook')
self.assertFalse(HasWebhookSecret().has_permission(request, None))
@override_settings(
DEBUG=False,
INTERNAL_SERVICE_KEY='prod-internal-key-xyz',
PAYMENT_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
def test_production_configured_secrets_accepts(self):
internal_request = self.factory.post(
'/api/payment/orders', HTTP_X_INTERNAL_KEY='prod-internal-key-xyz',
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
webhook_request = self.factory.post(
'/api/payment/webhook', HTTP_X_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
def test_debug_mode_local_fallback_still_valid(self):
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# منطق باید خودِ ماژول رو reload کرد.
from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'}
env_removed = {'INTERNAL_SERVICE_KEY', 'PAYMENT_WEBHOOK_SECRET'}
clean_env = {
k: v for k, v in os.environ.items() if k not in env_removed
}
clean_env.update(env_overrides)
try:
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.INTERNAL_SERVICE_KEY,
'dev-internal-service-key-change-me',
)
self.assertEqual(
settings_module.PAYMENT_WEBHOOK_SECRET,
'dev-webhook-secret-change-me',
)
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.
importlib.reload(settings_module)
class BazaarVerifyPurchaseFailClosedTests(TestCase):
"""
تست‌های متمرکز روی فیکسِ باگ fail-open در bazaar.verify_purchase: قبلاً
وقتی BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ بازار) ست نشده بود، هر
purchaseTokenِ غیرخالیِ ارسالی از کلاینت valid فرض می‌شد. حالا باید در
این حالت همیشه invalid برگرده، و مسیر واقعیِ صحت‌سنجی با بازار (وقتی
کلیدها ست شدن) دست‌نخورده کار کنه.
"""
def setUp(self):
# کش access_token رو بین تست‌ها پاک می‌کنیم تا یک تست روی تست بعدی
# اثر نذاره.
cache.clear()
@override_settings(BAZAAR_REFRESH_TOKEN='')
def test_missing_credentials_never_trusts_client_token(self):
# حتی با یک purchaseToken دلخواهِ غیرخالی، بدون کلیدهای بازار نباید
# valid برگرده.
result = bazaar.verify_purchase(
product_sku='some_sku', purchase_token='arbitrary-client-supplied-token',
)
self.assertFalse(result.is_valid)
@override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz',
BAZAAR_CLIENT_ID='client-id',
BAZAAR_CLIENT_SECRET='client-secret',
BAZAAR_PACKAGE_NAME='ir.tadrisino.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
@patch('payment.bazaar.requests.get')
@patch('payment.bazaar.requests.post')
def test_configured_credentials_and_successful_response_is_valid(self, mock_post, mock_get):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
'access_token': 'real-access-token', 'expires_in': 3600,
}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
'purchaseState': 0, 'consumptionState': 0, 'developerPayload': '',
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='real-token')
self.assertTrue(result.is_valid)
mock_get.assert_called_once()
_, get_kwargs = mock_get.call_args
self.assertEqual(get_kwargs['headers']['Authorization'], 'Bearer real-access-token')
@override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz',
BAZAAR_CLIENT_ID='client-id',
BAZAAR_CLIENT_SECRET='client-secret',
BAZAAR_PACKAGE_NAME='ir.tadrisino.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
@patch('payment.bazaar.requests.get')
@patch('payment.bazaar.requests.post')
def test_configured_credentials_provider_reports_invalid(self, mock_post, mock_get):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
'access_token': 'real-access-token', 'expires_in': 3600,
}
mock_get.return_value.status_code = 200
# purchaseState=1 یعنی لغوشده (طبق سند بازار)، یعنی خودِ سرور بازار
# می‌گه این خرید معتبر نیست.
mock_get.return_value.json.return_value = {
'purchaseState': 1, 'consumptionState': 0, 'developerPayload': '',
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='cancelled-token')
self.assertFalse(result.is_valid)
class BazaarVerifyPurchaseThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی BazaarVerifyPurchaseView
(throttle_scope='bazaar_verify'): هر درخواستِ پذیرفته‌شده یک تماسِ
سرور-به-سرورِ واقعی با API بازار می‌زنه؛ بدونِ throttle مخصوص، یک
کاربرِ احرازهویت‌شده می‌تونست با ارسالِ مکررِ purchaseToken بارِ زیادی
روی سرویسِ بازار/بک‌اند بندازه. GuestAwareBazaarThrottle با pk کاربرِ
احرازهویت‌شده کلید می‌خوره (نه IP)، پس تغییرِ IP این محدودیت رو دور
نمی‌زنه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-bazaar-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _make_order(self, key):
return Order.objects.create(
user_id=self.user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key=key,
)
def _verify(self, order_id, purchase_token, token=None, remote_addr=None):
headers = {}
if token:
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
if remote_addr:
headers['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/payment/bazaar/verify',
{'orderId': order_id, 'purchaseToken': purchase_token},
format='json',
**headers,
)
def test_unauthenticated_request_is_still_rejected(self):
response = self._verify(order_id='1', purchase_token='tok')
self.assertEqual(response.status_code, 401)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
for i in range(3):
order = self._make_order(f'order-bazaar-within-{i}')
response = self._verify(order.id, f'token-within-{i}', token=self.token)
self.assertEqual(response.status_code, 200)
def test_excessive_verify_attempts_from_same_user_are_blocked(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
for i in range(3):
order = self._make_order(f'order-bazaar-block-{i}')
response = self._verify(order.id, f'token-block-{i}', token=self.token)
self.assertEqual(response.status_code, 200)
extra_order = self._make_order('order-bazaar-block-extra')
blocked_response = self._verify(extra_order.id, 'token-block-extra', token=self.token)
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751')
registered_token = issue_access_token(registered_user.id)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
for i in range(3):
order = Order.objects.create(
user_id=registered_user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key=f'order-bazaar-ip-{i}',
)
response = self._verify(
order.id, f'token-ip-{i}', token=registered_token, remote_addr=f'10.4.0.{i + 1}',
)
self.assertEqual(response.status_code, 200)
extra_order = Order.objects.create(
user_id=registered_user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-ip-extra',
)
blocked_response = self._verify(
extra_order.id, 'token-ip-extra', token=registered_token, remote_addr='10.4.0.99',
)
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_does_not_call_bazaar_or_mutate_order(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
first_order = self._make_order('order-bazaar-mutate-1')
first_response = self._verify(first_order.id, 'token-mutate-1', token=self.token)
self.assertEqual(first_response.status_code, 200)
mock_verify.reset_mock()
second_order = self._make_order('order-bazaar-mutate-2')
blocked_response = self._verify(second_order.id, 'token-mutate-2', token=self.token)
self.assertEqual(blocked_response.status_code, 429)
mock_verify.assert_not_called()
second_order.refresh_from_db()
self.assertEqual(second_order.status, 'pending')
self.assertEqual(second_order.bazaar_purchase_token, '')
self.assertFalse(Transaction.objects.filter(order=second_order).exists())
class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='bazaar_verify' برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه برای تریگر کردنِ تماسِ سرور-به-سرورِ verify_purchase بگیره. حالا
GuestAwareBazaarThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
def _make_guest_with_order(self, device_id, key):
guest = User.objects.create(device_id=device_id, is_guest=True, name='')
order = Order.objects.create(
user_id=guest.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key=key,
)
return guest, order
def _verify(self, order_id, purchase_token, token):
return self.client.post(
'/api/payment/bazaar/verify',
{'orderId': order_id, 'purchaseToken': purchase_token},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '2/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-1', 'order-bazaar-rot-1')
response1 = self._verify(order1.id, 'token-rot-1', issue_access_token(guest1.id))
self.assertEqual(response1.status_code, 200)
guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-2', 'order-bazaar-rot-2')
response2 = self._verify(order2.id, 'token-rot-2', issue_access_token(guest2.id))
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk.
guest3, order3 = self._make_guest_with_order('device-bazaar-guest-rot-3', 'order-bazaar-rot-3')
response3 = self._verify(order3.id, 'token-rot-3', issue_access_token(guest3.id))
self.assertEqual(response3.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
user_a = User.objects.create(is_guest=False, name='', phone='+989120000761')
order_a = Order.objects.create(
user_id=user_a.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a',
)
response_a = self._verify(order_a.id, 'token-reg-a', issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120000762')
order_b = Order.objects.create(
user_id=user_b.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b',
)
response_b = self._verify(order_b.id, 'token-reg-b', issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_does_not_call_bazaar_or_mutate_order(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-4', 'order-bazaar-rot-4')
first_response = self._verify(order1.id, 'token-rot-4', issue_access_token(guest1.id))
self.assertEqual(first_response.status_code, 200)
mock_verify.reset_mock()
guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-5', 'order-bazaar-rot-5')
blocked_response = self._verify(order2.id, 'token-rot-5', issue_access_token(guest2.id))
self.assertEqual(blocked_response.status_code, 429)
mock_verify.assert_not_called()
order2.refresh_from_db()
self.assertEqual(order2.status, 'pending')
self.assertEqual(order2.bazaar_purchase_token, '')
self.assertFalse(Transaction.objects.filter(order=order2).exists())
class ZarinpalClientTests(TestCase):
"""
تست‌های واحدِ payment/zarinpal.py: بدونِ ZARINPAL_MERCHANT_ID باید
fail-closed باشه (دقیقاً مثلِ bazaar.verify_purchase)، و با کلیدهای
ست‌شده مسیرِ موفق/ناموفقِ درگاه باید درست پارس بشه.
"""
def setUp(self):
self.order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
idempotency_key='zp-client-order-1',
)
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='https://api.example.com/cb')
def test_request_payment_without_merchant_id_is_not_configured(self):
with self.assertRaises(zarinpal.ZarinpalNotConfigured):
zarinpal.request_payment(self.order)
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='')
def test_request_payment_without_callback_url_is_not_configured(self):
with self.assertRaises(zarinpal.ZarinpalNotConfigured):
zarinpal.request_payment(self.order)
@override_settings(
ZARINPAL_MERCHANT_ID='merchant-xyz',
ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
ZARINPAL_API_BASE='https://api.zarinpal.com/pg/v4/payment',
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
)
@patch('payment.zarinpal.requests.post')
def test_request_payment_success_returns_authority_and_url(self, mock_post):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
'data': {'code': 100, 'authority': 'A00000000000000000000000000123456789'},
'errors': [],
}
result = zarinpal.request_payment(self.order)
self.assertEqual(result.authority, 'A00000000000000000000000000123456789')
self.assertEqual(
result.payment_url,
'https://www.zarinpal.com/pg/StartPay/A00000000000000000000000000123456789',
)
_, post_kwargs = mock_post.call_args
self.assertEqual(post_kwargs['json']['merchant_id'], 'merchant-xyz')
self.assertEqual(post_kwargs['json']['amount'], 100000)
self.assertEqual(post_kwargs['json']['currency'], 'IRT')
@override_settings(
ZARINPAL_MERCHANT_ID='merchant-xyz',
ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
)
@patch('payment.zarinpal.requests.post')
def test_request_payment_rejected_by_gateway_raises_api_error(self, mock_post):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {'data': {'code': -9, 'authority': ''}, 'errors': []}
with self.assertRaises(zarinpal.ZarinpalAPIError):
zarinpal.request_payment(self.order)
@override_settings(ZARINPAL_MERCHANT_ID='')
def test_verify_payment_without_merchant_id_is_invalid(self):
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
self.assertFalse(result.is_valid)
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
@patch('payment.zarinpal.requests.post')
def test_verify_payment_success_returns_ref_id(self, mock_post):
mock_post.return_value.json.return_value = {'data': {'code': 100, 'ref_id': 987654321}}
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
self.assertTrue(result.is_valid)
self.assertEqual(result.ref_id, '987654321')
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
@patch('payment.zarinpal.requests.post')
def test_verify_payment_already_verified_is_still_valid(self, mock_post):
# ۱۰۱ یعنی این authority قبلاً تایید شده — idempotent از سمتِ زرین‌پال،
# نه یک خطا.
mock_post.return_value.json.return_value = {'data': {'code': 101, 'ref_id': 987654321}}
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
self.assertTrue(result.is_valid)
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
@patch('payment.zarinpal.requests.post')
def test_verify_payment_failed_is_invalid(self, mock_post):
mock_post.return_value.json.return_value = {'data': {'code': -1}}
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
self.assertFalse(result.is_valid)
class CallPaymentGatewayTests(TestCase):
"""
call_payment_gateway (که create_order موقعِ ساختِ سفارش صدا می‌زنه) نباید
هیچ تماسِ خارجی‌ای با زرین‌پال بزنه — فقط یک URL داخلی برمی‌گردونه.
PaymentRequestِ واقعی به تعویق افتاده تا PaymentRedirectView (نگاه کنید
به کلاس‌های تست پایین‌تر)، دقیقاً تا ساختِ سفارش (که از subscription صدا
زده می‌شه) به دسترس‌بودنِ زرین‌پال گره نخوره.
"""
@override_settings(PUBLIC_BASE_URL='https://api.tadrisino.ir')
def test_returns_internal_url_without_calling_zarinpal(self):
order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
idempotency_key='cpg-order-1',
)
url = call_payment_gateway(order)
self.assertEqual(url, f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay')
class PaymentRedirectViewTests(TestCase):
"""
GET /api/payment/orders/{id}/pay: اولین بازکردن باید authority بسازه و
ریدایرکت کنه؛ بازکردن‌های بعدی نباید دوباره PaymentRequest بزنن.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
idempotency_key='pay-view-order-1',
)
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='')
def test_unconfigured_gateway_returns_503(self):
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
self.assertEqual(response.status_code, 503)
@override_settings(
ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
)
@patch('payment.views.zarinpal.request_payment')
def test_first_open_requests_payment_and_redirects(self, mock_request_payment):
mock_request_payment.return_value = zarinpal.ZarinpalRequestResult(
authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first',
)
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first')
self.order.refresh_from_db()
self.assertEqual(self.order.gateway_authority, 'A-first')
mock_request_payment.assert_called_once()
@override_settings(
ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
)
@patch('payment.views.zarinpal.request_payment')
def test_second_open_reuses_existing_authority(self, mock_request_payment):
self.order.gateway_authority = 'A-existing'
self.order.save(update_fields=['gateway_authority'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing')
mock_request_payment.assert_not_called()
@patch('payment.views.zarinpal.request_payment')
def test_already_resolved_order_does_not_call_gateway(self, mock_request_payment):
self.order.status = 'paid'
self.order.save(update_fields=['status'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
self.assertEqual(response.status_code, 302)
mock_request_payment.assert_not_called()
def test_unknown_order_is_404(self):
response = self.client.get('/api/payment/orders/999999/pay')
self.assertEqual(response.status_code, 404)
def test_bazaar_order_is_404_on_gateway_pay_endpoint(self):
bazaar_order = Order.objects.create(
user_id=1, amount=100000, provider='bazaar',
bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1',
)
response = self.client.get(f'/api/payment/orders/{bazaar_order.id}/pay')
self.assertEqual(response.status_code, 404)
@override_settings(ZARINPAL_REDIRECT_URL='')
class ZarinpalCallbackViewTests(TestCase):
"""
GET /api/payment/zarinpal/callback: امنیت از verify_payment میاد، نه از
Status=OK توی query string — یک Status=OK جعلی بدونِ تاییدِ واقعیِ
زرین‌پال نباید سفارش رو paid کنه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
gateway_authority='A-callback-1', idempotency_key='cb-order-1',
)
def test_unknown_authority_is_404(self):
response = self.client.get('/api/payment/zarinpal/callback?Authority=unknown&Status=OK')
self.assertEqual(response.status_code, 404)
@patch('payment.views.zarinpal.verify_payment')
def test_status_nok_marks_order_failed_without_calling_verify(self, mock_verify):
response = self.client.get(
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=NOK'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['status'], 'failed')
mock_verify.assert_not_called()
self.order.refresh_from_db()
self.assertEqual(self.order.status, 'failed')
@patch('payment.views.zarinpal.verify_payment')
def test_status_ok_with_failed_verification_does_not_mark_paid(self, mock_verify):
# کاربر می‌تونه Status=OK رو دستی توی URL جعل کنه؛ فقط verify_payمنتِ
# واقعی تعیین‌کننده‌ست.
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=False)
response = self.client.get(
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['status'], 'failed')
self.order.refresh_from_db()
self.assertEqual(self.order.status, 'failed')
@patch('payment.views.zarinpal.verify_payment')
def test_status_ok_with_successful_verification_marks_paid(self, mock_verify):
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-123')
response = self.client.get(
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['status'], 'paid')
self.order.refresh_from_db()
self.assertEqual(self.order.status, 'paid')
txn = Transaction.objects.get(order=self.order)
self.assertEqual(txn.gateway, 'zarinpal')
self.assertEqual(txn.gateway_ref_id, 'ref-123')
@patch('payment.views.zarinpal.verify_payment')
def test_repeated_callback_does_not_double_process(self, mock_verify):
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-once')
url = f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
first = self.client.get(url)
second = self.client.get(url)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(Transaction.objects.filter(order=self.order).count(), 1)
@override_settings(ZARINPAL_REDIRECT_URL='tadrisino://payment-result')
@patch('payment.views.zarinpal.verify_payment')
def test_redirect_url_configured_bounces_client_there(self, mock_verify):
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-999')
response = self.client.get(
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, f'tadrisino://payment-result?orderId={self.order.id}&status=paid',
)
class PaymentReturnsToTheAppTests(TestCase):
"""
بعد از پرداخت، کاربر به خودِ اپِ وب برمی‌گردد. قبلاً ZARINPAL_REDIRECT_URL
روی سرور خالی بود و دانش‌آموز بعد از پرداختِ واقعی روی یک صفحه‌ی JSONِ
خام روی دامنه‌ی API می‌ماند.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
def test_default_redirect_is_the_web_app(self):
from django.conf import settings
self.assertEqual(settings.ZARINPAL_REDIRECT_URL, f'{settings.FRONTEND_URL}/')
@override_settings(ZARINPAL_REDIRECT_URL='https://math.tadrisino2.ir/')
@patch('payment.views.zarinpal.verify_payment')
def test_callback_sends_the_student_back_with_the_result(self, mock_verify):
mock_verify.return_value = MagicMock(is_valid=False, ref_id='')
order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
gateway_authority='A-return-1', idempotency_key='ret-1',
)
response = self.client.get('/api/payment/zarinpal/callback?Authority=A-return-1&Status=NOK')
self.assertEqual(response.status_code, 302)
self.assertEqual(
response['Location'],
f'https://math.tadrisino2.ir/?orderId={order.id}&status=failed',
)
@override_settings(ZARINPAL_REDIRECT_URL='https://math.tadrisino2.ir/')
def test_reopening_a_finished_order_goes_back_to_the_app(self):
order = Order.objects.create(
user_id=1, amount=100000, currency='IRT', provider='gateway',
status='paid', idempotency_key='ret-2',
)
response = self.client.get(f'/api/payment/orders/{order.id}/pay')
self.assertEqual(response.status_code, 302)
self.assertEqual(
response['Location'],
f'https://math.tadrisino2.ir/?orderId={order.id}&status=paid',
)