throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -2,7 +2,7 @@ from unittest.mock import Mock, patch
from django.conf import settings
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
@@ -90,6 +90,71 @@ class SendOtpSmsTests(TestCase):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
@override_settings(DEBUG=True)
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچ‌وقت شماره‌تلفنِ کامل رو
# نشون بده؛ فقط ۴ رقمِ آخر (شناسه‌ی امن) باید در پیامِ لاگ باشه.
phone = '+989120000000'
with self.assertLogs('accounts.services', level='INFO') as captured:
send_otp_sms(phone=phone, code='1234')
self.assertEqual(len(captured.records), 1)
message = captured.records[0].getMessage()
self.assertNotIn(phone, message)
self.assertIn('phone_suffix=0000', message)
# رفتار/سطح/کدِ قبلی حفظ شده: کدِ OTP هیچ‌وقت لاگ نمی‌شه، فقط طولش.
self.assertNotIn('1234', message)
self.assertIn('code_len=4', message)
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
def test_provider_error_logs_never_contain_raw_response_body(self):
# 🔒 اثبات فیکسِ این دور: لاگ‌های خطای provider دیگه response.text
# خام رو شامل نمی‌شن؛ فقط context مفید و بی‌خطر (phone_suffix،
# status، provider_status) باقی می‌مونه.
# حالتِ ۱: HTTP غیرِ ۲۰۰.
mock_response = Mock(status_code=400, text='bad request body secret-ish')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('bad request body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('status=400', message)
# حالتِ ۲: بدنه‌ی نامعتبر/غیرقابل‌parse.
mock_response = Mock(status_code=200, text='not json body secret-ish')
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('not json body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('status=200', message)
# حالتِ ۳: ردِ اپلیکیشنی (HTTP 200، return.status != 200).
mock_response = Mock(
status_code=200,
text='{"return": {"status": 401, "message": "secret-ish detail"}, "entries": []}',
)
mock_response.json.return_value = {
'return': {'status': 401, 'message': 'secret-ish detail'},
'entries': [],
}
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('secret-ish detail', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('provider_status=401', message)
class RequestOTPViewTests(TestCase):
"""
@@ -166,7 +231,12 @@ class RequestOTPStaleInvalidationTests(TestCase):
self.assertEqual(response.data['devOtp'], code)
return code
@override_settings(DEBUG=True)
# ⚠️ این کلاس چندین درخواست پشتِ‌سرِهم برای *همون* شماره می‌فرسته (بدونِ
# فاصله)، چون داره رفتارِ invalidation رو می‌سنجه، نه cooldown رو. پس
# cooldown جدیدِ بین‌درخواستی رو اینجا خاموش می‌کنیم تا این تست‌ها با
# اون تداخل نکنن؛ cooldown خودش تستِ اختصاصیِ جدا داره
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_second_request_invalidates_first_otp(self):
phone = '+989121110001'
first_code = self._request_otp(phone, '1111')
@@ -178,7 +248,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
first_otp.refresh_from_db()
self.assertTrue(first_otp.is_used)
@override_settings(DEBUG=True)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_newest_otp_still_works_for_login(self):
phone = '+989121110002'
self._request_otp(phone, '1111')
@@ -189,7 +259,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
)
self.assertEqual(response.status_code, 200)
@override_settings(DEBUG=True)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_older_otp_is_rejected_after_new_request(self):
phone = '+989121110003'
first_code = self._request_otp(phone, '1111')
@@ -282,6 +352,464 @@ class LoginPhoneRateLimitTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
class RequestOTPPhoneRateLimitTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ بر اساسِ شماره‌تلفن روی RequestOTPView: درخواست‌های
تکراریِ صدورِ OTP برای همون شماره باید بعد از حدِ مجاز بلاک بشن، حتی اگه
throttle_scope='otp_request' (IP-محور) رو با تغییرِ IP دور بزنیم. همچنین
درخواستِ بلاک‌شده نباید OTPCode جدید بسازه یا پیامک بفرسته.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
# cooldown بین‌درخواستیِ جدید رو اینجا خاموش می‌کنیم چون این تست‌ها دارن
# سقفِ *ساعتی* رو می‌سنجن (با چند درخواستِ پشتِ‌سرِهم بدونِ فاصله)، نه
# cooldown رو؛ cooldown تستِ اختصاصیِ خودش رو داره
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
phone = '+989127778888'
# otp_request (IP-محور) پیش‌فرض 5/hour است؛ برای اینکه فقط
# محدودیتِ شماره‌تلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
# می‌کنیم تا تداخلی با شمارشِ این تست نداشته باشه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
for _ in range(3):
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
phone = '+989127778889'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
for i in range(3):
# هر درخواست از یک IP متفاوت میاد؛ throttle IP-محور
# (که به‌هرحال بالا باز شده) اینجا اهمیتی نداره — نکته
# اینه که محدودیتِ شماره‌تلفن مستقل از IP باید اعمال بشه.
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
REMOTE_ADDR=f'10.0.0.{i + 1}',
)
self.assertEqual(response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
REMOTE_ADDR='10.0.0.99',
)
self.assertEqual(blocked_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
phone = '+989127778890'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms') as mock_send:
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(mock_send.call_count, 1)
otp_count_before = OTPCode.objects.filter(phone=phone).count()
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
# نه OTPCode جدیدی ساخته شده، نه پیامکِ جدیدی ارسال شده.
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(
OTPCode.objects.filter(phone=phone).count(), otp_count_before,
)
class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
"""
🔒 اثباتِ ایمنی محدودیتِ شماره‌تلفن زیرِ همزمانی: دو درخواستِ *واقعاً*
هم‌زمان (با threading.Barrier هم‌زمان‌شده، نه صرفاً پشتِ‌سرِهم) برای یک
شماره، با سقفِ مجازِ ۱، نباید هر دو موفق بشن. الگویِ cache.add که یک
عملیاتِ اتمیکِ سطحِ backend cache است (نه یک read-then-write ناامن)
دقیقاً همینو تضمین می‌کنه. از TransactionTestCase استفاده می‌شه چون
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش می‌پیچه که برای
درخواست‌هایِ واقعاً هم‌زمان از threadهایِ جدا مناسب نیست.
"""
def setUp(self):
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1)
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
import threading
phone = '+989127778891'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
def _make_request():
client = APIClient()
barrier.wait() # هر دو thread دقیقاً هم‌زمان درخواست بفرستن
response = client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
with results_lock:
results.append(response.status_code)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
threads = [threading.Thread(target=_make_request) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(results), 2)
# دقیقاً یکی باید موفق بشه (200) و اون یکی باید بلاک بشه (429)؛
# هیچ‌وقت هر دو نباید 200 بگیرن.
self.assertEqual(sorted(results), [200, 429])
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
class RequestOTPPhoneCooldownTests(TestCase):
"""
🔒 اثباتِ محدودیتِ جدیدِ cooldown بین‌درخواستی برای RequestOTPView: هدفِ
این محدودیت جلوگیریِ burst (چند درخواست برای همون شماره در عرضِ چند
ثانیه/بدونِ فاصله) است — چیزی که سقفِ ساعتیِ per-phone موجود
(OTP_REQUEST_PHONE_MAX_ATTEMPTS) به‌تنهایی جلوش رو نمی‌گیره، چون فقط
حجمِ کلی رو محدود می‌کنه نه فاصله‌ی بینِ تلاش‌ها رو.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
phone = '+989127779001'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(second_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60, OTP_REQUEST_PHONE_MAX_ATTEMPTS=5)
def test_cooldown_blocks_burst_without_consuming_the_hourly_quota(self):
# پنج درخواستِ پشتِ‌سرِهم بدونِ فاصله برای همون شماره: فقط اولی باید
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
# درخواست‌هایِ بلاک‌شده توسطِ cooldown هیچ‌وقت به شمارنده‌ی ساعتی
# نمی‌رسن، سهمیه‌ی ساعتی مصرف نمی‌شه.
phone = '+989127779002'
# throttle IP-محورِ otp_request (پیش‌فرض 5/hour) رو موقتاً باز
# می‌کنیم تا اینجا فقط cooldown/سهمیه‌ی ساعتیِ per-phone سنجیده بشه،
# نه throttle IP-محورِ جدا (که تستِ اختصاصیِ خودش رو داره).
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms') as mock_send:
statuses = []
for _ in range(5):
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
statuses.append(response.status_code)
self.assertEqual(statuses, [200, 429, 429, 429, 429])
mock_send.assert_called_once()
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
# سهمیه‌ی ساعتی هنوز دست‌نخورده است: بعد از پایانِ cooldown،
# همچنان می‌شه تا سقفِ مجاز درخواست داد.
cache.delete(f'otp_request_phone_cooldown:{phone}')
with patch('accounts.views.send_otp_sms') as mock_send_again:
allowed_again = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(allowed_again.status_code, 200)
mock_send_again.assert_called_once()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
phone = '+989127779003'
with patch('accounts.views.send_otp_sms') as mock_send:
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
otp_count_before = OTPCode.objects.filter(phone=phone).count()
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), otp_count_before)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=1)
def test_cooldown_expires_after_configured_window(self):
import time
phone = '+989127779004'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
time.sleep(1.2)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(second_response.status_code, 200)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_cooldown_is_per_phone_not_global(self):
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
def test_default_cooldown_is_120_seconds(self):
# ⚠️ تصمیمِ محصول/امنیت: کاربر باید هر ۲ دقیقه بتونه OTP دوباره
# بگیره؛ این باید دقیقاً همون مقداریه که enforcement سمتِ سرور
# اعمال می‌کنه (نه صرفاً یک عددِ hardcode‌شده در فرانت).
self.assertEqual(settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS, 120)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_response_reports_retry_after_header_and_body(self):
phone = '+989127779008'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
# هدرِ استانداردِ Retry-After — فرانت باید countdown رو دقیقاً از
# همین مقدار شروع کنه، نه از یک عددِ ثابتِ سمتِ کلاینت.
self.assertIn('Retry-After', blocked_response.headers)
retry_after_header = int(blocked_response.headers['Retry-After'])
self.assertGreaterEqual(retry_after_header, 1)
self.assertLessEqual(retry_after_header, 120)
# همون مقدار در بدنه‌ی پاسخ هم برگردونده می‌شه (retryAfter، هم‌راستا
# با استایلِ camelCase بقیه‌ی فیلدهای response در این پروژه).
self.assertIn('retryAfter', blocked_response.data)
self.assertEqual(blocked_response.data['retryAfter'], retry_after_header)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_retry_after_counts_down_as_time_passes(self):
import time
phone = '+989127779009'
with patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
immediate_retry = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(immediate_retry.status_code, 429)
immediate_retry_after = immediate_retry.data['retryAfter']
time.sleep(1.1)
later_retry = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(later_retry.status_code, 429)
later_retry_after = later_retry.data['retryAfter']
# با گذشتِ زمان، عددِ retryAfter باید کم بشه (نه ثابت بمونه) — این
# همون چیزیه که فرانت برای countdown دقیق بهش نیاز داره.
self.assertLess(later_retry_after, immediate_retry_after)
class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
"""
🔒 اثباتِ ایمنیِ cooldown زیرِ همزمانیِ واقعی (نه صرفاً پشتِ‌سرِهم):
دقیقاً همون الگویِ RequestOTPPhoneRateLimitConcurrencyTests، این‌بار
برایِ کلیدِ cooldown. از TransactionTestCase استفاده می‌شه چون
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش می‌پیچه که برایِ
درخواست‌هایِ واقعاً هم‌زمان از threadهایِ جدا مناسب نیست.
"""
def setUp(self):
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
import threading
phone = '+989127779007'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
def _make_request():
client = APIClient()
barrier.wait()
response = client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
with results_lock:
results.append(response.status_code)
with patch('accounts.views.send_otp_sms'):
threads = [threading.Thread(target=_make_request) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(results), 2)
self.assertEqual(sorted(results), [200, 429])
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_is_cleared_when_sms_delivery_fails(self):
# ⚠️ فیکسِ باگ: قبلاً cooldown *قبل از* فراخوانیِ send_otp_sms ثبت
# می‌شد. اگه ارسالِ پیامک با OTPDeliveryError شکست می‌خورد، کاربر
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک می‌موند.
# این تست تضمین می‌کنه که با شکستِ ارسال، cooldown پاک می‌شه و
# کاربر می‌تونه فوراً دوباره تلاش کنه.
phone = '+989127779010'
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 503)
# اثباتِ اینکه cooldown دیگه فعال نیست: بلافاصله یک تلاشِ دیگه (این‌بار
# موفق) باید 429 نگیره.
with patch('accounts.views.send_otp_sms') as mock_send:
retry_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(retry_response.status_code, 200)
mock_send.assert_called_once()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_still_active_after_successful_delivery(self):
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
# رفتارِ قبلی فعال می‌مونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
phone = '+989127779011'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
self.assertIn('Retry-After', blocked_response.headers)
class RefreshTokenThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ throttle_scope='token_refresh' روی
RefreshTokenView: درخواست‌های عادی و کافی موفق باشن، درخواست‌های
بیش‌ازحد با ۴۲۹ رد بشن، و یک درخواستِ بلاک‌شده هیچ Session جدیدی
نسازه/revoke نکنه و هیچ توکنِ جدیدی تولید نکنه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
def _create_session(self, token_suffix):
return Session.objects.create(
user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}',
device_id='device-refresh-throttle-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
)
self.assertEqual(response.status_code, 200)
def test_excessive_refresh_requests_are_eventually_rejected(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
)
self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra')
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_does_not_revoke_or_create_session_or_token(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first')
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
)
self.assertEqual(first_response.status_code, 200)
session_count_before = Session.objects.count()
blocked_session = self._create_session('blocked')
with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called()
# هیچ Session جدیدی ساخته نشده (فقط همون session ای که قبل از
# درخواستِ بلاک‌شده دستی ساختیم) و session ای که با آن تلاش
# کردیم هنوز revoked=False و دست‌نخورده است.
self.assertEqual(Session.objects.count(), session_count_before + 1)
blocked_session.refresh_from_db()
self.assertFalse(blocked_session.revoked)
class GuestSessionThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی