Files
MathApp/payment/tests_security_review.py
Zareian 9c3adc3e60 Subscriptions only: Bazaar panel prices (598/898/1398k), assistant quota 5/3/3 from a cost calculation, packs off sale
- subscription.0016: prices equal to the Bazaar panel (senior), ai_daily_quota 5/3/3, card features; packs on_sale=False
  (one paid pack order, user 26, keeps its credits; pack paths stay for that order and for admin).
- Quota math in docs/ai-cost-and-guardrails.md: ~77% of a Bazaar sale reaches us; worst case (full quota every day)
  the assistant costs 23/27/35% of it.
- Assistant quota message points to the subscription, not packs.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:16 +03:30

253 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan
from subscription.services import credit_balance
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .services import apply_order_payment_result
def client_for(user):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return c
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120009901')
self.app = client_for(self.user)
self.pack = Plan.objects.get(kind='pack', question_credits=20)
def tearDown(self):
cache.clear()
def pack_order(self, **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
return Order.objects.get(pk=r.json()['orderId'])
class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
staff.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(staff)
def test_refund_is_refused(self):
order = self.pack_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
def test_cancel_is_refused(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
self.client.post('/admin/payment/order/', {'action': 'cancel_pending', '_selected_action': [order.pk]})
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class CallbackWithoutAuthorityTests(_Base):
def test_missing_authority_is_400_and_touches_no_order(self):
DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
order = self.pack_order()
self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
format='json').status_code, 200)
Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
resp = APIClient().get('/api/payment/zarinpal/callback', {'Status': 'NOK'})
self.assertEqual(resp.status_code, 400)
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class PlanEditedBetweenOrderAndPaymentTests(_Base):
def test_buyer_gets_what_the_order_sold(self):
order = self.pack_order()
Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(order.question_credits, 20)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
def test_second_token_is_409_and_stays_usable(self):
order = self.pack_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
with patch('payment.bazaar.verify_purchase', return_value=valid):
first = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
again = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
second = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(first.json()['status'], 'paid')
self.assertEqual(again.status_code, 200) # همان توکن دوباره: بی‌اثر و بی‌خطا
self.assertEqual(second.status_code, 409)
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
with patch('payment.bazaar.verify_purchase', return_value=valid):
fresh = self.app.post('/api/payment/bazaar/verify',
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-p20')
self.big = Plan.objects.filter(kind='pack').exclude(pk=self.pack.pk).order_by('-price').first()
Plan.objects.filter(pk=self.big.pk).update(bazaar_sku='sec-pbig')
def verify(self, order_id, token, result):
with patch('payment.bazaar.verify_purchase', return_value=result):
return self.app.post('/api/payment/bazaar/verify', {'orderId': str(order_id), 'purchaseToken': token},
format='json')
def test_same_order_after_a_rejection(self):
order = self.pack_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=True, purchase_state=0))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
def test_token_sent_with_the_wrong_order_still_pays_the_right_one(self):
small = self.pack_order(paymentMethod='bazaar')
big = self.app.post('/api/subscription/purchase', {'planId': str(self.big.id), 'paymentMethod': 'bazaar'},
format='json').json()['orderId']
self.verify(big, 'TOK-S', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(small.id, 'TOK-S', BazaarPurchaseResult(is_valid=True, purchase_state=0))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(Order.objects.get(pk=big).status, 'failed')
def test_refunded_order_refuses_a_new_token(self):
from .services import refund_order
order = self.pack_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True, purchase_state=0))
refund_order(Order.objects.get(pk=order.pk))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True, purchase_state=0))
self.assertEqual(r.status_code, 409)
self.assertEqual(Order.objects.get(pk=order.pk).status, 'refunded')
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOK-2').exists())
class OrderAdminMoneyFieldsReadOnlyTests(_Base):
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و تعدادِ سوالِ سفارش ویرایش‌شدنی بود."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
from django.contrib.auth.models import Permission
staff = get_user_model().objects.create_user('ops', 'ops@example.com', 'pw-for-tests-only', is_staff=True)
group = Group.objects.create(name='ops-sec')
group.permissions.set(Permission.objects.filter(
content_type__app_label='payment',
codename__in=['view_order', 'change_order', 'delete_order', 'add_order',
'view_transaction', 'change_transaction', 'delete_transaction', 'add_transaction']))
staff.groups.add(group)
self.client.force_login(staff)
def test_amount_and_credits_cannot_be_edited(self):
order = self.pack_order()
data = {'amount': 1000, 'question_credits': 100000, 'user_id': order.user_id,
'transactions-TOTAL_FORMS': 0, 'transactions-INITIAL_FORMS': 0,
'transactions-MIN_NUM_FORMS': 0, 'transactions-MAX_NUM_FORMS': 1000}
self.client.post(f'/admin/payment/order/{order.pk}/change/', data)
order.refresh_from_db()
self.assertEqual((order.amount, order.question_credits), (self.pack.price, 20))
page = self.client.get(f'/admin/payment/order/{order.pk}/change/')
self.assertNotContains(page, 'name="amount"')
def test_orders_and_transactions_are_not_deleted_or_added_from_the_panel(self):
order = self.pack_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='DEL-1', new_status='paid', event='t')
self.client.post(f'/admin/payment/order/{order.pk}/delete/', {'post': 'yes'})
txn = Transaction.objects.get(gateway_ref_id='DEL-1')
self.client.post(f'/admin/payment/transaction/{txn.pk}/delete/', {'post': 'yes'})
self.assertTrue(Order.objects.filter(pk=order.pk).exists())
self.assertTrue(Transaction.objects.filter(pk=txn.pk).exists())
self.assertEqual(self.client.get('/admin/payment/order/add/').status_code, 403)
self.assertEqual(self.client.get('/admin/payment/transaction/add/').status_code, 403)
class DiscountDuringPaymentRequestTests(_Base):
"""کدِ تخفیفی که وسطِ PaymentRequest ثبت شود، authority را با مبلغِ قبلی روی سفارشِ ارزان‌تر می‌نشاند."""
def test_authority_matches_the_final_amount(self):
from .zarinpal import ZarinpalRequestResult
DiscountCode.objects.create(code='SECHALF', type='percent', value=50, max_uses=5)
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
order_id, pay_url = r.json()['orderId'], r.json()['paymentUrl']
requested = []
def fake_request(order):
requested.append(order.amount)
if len(requested) == 1: # کاربر در تبِ دیگر همان لحظه کد را می‌زند
self.app.post('/api/discount/redeem', {'code': 'SECHALF', 'orderId': order_id}, format='json')
return ZarinpalRequestResult(authority=f'A-{len(requested)}', payment_url='x')
with patch('payment.zarinpal.request_payment', side_effect=fake_request):
resp = APIClient().get('/api/' + pay_url.split('/api/', 1)[1])
order = Order.objects.get(pk=order_id)
self.assertEqual(resp.status_code, 302)
self.assertEqual(order.amount, self.pack.price // 2)
self.assertEqual(requested, [self.pack.price, self.pack.price // 2])
self.assertEqual(order.gateway_authority, 'A-2')
class BazaarTokenNotGuessableByAdminFilterTests(TestCase):
"""فیلترِ querystring روی refresh_token/pending_state توکنِ بازار را حرف‌به‌حرف لو نمی‌دهد."""
def test_filter_is_refused(self):
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from .models import BazaarCredential
BazaarCredential.objects.update_or_create(pk=1, defaults={'refresh_token': 'TOPSECRET', 'pending_state': 'st8'})
staff = get_user_model().objects.create_user('bz', 'bz@example.com', 'pw-for-tests-only', is_staff=True)
staff.user_permissions.add(Permission.objects.get(content_type__app_label='payment', codename='view_bazaarcredential'))
self.client.force_login(staff)
self.assertEqual(self.client.get('/admin/payment/bazaarcredential/').status_code, 200)
for lookup in ('refresh_token__startswith', 'pending_state__startswith', 'refresh_token__regex'):
r = self.client.get('/admin/payment/bazaarcredential/', {lookup: 'T'})
self.assertEqual(r.status_code, 400, lookup)