throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
422
content/tests.py
422
content/tests.py
@@ -1,10 +1,14 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
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
|
||||
from progress.models import UserHearts, UserStats
|
||||
|
||||
from .models import Grade, Lesson, Node, Question, Unit
|
||||
from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
|
||||
|
||||
|
||||
class LessonCompleteImpossibleCountTests(TestCase):
|
||||
@@ -107,3 +111,419 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
|
||||
response = self._complete(correct_count=1, total_questions=1)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['stars'], 1)
|
||||
|
||||
|
||||
class QuestionAnswerThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView
|
||||
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-answer-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
self.question = Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
|
||||
)
|
||||
self.correct_choice = Choice.objects.create(
|
||||
question=self.question, value='درست', is_correct=True,
|
||||
)
|
||||
|
||||
def _answer(self, idempotency_key=None, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if idempotency_key:
|
||||
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._answer()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._answer()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._answer()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# QuestionAnswerGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _answer_as(remote_addr):
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for i in range(3):
|
||||
response = _answer_as(f'10.2.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _answer_as('10.2.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_answer_and_no_hearts_or_progress_change(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||||
first_response = self._answer()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
answer_count_before = UserAnswer.objects.filter(user_id=self.user.id).count()
|
||||
hearts_before = UserHearts.objects.filter(user_id=self.user.id).first()
|
||||
hearts_snapshot = hearts_before.current_hearts if hearts_before else None
|
||||
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
progress_snapshot = (
|
||||
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
|
||||
if progress_before else None
|
||||
)
|
||||
|
||||
blocked_response = self._answer()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
self.assertEqual(
|
||||
UserAnswer.objects.filter(user_id=self.user.id).count(), answer_count_before,
|
||||
)
|
||||
hearts_after = UserHearts.objects.filter(user_id=self.user.id).first()
|
||||
self.assertEqual(
|
||||
hearts_after.current_hearts if hearts_after else None, hearts_snapshot,
|
||||
)
|
||||
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
if progress_snapshot is None:
|
||||
self.assertIsNone(progress_after)
|
||||
else:
|
||||
self.assertEqual(progress_after.status, progress_snapshot['status'])
|
||||
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
|
||||
|
||||
def test_idempotency_key_retry_still_works_within_the_limit(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '10/min'}):
|
||||
first_response = self._answer(idempotency_key='retry-key-1')
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
second_response = self._answer(idempotency_key='retry-key-1')
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
UserAnswer.objects.filter(
|
||||
user_id=self.user.id, question=self.question, idempotency_key='retry-key-1',
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
class LessonCompleteThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView
|
||||
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserLessonCompletion جدید میسازن و سیگنالِ post_save
|
||||
(NodeProgress) + بهروزرسانیِ UserStats رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-complete-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, idempotency_key=None, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if idempotency_key:
|
||||
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._complete()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._complete()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._complete()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# LessonCompleteGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _complete_as(remote_addr):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for i in range(3):
|
||||
response = _complete_as(f'10.3.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _complete_as('10.3.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_completion_or_other_side_effects(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/min'}):
|
||||
first_response = self._complete()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
completion_count_before = UserLessonCompletion.objects.filter(user_id=self.user.id).count()
|
||||
stats_before = UserStats.objects.filter(user_id=self.user.id).first()
|
||||
stats_snapshot = (
|
||||
{'xp': stats_before.xp, 'coins': stats_before.coins, 'progress_percent': stats_before.progress_percent}
|
||||
if stats_before else None
|
||||
)
|
||||
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
progress_snapshot = (
|
||||
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
|
||||
if progress_before else None
|
||||
)
|
||||
|
||||
blocked_response = self._complete()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
self.assertEqual(
|
||||
UserLessonCompletion.objects.filter(user_id=self.user.id).count(), completion_count_before,
|
||||
)
|
||||
stats_after = UserStats.objects.filter(user_id=self.user.id).first()
|
||||
if stats_snapshot is None:
|
||||
self.assertIsNone(stats_after)
|
||||
else:
|
||||
self.assertEqual(stats_after.xp, stats_snapshot['xp'])
|
||||
self.assertEqual(stats_after.coins, stats_snapshot['coins'])
|
||||
self.assertEqual(stats_after.progress_percent, stats_snapshot['progress_percent'])
|
||||
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
if progress_snapshot is None:
|
||||
self.assertIsNone(progress_after)
|
||||
else:
|
||||
self.assertEqual(progress_after.status, progress_snapshot['status'])
|
||||
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
|
||||
|
||||
|
||||
class QuestionAnswerGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه بگیره و منطقِ هارت/NodeProgress رو بیمحدودیت اجرا کنه. حالا
|
||||
GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
|
||||
(همون get_ident که guest_session هم استفاده میکنه) میسازه، پس چرخشِ
|
||||
حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرِ
|
||||
غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
|
||||
self.question = Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
|
||||
)
|
||||
self.correct_choice = Choice.objects.create(
|
||||
question=self.question, value='درست', is_correct=True,
|
||||
)
|
||||
|
||||
def _answer(self, token):
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
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, {'question_answer': '2/min'}):
|
||||
guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._answer(issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._answer(issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._answer(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, {'question_answer': '1/min'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000711')
|
||||
response_a = self._answer(issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000712')
|
||||
response_b = self._answer(issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_creates_no_answer_or_hearts_change(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه
|
||||
# یا هارت/progress رو تغییر بده.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||||
guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._answer(issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._answer(issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0)
|
||||
self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists())
|
||||
|
||||
|
||||
class LessonCompleteGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بیمحدودیت
|
||||
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
|
||||
بر اساسِ IP میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, token):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
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, {'lesson_complete': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._complete(issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._complete(issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._complete(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, {'lesson_complete': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000721')
|
||||
response_a = self._complete(issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000722')
|
||||
response_b = self._complete(issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion
|
||||
# بسازه یا UserStats/NodeProgress رو تغییر بده.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._complete(issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._complete(issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0)
|
||||
self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists())
|
||||
self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists())
|
||||
|
||||
@@ -5,6 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from progress.models import UserHearts, UserStats
|
||||
@@ -23,6 +24,40 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwareContentThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده
|
||||
از request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، برای هر
|
||||
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
|
||||
بگیره و محدودیتِ واقعی رو دور بزنه — درحالیکه هر دو endpoint اثرِ
|
||||
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
|
||||
xp/coins/progress).
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
answer/heart/progress/completion/idempotency دستنخوردهست.
|
||||
"""
|
||||
|
||||
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}
|
||||
|
||||
|
||||
class GradesListView(APIView):
|
||||
"""GET /api/grades — لیست پایهها"""
|
||||
|
||||
@@ -130,6 +165,20 @@ class QuestionAnswerView(APIView):
|
||||
|
||||
# userId همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن —
|
||||
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
|
||||
# احرازهویتشده باشه با pk کاربر کلید میخوره (نه فقط IP)، پس تغییرِ
|
||||
# IP این محدودیت رو دور نمیزنه.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareContentThrottleِ مشترک استفاده میشه که برای کاربرهای
|
||||
# مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ
|
||||
# per-user قبلی رو دستنخورده نگه میداره.
|
||||
throttle_scope = 'question_answer'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=AnswerRequestSerializer,
|
||||
@@ -229,6 +278,18 @@ class LessonCompleteView(APIView):
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
|
||||
# اجباریه و همیشه از request.user.id استفاده میشه.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserLessonCompletion جدید میسازن و سیگنالِ post_save (NodeProgress) +
|
||||
# بهروزرسانیِ UserStats رو اجرا میکنن. ScopedRateThrottle با pk
|
||||
# کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
|
||||
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
|
||||
# مشترک استفاده میشه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
|
||||
# endpoint رکوردِ completion ماندگار میسازه و xp/coins/progress رو
|
||||
# واقعاً تغییر میده (برخلافِ question_answer که فرکانسِ طبیعیاش بالاتره).
|
||||
throttle_scope = 'lesson_complete'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=LessonCompleteRequestSerializer,
|
||||
|
||||
Reference in New Issue
Block a user