Files
MathApp/payment/tests.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

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

684 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
from .services import call_payment_gateway
class SecretDefaultsFailClosedTests(TestCase):
"""
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY
دیگه در پروداکشن (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,
INTERNAL_SERVICE_KEY='prod-internal-key-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))
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'}
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',
)
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',
)