throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
@@ -296,3 +298,224 @@ class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase):
|
||||
self.assertEqual(subscription.plan_id, self.plan.id)
|
||||
self.assertIsNone(subscription.order_id)
|
||||
self.assertEqual(subscription.version, 2)
|
||||
|
||||
|
||||
class PurchaseThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی PurchaseView (throttle_scope='purchase'):
|
||||
تعدادِ زیادِ درخواستِ purchase با Idempotency-Key های *متفاوت* از یک
|
||||
اکانتِ احرازهویتشده باید بعد از حدِ مجاز با ۴۲۹ رد بشه — چون محافظتِ
|
||||
idempotency فقط جلوی reuse یک کلیدِ یکسان رو میگیره، نه حجمِ بالای
|
||||
تلاشهای *متفاوت*. ScopedRateThrottle اینجا با pk کاربر (نه IP) کلید
|
||||
میخوره، پس تغییرِ IP از همون اکانت این محدودیت رو دور نمیزنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-purchase-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.plan = Plan.objects.create(
|
||||
title='ماهانه', price=100000, currency='IRT', duration_days=30,
|
||||
)
|
||||
|
||||
def _purchase(self, idempotency_key, remote_addr=None):
|
||||
kwargs = {}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
|
||||
'purchase': '3/hour',
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
# ⚠️ مثلِ GuestSessionThrottleTests: override_settings بهتنهایی روی
|
||||
# ScopedRateThrottle.THROTTLE_RATES (که یکبار موقعِ import گرفته
|
||||
# شده) اثر نداره؛ برای اینکه نرخِ واقعاً محدودشده اعمال بشه، مستقیماً
|
||||
# دیکشنریِ زندهی THROTTLE_RATES رو patch میکنیم.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._purchase(f'key-within-limit-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_purchase_attempts_from_same_user_are_blocked(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._purchase(f'key-block-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._purchase('key-block-extra')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# PurchaseGuestThrottleBypassTests پایینتر) — این تست فقط رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه که
|
||||
# باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _purchase_as(idempotency_key, remote_addr):
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = _purchase_as(f'key-ip-{i}', f'10.1.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _purchase_as('key-ip-extra', '10.1.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_order_and_does_not_mutate_subscription(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
first_response = self._purchase('key-mutate-1')
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
subscription = Subscription.objects.get(user_id=self.user.id)
|
||||
snapshot = {
|
||||
'status': subscription.status,
|
||||
'plan_id': subscription.plan_id,
|
||||
'order_id': subscription.order_id,
|
||||
'version': subscription.version,
|
||||
}
|
||||
order_count_before = Order.objects.filter(user_id=self.user.id).count()
|
||||
|
||||
with patch('subscription.views.create_order') as mock_create_order:
|
||||
blocked_response = self._purchase('key-mutate-2')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_create_order.assert_not_called()
|
||||
|
||||
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), order_count_before)
|
||||
subscription.refresh_from_db()
|
||||
self.assertEqual(subscription.status, snapshot['status'])
|
||||
self.assertEqual(subscription.plan_id, snapshot['plan_id'])
|
||||
self.assertEqual(subscription.order_id, snapshot['order_id'])
|
||||
self.assertEqual(subscription.version, snapshot['version'])
|
||||
|
||||
|
||||
class PurchaseGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='purchase' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای سقفِ ۱۰/ساعتِ purchase بگیره. این endpoint حساسِ مالیه: هر
|
||||
درخواستِ پذیرفتهشده Subscription میسازه/آپدیت میکنه و create_order()
|
||||
رو صدا میزنه. حالا GuestAwarePurchaseThrottle برای کاربرهای مهمان
|
||||
کلید رو بر اساسِ IP (همون get_ident که guest_session هم استفاده
|
||||
میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ
|
||||
throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.plan = Plan.objects.create(
|
||||
title='ماهانه', price=100000, currency='IRT', duration_days=30,
|
||||
)
|
||||
|
||||
def _purchase(self, idempotency_key, token):
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._purchase('key-guest-rot-1', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._purchase('key-guest-rot-2', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3 = User.objects.create(device_id='device-purchase-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._purchase('key-guest-rot-3', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
|
||||
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
|
||||
# (چهارمین pk) هم نباید bucket تازهای بگیره — سقف هنوز به IP
|
||||
# مشترک متصل است.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-4', is_guest=True, name='')
|
||||
response1 = self._purchase('key-guest-rot-4', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-5', is_guest=True, name='')
|
||||
blocked = self._purchase('key-guest-rot-5', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3 = User.objects.create(device_id='device-purchase-guest-rot-6', is_guest=True, name='')
|
||||
still_blocked = self._purchase('key-guest-rot-6', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(still_blocked.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000601')
|
||||
response_a = self._purchase('key-reg-a', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000602')
|
||||
response_b = self._purchase('key-reg-b', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_mutate_subscription_or_call_purchase_logic(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید Subscription/Order
|
||||
# بسازه یا create_order() رو صدا بزنه — throttle باید *قبل* از
|
||||
# رسیدن به منطقِ purchase رد کنه، حتی با Idempotency-Key معتبر.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-7', is_guest=True, name='')
|
||||
first_response = self._purchase('key-guest-rot-7', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم باید بلاک بشه (bucket مشترکِ IP از
|
||||
# قبل مصرف شده) و نباید Subscription/Order برای آن بسازد.
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-8', is_guest=True, name='')
|
||||
with patch('subscription.views.create_order') as mock_create_order:
|
||||
blocked_response = self._purchase('key-guest-rot-8', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_create_order.assert_not_called()
|
||||
|
||||
self.assertFalse(Subscription.objects.filter(user_id=guest2.id).exists())
|
||||
self.assertEqual(Order.objects.filter(user_id=guest2.id).count(), 0)
|
||||
|
||||
Reference in New Issue
Block a user