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 Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion class LessonCompleteImpossibleCountTests(TestCase): """ 🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو بدونِ مقایسه با تعدادِ واقعیِ سوال‌های درس می‌پذیرفت، پس یک کلاینتِ مخرب می‌تونست با correctCount بزرگ‌تر از تعدادِ واقعیِ سوال‌ها، xp/coins متورم بگیره. این تست هم ردِ درخواست‌های غیرممکن رو، هم رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین می‌کنه. """ def setUp(self): self.client = APIClient() self.user = User.objects.create(device_id='device-complete-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) node = Node.objects.create(unit=unit, title='گره ۱', order=1) self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...') for i in range(3): Question.objects.create( lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}', ) def _complete(self, correct_count, total_questions): return self.client.post( f'/api/lessons/{self.lesson.id}/complete', {'correctCount': correct_count, 'totalQuestions': total_questions}, format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}', ) def test_correct_count_exceeding_actual_questions_is_rejected(self): # فقط ۳ سوال واقعی در این درس هست؛ کلاینت ادعای ۱۰۰ تای درست از # ۱۰ تای کل رو می‌کنه. response = self._complete(correct_count=100, total_questions=10) self.assertEqual(response.status_code, 400) self.assertIn('correctCount', response.data) def test_negative_correct_count_is_rejected(self): response = self._complete(correct_count=-1, total_questions=3) self.assertEqual(response.status_code, 400) def test_zero_total_questions_is_rejected(self): response = self._complete(correct_count=1, total_questions=0) self.assertEqual(response.status_code, 400) def test_valid_completion_still_grants_expected_reward(self): # ۳ از ۳ سوالِ واقعیِ همین درس، درست — باید ۳ ستاره و xp کامل بده. response = self._complete(correct_count=3, total_questions=3) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['stars'], 3) self.assertEqual(response.data['xpEarned'], 30) self.assertEqual(response.data['coinsEarned'], 15) class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): """ 🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالی‌ی خودِ کلاینت محاسبه می‌شد، نه از تعدادِ واقعیِ سوال‌های درس. یعنی حتی بعد از سقف‌زدنِ correctCount، کلاینت می‌تونست با totalQuestions=1 (به‌جای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins کامل بگیره درحالی‌که فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio همیشه بر اساسِ actual_question_count محاسبه می‌شه. """ def setUp(self): self.client = APIClient() self.user = User.objects.create(device_id='device-complete-2', 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) node = Node.objects.create(unit=unit, title='گره ۲', order=1) self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...') for i in range(3): Question.objects.create( lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}', ) def _complete(self, correct_count, total_questions): return self.client.post( f'/api/lessons/{self.lesson.id}/complete', {'correctCount': correct_count, 'totalQuestions': total_questions}, format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}', ) def test_manipulated_total_questions_does_not_yield_full_stars(self): # درسِ ۳-سوالی؛ کلاینت فقط ۱ درست داشته ولی totalQuestions=1 # می‌فرسته تا وانمود کنه ۱۰۰٪ جواب داده. response = self._complete(correct_count=1, total_questions=1) self.assertEqual(response.status_code, 200) self.assertNotEqual(response.data['stars'], 3) def test_ratio_is_based_on_actual_lesson_question_count(self): # ۱ از ۳ سوالِ واقعی = ratio نزدیکِ ۰.۳۳ → طبقِ آستانه‌های موجود # (>=0.9 → 3, >=0.6 → 2, >0 → 1)، باید دقیقاً ۱ ستاره بده. 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())