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:
2026-10-05 18:20:10 +03:30
parent 8835e01595
commit 663252a260
61 changed files with 357 additions and 1518 deletions

View File

@@ -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)