Files
MathApp/content/tests_read_throttles.py
Zareian 7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30

99 lines
3.9 KiB
Python

"""
مسیرهای خواندنی‌ای که تا پیش از این سقفِ نرخ نداشتند (بند ۳۰ در docs/security-before-production.md).
هر تست نرخِ `read` را روی ۳ در دقیقه می‌گذارد و می‌بیند درخواستِ چهارم ۴۲۹ می‌گیرد.
"""
from unittest import mock
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
from content.models import Grade, Lesson, Node, Unit
from notifications.models import Notification
from support.models import Ticket
class ReadEndpointsAreThrottledTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='G', icon='', color='', number=77)
unit = Unit.objects.create(grade=grade, title='U', order=1)
node = Node.objects.create(unit=unit, title='N', order=1)
self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x')
self.user = User.objects.create(phone='09120008888')
self.notification = Notification.objects.create(user_id=self.user.id, title='t', body='b')
self.ticket = Ticket.objects.create(user_id=self.user.id, subject='s')
self.client = APIClient()
def _auth(self):
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def _hammer(self, method, url, scope='read'):
rates = dict(ScopedRateThrottle.THROTTLE_RATES, **{scope: '3/min'})
with mock.patch.object(ScopedRateThrottle, 'THROTTLE_RATES', rates):
codes = [getattr(self.client, method)(url).status_code for _ in range(5)]
self.assertEqual(codes[3:], [429, 429], codes)
self.assertNotIn(429, codes[:3], codes)
def test_lesson_intro_without_login(self):
self._hammer('get', f'/api/lessons/{self.lesson.id}/intro')
def test_lesson_questions_without_login(self):
self._hammer('get', f'/api/lessons/{self.lesson.id}/questions')
def test_progress_nodes(self):
self._auth()
self._hammer('get', '/api/progress/nodes')
def test_hearts(self):
self._auth()
self._hammer('get', '/api/user/hearts')
def test_notification_mark_read(self):
self._auth()
self._hammer('patch', f'/api/notifications/{self.notification.id}/read')
def test_ticket_detail(self):
self._auth()
self._hammer('get', f'/api/support/tickets/{self.ticket.id}')
# مسیرهای دسته‌ی دوم. شناسه‌ها عمداً وجود ندارند: throttle پیش از ویو اجرا می‌شود، پس
# ۴۰۴ هم یک درخواستِ شمرده‌شده است و جلوی ساختنِ داده‌ی هر مسیر را می‌گیرد.
def test_plans_without_login(self):
self._hammer('get', '/api/subscription/plans')
def test_subscription_status(self):
self._auth()
self._hammer('get', '/api/subscription/status')
def test_order_detail(self):
self._auth()
self._hammer('get', '/api/payment/orders/999999')
def test_my_offers(self):
self._auth()
self._hammer('get', '/api/discount/offers/mine')
def test_affiliate_me(self):
self._auth()
self._hammer('get', '/api/affiliate/me')
def test_affiliate_conversions(self):
self._auth()
self._hammer('get', '/api/affiliate/conversions')
def test_conversation_messages(self):
self._auth()
self._hammer('get', '/api/ai/conversations/999999/messages')
def test_message_feedback(self):
self._auth()
self._hammer('post', '/api/ai/messages/999999/feedback', scope='user_profile_write')
def test_logout(self):
self._auth()
self._hammer('post', '/api/auth/logout', scope='user_profile_write')