Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
120
payment/tests_security_review.py
Normal file
120
payment/tests_security_review.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
حفاظهای بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه میدارد که پیشتر غلط بود.
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user