Files
MathApp/payment/tests_security_review.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

251 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 django.utils import timezone
from subscription.models import Plan, Subscription
from subscription.services import has_active_subscription
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')
self.app = client_for(self.user)
self.plan = Plan.objects.get(on_sale=True, duration_days=90)
def tearDown(self):
cache.clear()
def sub_order(self, key='sec-k', **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id), **extra}, format='json',
HTTP_IDEMPOTENCY_KEY=key)
return Order.objects.get(pk=r.json()['orderId'])
def days_left(self):
return (Subscription.objects.get(user_id=self.user.id).expires_at - timezone.now()).days
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.sub_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.assertTrue(has_active_subscription(self.user.id))
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.sub_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.sub_order()
Plan.objects.filter(pk=self.plan.pk).update(price=order.amount * 5, duration_days=365)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertEqual(order.duration_days, 90)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
super().setUp()
def test_second_token_is_409_and_stays_usable(self):
order = self.sub_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True)
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.plan.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.assertAlmostEqual(self.days_left(), 179, delta=1)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند."""
def setUp(self):
super().setUp()
self.big = Plan.objects.get(on_sale=True, duration_days=180)
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.sub_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))
self.assertEqual(r.json()['status'], 'paid')
self.assertAlmostEqual(self.days_left(), 89, delta=1)
def test_token_sent_with_the_wrong_order_still_pays_the_right_one(self):
small = self.sub_order(paymentMethod='bazaar')
big = self.app.post('/api/subscription/purchase', {'planId': str(self.big.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-big').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))
self.assertEqual(r.json()['status'], 'paid')
self.assertAlmostEqual(self.days_left(), 89, delta=1)
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.sub_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True))
refund_order(Order.objects.get(pk=order.pk))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True))
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_duration_cannot_be_edited(self):
order = self.sub_order()
data = {'amount': 1000, 'duration_days': 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.duration_days), (self.plan.price, 90))
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.sub_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.plan.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.plan.price // 2)
self.assertEqual(requested, [self.plan.price, self.plan.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)