- 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>
99 lines
3.9 KiB
Python
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')
|