Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in
accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ class ValidateDiscountPermissionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-validate-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000038', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.discount = DiscountCode.objects.create(
|
||||
code='SUMMER10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
@@ -71,7 +71,7 @@ class RedeemOfferCodeThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-offer-redeem-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000039', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _redeem(self, code):
|
||||
@@ -103,7 +103,7 @@ class RedeemOfferCodeThrottleTests(TestCase):
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
|
||||
other_user = User.objects.create(name='', phone='+989120000701')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
@@ -148,22 +148,8 @@ class RedeemOfferCodeThrottleTests(TestCase):
|
||||
mock_redeem.assert_not_called()
|
||||
|
||||
|
||||
class RedeemOfferCodeGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='offer_redeem' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView با
|
||||
هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای probe/brute-force کردنِ کدهای آفر بگیره. چون Offer.code
|
||||
سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ *همون* کاربر رو
|
||||
میگیره، این دورزدن میتونست به یک حسابِ مهمانِ چرخشی اجازه بده همون
|
||||
آفرِ فعال رو دوباره از apply_offer() بگیره و اشتراکِ رایگان/آزمایشیِ
|
||||
مربوطه رو دوباره اعمال کنه. حالا GuestAwareDiscountThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session هم
|
||||
استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
مونده.
|
||||
"""
|
||||
class RedeemOfferCodePerUserThrottleTests(TestCase):
|
||||
"""هر کاربرِ ثبتنامشده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمیرسد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -177,81 +163,21 @@ class RedeemOfferCodeGuestThrottleBypassTests(TestCase):
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._redeem('INVALID-ROT-1', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 400)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._redeem('INVALID-ROT-2', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 400)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3 = User.objects.create(device_id='device-offer-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._redeem('INVALID-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, {'offer_redeem': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-4', is_guest=True, name='')
|
||||
response1 = self._redeem('INVALID-ROT-4', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 400)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-5', is_guest=True, name='')
|
||||
blocked = self._redeem('INVALID-ROT-5', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3 = User.objects.create(device_id='device-offer-guest-rot-6', is_guest=True, name='')
|
||||
still_blocked = self._redeem('INVALID-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, {'offer_redeem': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000801')
|
||||
user_a = User.objects.create(name='', phone='+989120000801')
|
||||
response_a = self._redeem('INVALID-REG-A', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 400)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000802')
|
||||
user_b = User.objects.create(name='', phone='+989120000802')
|
||||
response_b = self._redeem('INVALID-REG-B', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 400)
|
||||
|
||||
def test_blocked_guest_request_does_not_call_redeem_offer_code(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید redeem_offer_code()
|
||||
# رو صدا بزنه — throttle باید *قبل* از رسیدن به منطقِ redemption/
|
||||
# apply_offer رد کنه، حتی با کدِ درست.
|
||||
offer = Offer.objects.create(
|
||||
name='Guest Rotation Offer', kind='free_full', code='GUESTROTBLOCK', is_active=True,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-7', is_guest=True, name='')
|
||||
first_response = self._redeem('INVALID-ROT-7', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 400)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم، حتی با کدِ درست، باید بلاک بشه
|
||||
# (bucket مشترکِ IP از قبل مصرف شده) و نباید به
|
||||
# redeem_offer_code() برسه.
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-8', is_guest=True, name='')
|
||||
with patch('discount.views.redeem_offer_code') as mock_redeem:
|
||||
blocked_response = self._redeem(offer.code, issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_redeem.assert_not_called()
|
||||
|
||||
|
||||
class ValidateDiscountThrottleTests(TestCase):
|
||||
"""
|
||||
@@ -266,7 +192,7 @@ class ValidateDiscountThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-validate-throttle-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000040', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.discount = DiscountCode.objects.create(
|
||||
code='THROTTLE10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
@@ -295,7 +221,7 @@ class ValidateDiscountThrottleTests(TestCase):
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000731')
|
||||
other_user = User.objects.create(name='', phone='+989120000731')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}):
|
||||
@@ -343,7 +269,7 @@ class RedeemDiscountThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-redeem-throttle-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000041', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.order = Order.objects.create(
|
||||
user_id=self.user.id, amount=10000, idempotency_key='order-redeem-throttle-1',
|
||||
@@ -378,7 +304,7 @@ class RedeemDiscountThrottleTests(TestCase):
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000301')
|
||||
other_user = User.objects.create(name='', phone='+989120000301')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
other_order = Order.objects.create(
|
||||
user_id=other_user.id, amount=5000, idempotency_key='order-redeem-throttle-2',
|
||||
@@ -427,32 +353,13 @@ class RedeemDiscountThrottleTests(TestCase):
|
||||
self.assertEqual(self.order.amount, 10000)
|
||||
|
||||
|
||||
class RedeemDiscountGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_redeem' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای probe/brute-force کردنِ کدهای تخفیف بگیره. چون DiscountCode
|
||||
یک رکوردِ سراسری با max_uses مشترک است، این دورزدن صرفاً افشای
|
||||
اطلاعات نبود؛ به مهاجم guessهای واقعیِ بیشتری روی یک کدِ سراسری
|
||||
میداد که هرکدوم میتونست یک Redemption واقعی بسازه و مبلغِ سفارش رو
|
||||
کم کنه. حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو
|
||||
بر اساسِ IP (همون get_ident که guest_session هم استفاده میکنه)
|
||||
میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle
|
||||
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
class RedeemDiscountPerUserThrottleTests(TestCase):
|
||||
"""هر کاربرِ ثبتنامشده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمیرسد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _make_guest_with_order(self, device_id, amount=10000):
|
||||
guest = User.objects.create(device_id=device_id, is_guest=True, name='')
|
||||
order = Order.objects.create(
|
||||
user_id=guest.id, amount=amount, idempotency_key=f'order-{device_id}',
|
||||
)
|
||||
return guest, order
|
||||
|
||||
def _redeem(self, code, order_id, token):
|
||||
return self.client.post(
|
||||
@@ -462,58 +369,20 @@ class RedeemDiscountGuestThrottleBypassTests(TestCase):
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '2/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-1')
|
||||
response1 = self._redeem('PROBE-ROT-1', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 404)
|
||||
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-2')
|
||||
response2 = self._redeem('PROBE-ROT-2', order2.id, issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 404)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-3')
|
||||
response3 = self._redeem('PROBE-ROT-3', order3.id, 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, {'discount_redeem': '1/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-4')
|
||||
response1 = self._redeem('PROBE-ROT-4', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 404)
|
||||
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-5')
|
||||
blocked = self._redeem('PROBE-ROT-5', order2.id, issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-6')
|
||||
still_blocked = self._redeem('PROBE-ROT-6', order3.id, 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, {'discount_redeem': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000401')
|
||||
user_a = User.objects.create(name='', phone='+989120000401')
|
||||
order_a = Order.objects.create(
|
||||
user_id=user_a.id, amount=10000, idempotency_key='order-redeem-reg-a',
|
||||
)
|
||||
response_a = self._redeem('PROBE-REG-A', order_a.id, issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 404)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000402')
|
||||
user_b = User.objects.create(name='', phone='+989120000402')
|
||||
order_b = Order.objects.create(
|
||||
user_id=user_b.id, amount=10000, idempotency_key='order-redeem-reg-b',
|
||||
)
|
||||
@@ -521,42 +390,9 @@ class RedeemDiscountGuestThrottleBypassTests(TestCase):
|
||||
|
||||
self.assertEqual(response_b.status_code, 404)
|
||||
|
||||
def test_blocked_guest_request_does_not_mutate_order_or_create_redemption(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید مبلغِ سفارش رو
|
||||
# تغییر بده یا Redemption بسازه — throttle باید *قبل* از رسیدن به
|
||||
# select_for_update()/منطقِ redemption رد کنه، حتی با کدِ درست.
|
||||
discount = DiscountCode.objects.create(
|
||||
code='REDEEMGUESTROTBLOCK', type='fixed', value=1000, max_uses=5, expires_at=None,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-7')
|
||||
first_response = self._redeem('PROBE-ROT-7', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 404)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم، حتی با کدِ درست روی سفارشِ خودش، باید
|
||||
# بلاک بشه (bucket مشترکِ IP از قبل مصرف شده).
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-8')
|
||||
blocked_response = self._redeem(discount.code, order2.id, issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
order2.refresh_from_db()
|
||||
self.assertEqual(order2.amount, 10000)
|
||||
self.assertEqual(discount.redemptions.count(), 0)
|
||||
|
||||
|
||||
class ValidateDiscountGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_validate' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود، دقیقاً همون مشکلی که
|
||||
RedeemDiscountGuestThrottleBypassTests برای discount_redeem اثبات کرده.
|
||||
چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک
|
||||
bucket throttle تازه برای probe کردنِ type/value کدهای تخفیف بگیره.
|
||||
حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
|
||||
میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle
|
||||
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
class ValidateDiscountPerUserThrottleTests(TestCase):
|
||||
"""هر کاربرِ ثبتنامشده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمیرسد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -570,34 +406,16 @@ class ValidateDiscountGuestThrottleBypassTests(TestCase):
|
||||
'/api/discount/validate', {'code': code}, HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-validate-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._validate(self.discount.code, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-validate-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._validate(self.discount.code, issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-validate-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._validate(self.discount.code, issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000741')
|
||||
user_a = User.objects.create(name='', phone='+989120000741')
|
||||
response_a = self._validate(self.discount.code, issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000742')
|
||||
user_b = User.objects.create(name='', phone='+989120000742')
|
||||
response_b = self._validate(self.discount.code, issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
@@ -20,40 +20,6 @@ from .serializers import (
|
||||
from .services import redeem_offer_code
|
||||
|
||||
|
||||
class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا هم هست.
|
||||
ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||||
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
|
||||
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همهی کاربرهاست؛
|
||||
یک حدسِ درست صرفاً اطلاعات لو نمیده، بلکه یک Redemption واقعی
|
||||
میسازه و مبلغِ سفارش رو کم میکنه — یعنی throttle دورزدهشده به
|
||||
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری میده.
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
lookup/redemption/order دستنخوردهست.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||||
ident = f'guest-ip:{self.get_ident(request)}'
|
||||
elif user and user.is_authenticated:
|
||||
ident = user.pk
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
|
||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||
|
||||
|
||||
def _is_usable(discount: DiscountCode) -> bool:
|
||||
# ⚠️ هر Redemption شمرده میشود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس
|
||||
# سبدِ رهاشده سهمِ max_uses را برای همیشه میسوزاند و خودِ همان کاربر هم
|
||||
@@ -73,11 +39,9 @@ class ValidateDiscountView(APIView):
|
||||
|
||||
# این endpoint type/value کد رو برمیگردونه، پس بدونِ throttle یک
|
||||
# کاربر میتونست بینهایت کد رو probe کنه؛ نرخ کمی بالاتر از
|
||||
# offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
|
||||
# مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
|
||||
# مهمانها کلید رو بر اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
# offer_redeem چون این یک چکِ read-only پیش از خریده.
|
||||
throttle_scope = 'discount_validate'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
|
||||
@@ -112,10 +76,9 @@ class RedeemDiscountView(APIView):
|
||||
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
|
||||
# درست یه کدِ واقعی رو مصرف میکنه (سهمِ max_uses مشترک) و مبلغِ سفارش
|
||||
# رو واقعاً کم میکنه، ضمن اینکه هر تلاش دو select_for_update و یه
|
||||
# تراکنشه. GuestAwareDiscountThrottle برای مهمانها کلید رو بر اساسِ IP
|
||||
# میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
# تراکنشه.
|
||||
throttle_scope = 'discount_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
|
||||
tags=['Discount (discount)'])
|
||||
@@ -209,11 +172,9 @@ class RedeemOfferCodeView(APIView):
|
||||
|
||||
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساستره چون
|
||||
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
|
||||
# کاربر رو میگیره — یک حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو
|
||||
# دوباره بگیره. GuestAwareDiscountThrottle برای مهمانها کلید رو بر
|
||||
# اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
# کاربر رو میگیره.
|
||||
throttle_scope = 'offer_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
|
||||
|
||||
Reference in New Issue
Block a user