connect Zarinpal as the real payment gateway
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):
- create_order/CreateOrderView still never call Zarinpal directly —
paymentUrl points at our own GET /api/payment/orders/{id}/pay
(PaymentRedirectView), which requests the real Zarinpal authority on
first visit and redirects to StartPay. This keeps order creation from
depending on Zarinpal's uptime/latency, and means none of the existing
purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
Zarinpal redirects the user back to; it always re-verifies
server-to-server via zarinpal.verify_payment before marking an order
paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
isn't configured, matching payment/bazaar.py's existing convention —
never a fake successful payment.
Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
285
payment/tests.py
285
payment/tests.py
@@ -10,10 +10,11 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from . import bazaar
|
||||
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):
|
||||
@@ -364,3 +365,285 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase):
|
||||
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)
|
||||
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user