Files
MathApp/content/tests_security_review.py
Zareian 663252a260 Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in
accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30

155 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (محتوا، پنل و ورودی‌ها). هر تست رفتاری را نگه می‌دارد که
پیش‌تر غلط بود.
"""
import io
import json
from django.contrib.auth.models import Group, User as AuthUser
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer
class SupervisorCannotEditContentTests(TestCase):
"""admin_view فقط is_staff را می‌سنجد؛ صفحه‌های ورودِ فایل اجازه‌ی مدل را جدا چک نمی‌کردند."""
def setUp(self):
cache.clear()
call_command('setup_supervisor_group', stdout=io.StringIO())
sup = AuthUser.objects.create_user('sup', 's@x.c', 'pw12345!', is_staff=True)
sup.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(sup)
grade = Grade.objects.create(title='G', icon='', color='', number=55)
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.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?')
ok = Choice.objects.create(question=self.q, value='1', is_correct=True)
Choice.objects.create(question=self.q, value='2', is_correct=False)
self.student = User.objects.create(phone='09120009999')
UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True)
def test_question_import_with_replace_is_refused(self):
f = io.BytesIO(json.dumps([{
'lessonId': self.lesson.id, 'prompt': 'HACKED', 'type': 'multiple_choice',
'choices': ['a', 'b'], 'correct': 0,
}]).encode())
f.name = 'q.json'
r = self.client.post('/admin/content/question/import/', {'file': f, 'replace': 'on'})
self.assertEqual(r.status_code, 403)
self.assertEqual(list(Question.objects.filter(lesson=self.lesson).values_list('prompt', flat=True)), ['p?'])
self.assertEqual(UserAnswer.objects.filter(user_id=self.student.id).count(), 1)
def test_curriculum_import_and_content_browser_are_refused(self):
self.assertEqual(self.client.get('/admin/content/grade/import-curriculum/').status_code, 403)
self.assertEqual(self.client.get('/admin/content/question/import/').status_code, 403)
self.assertEqual(self.client.get(f'/admin/content/question/browse/questions/{self.lesson.id}/').status_code, 403)
def test_mark_resolved_action_is_refused(self):
report = QuestionReport.objects.create(question=self.q, reason='wrong_answer', user_id=self.student.id)
self.client.post('/admin/content/questionreport/', {'action': 'mark_resolved', '_selected_action': [report.pk]})
report.refresh_from_db()
self.assertNotEqual(report.status, 'resolved')
class NonAsciiDigitsAre400Tests(TestCase):
"""'²'.isdigit() هم True است ولی int('²') خطا می‌دهد؛ این ورودی‌ها ۵۰۰ می‌دادند."""
def setUp(self):
cache.clear()
user = User.objects.create(phone='09000000010')
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_character_and_unit_ids(self):
self.assertEqual(self.app.post('/api/characters/select', {'characterId': 'abc'}, format='json').status_code, 400)
self.assertEqual(self.app.post('/api/characters/select', {'characterId': '²'}, format='json').status_code, 400)
self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '²'}).status_code, 400)
def test_ticket_text_is_capped(self):
r = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5001}, format='json')
self.assertEqual(r.status_code, 400)
ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json')
self.assertIn(ok.status_code, (200, 201))
class JunkGradeIdsDoNotFlushCacheTests(TestCase):
"""
هر ?gradeId=ِ ناموجود در /api/home یک کلیدِ کش می‌ساخت؛ ۴۰۰ عددِ ساختگی LocMemCache را پر و
شمارنده‌ی سقفِ ورودِ ادمین را پاک می‌کرد (یازدهمین تلاش دوباره ۲۰۰ می‌گرفت، نه ۴۲۹).
"""
def setUp(self):
from unittest import mock
from rest_framework.throttling import ScopedRateThrottle
cache.clear()
self.addCleanup(cache.clear)
# فقط برای سرعتِ تست؛ با سقفِ واقعی (۱۲۰ در دقیقه) همین کار حدودِ سه دقیقه طول می‌کشید.
patcher = mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'read': '100000/min'})
patcher.start()
self.addCleanup(patcher.stop)
user = User.objects.create(phone='09120009911')
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_admin_login_limit_survives_junk_grade_ids(self):
anon = APIClient()
for _ in range(10):
anon.post('/admin/login/', {'username': 'x', 'password': 'y'})
self.assertEqual(anon.post('/admin/login/', {'username': 'x', 'password': 'y'}).status_code, 429)
for n in range(1000, 1400):
self.assertEqual(self.app.get('/api/home', {'gradeId': n}).status_code, 404)
self.assertEqual(anon.post('/admin/login/', {'username': 'x', 'password': 'y'}).status_code, 429)
def test_unknown_grade_is_not_cached_and_new_grade_still_loads(self):
self.app.get('/api/home', {'gradeId': 777})
self.assertFalse(any('grade:777' in k for k in cache._cache)) # LocMemCache در تست‌ها
grade = Grade.objects.create(title='تازه', icon='', color='')
self.assertEqual(self.app.get('/api/home', {'gradeId': grade.id}).status_code, 200)
class ProgressNodesInputAndQueriesTests(TestCase):
"""?unit= بزرگ‌تر از INTEGERِ SQLite ۵۰۰ می‌داد؛ lessonsTotal برای هر ردیف یک کوئریِ جدا بود."""
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009912')
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_huge_unit_is_400(self):
self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '9' * 25}).status_code, 400)
def test_query_count_does_not_grow_with_rows(self):
from django.db import connection
from django.test.utils import CaptureQueriesContext
from content.models import NodeProgress
unit = Unit.objects.create(grade=Grade.objects.create(title='g', icon='', color=''), title='u', order=1)
def add_nodes(start, n):
for i in range(start, start + n):
node = Node.objects.create(unit=unit, title=f'n{i}', order=i)
Lesson.objects.create(node=node, title='l1')
Lesson.objects.create(node=node, title='l2')
NodeProgress.objects.create(user_id=self.user.id, node=node)
add_nodes(0, 2)
with CaptureQueriesContext(connection) as small:
self.app.get('/api/progress/nodes')
add_nodes(2, 8)
with CaptureQueriesContext(connection) as big:
r = self.app.get('/api/progress/nodes')
self.assertEqual(len(big), len(small))
self.assertEqual([row['lessonsTotal'] for row in r.json()['progress']], [2] * 10)