from django.test import TestCase from rest_framework.test import APIClient from accounts.authentication import issue_access_token from accounts.models import User from .models import Grade, Lesson, Node, Question, Unit 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)