Files
MathApp/payment/tests_security_review.py
Zareian 28b8527760 Security review: staff admin lockdown, unicode-safe login/phone/grade inputs, demo guest not upgradable, home cache not floodable
- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
  auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:34:42 +03:30

248 lines
13 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.
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
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):
cache.clear()
self.user = User.objects.create(phone='09120009901', is_guest=False)
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):
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):
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):
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):
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)