throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
@@ -21,6 +23,11 @@ class AskIdempotencyFollowUpsTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: بعد از اضافهشدنِ ScopedRateThrottle (scope='ai_ask')
|
||||
# به AskAIView، cache باید بینِ تستها پاک بشه؛ وگرنه شمارشگرِ throttle
|
||||
# (که در محیطِ تست روی LocMemCache نگهداری میشه و بهصورتِ خودکار
|
||||
# بینِ تستها پاک نمیشه) میتونه از یک تستِ قبلی باقی بمونه.
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -73,6 +80,8 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -163,6 +172,8 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -238,6 +249,8 @@ class AskAIConversationApiContractTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -305,6 +318,10 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
تاریخچهی خامِ مکالمه رو بسازه و به این مرز بفرسته.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
|
||||
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
|
||||
# مرز باید grade_id/lesson_id رو (بهعنوانِ metadataِ RAGِ آینده) و
|
||||
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیامهای
|
||||
@@ -398,3 +415,100 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
# نشدن، None هستن.
|
||||
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
|
||||
self.assertIsNone(context_arg)
|
||||
|
||||
|
||||
class AskAIViewThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='ai_ask') روی
|
||||
AskAIView: قبلاً این endpoint هیچ throttle مخصوصی نداشت (فقط
|
||||
DAILY_AI_QUOTA که سقفِ روزانه است، نه محدودیتِ burst کوتاهمدت).
|
||||
این تست ثابت میکند که درخواستهای مجاز داخلِ نرخ همچنان کار میکنند
|
||||
و درخواستِ بیش از حد با 429 رد میشود — بدون اینکه به کوتای روزانه یا
|
||||
idempotency دست بزند.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='device-ai-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _ask(self, idempotency_key):
|
||||
return self.client.post(
|
||||
'/api/ai/ask',
|
||||
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
|
||||
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
|
||||
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._ask(f'ai-ask-throttle-key-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
throttled_response = self._ask('ai-ask-throttle-key-3')
|
||||
self.assertEqual(throttled_response.status_code, 429)
|
||||
|
||||
|
||||
class AskAIViewGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='ai_ask' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||||
مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket
|
||||
throttle تازه برای AskAIView بگیره. حالا GuestAwareAIThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session
|
||||
هم استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه
|
||||
نمیده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل)
|
||||
دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _ask(self, token, idempotency_key):
|
||||
return self.client.post(
|
||||
'/api/ai/ask',
|
||||
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||
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, {'ai_ask': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._ask(issue_access_token(guest1.id), 'guest-rot-key-1')
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._ask(issue_access_token(guest2.id), 'guest-rot-key-2')
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._ask(issue_access_token(guest3.id), 'guest-rot-key-3')
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000101')
|
||||
response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1')
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000102')
|
||||
response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1')
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
Reference in New Issue
Block a user