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

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