cleanup: trim bloated multi-line comments across the project
This commit is contained in:
@@ -12,13 +12,8 @@ from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, U
|
||||
|
||||
|
||||
class LessonCompleteImpossibleCountTests(TestCase):
|
||||
"""
|
||||
🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو
|
||||
بدونِ مقایسه با تعدادِ واقعیِ سوالهای درس میپذیرفت، پس یک کلاینتِ
|
||||
مخرب میتونست با correctCount بزرگتر از تعدادِ واقعیِ سوالها،
|
||||
xp/coins متورم بگیره. این تست هم ردِ درخواستهای غیرممکن رو، هم
|
||||
رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین میکنه.
|
||||
"""
|
||||
"""رگرسیون: correctCount باید با تعدادِ واقعیِ سوالهای درس مقایسه بشه، وگرنه
|
||||
کلاینتِ مخرب میتونه xp/coins رو متورم کنه."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
@@ -67,14 +62,9 @@ class LessonCompleteImpossibleCountTests(TestCase):
|
||||
|
||||
|
||||
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
|
||||
"""
|
||||
🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالیی
|
||||
خودِ کلاینت محاسبه میشد، نه از تعدادِ واقعیِ سوالهای درس. یعنی حتی
|
||||
بعد از سقفزدنِ correctCount، کلاینت میتونست با totalQuestions=1
|
||||
(بهجای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins
|
||||
کامل بگیره درحالیکه فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio
|
||||
همیشه بر اساسِ actual_question_count محاسبه میشه.
|
||||
"""
|
||||
"""رگرسیون: ratio باید از actual_question_count محاسبه بشه نه از totalQuestیِ
|
||||
ارسالیِ کلاینت، وگرنه حتی بعدِ سقفزدنِ correctCount کلاینت میتونه با
|
||||
totalQuestions دروغین ratio رو مصنوعاً ۱۰۰٪ کنه."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
@@ -114,13 +104,8 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
|
||||
|
||||
|
||||
class QuestionAnswerThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView
|
||||
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
"""محدودیتِ per-user روی QuestionAnswerView (throttle_scope='question_answer')؛
|
||||
Idempotency-Key فقط retry با همون کلید رو میگیره، نه حجمِ درخواستهای متفاوت."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
@@ -242,14 +227,8 @@ class QuestionAnswerThrottleTests(TestCase):
|
||||
|
||||
|
||||
class LessonCompleteThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView
|
||||
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserLessonCompletion جدید میسازن و سیگنالِ post_save
|
||||
(NodeProgress) + بهروزرسانیِ UserStats رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
"""محدودیتِ per-user روی LessonCompleteView (throttle_scope='lesson_complete')؛
|
||||
Idempotency-Key فقط retry با همون کلید رو میگیره، نه حجمِ درخواستهای متفاوت."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
@@ -361,17 +340,9 @@ class LessonCompleteThrottleTests(TestCase):
|
||||
|
||||
|
||||
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، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید
|
||||
bucket تازهی question_answer بده — GuestAwareContentThrottle برای
|
||||
مهمانها کلید رو بر اساسِ IP میسازه، نه user.pk."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
@@ -446,17 +417,9 @@ class QuestionAnswerGuestThrottleBypassTests(TestCase):
|
||||
|
||||
|
||||
class LessonCompleteGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بیمحدودیت
|
||||
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
|
||||
بر اساسِ IP میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
مونده.
|
||||
"""
|
||||
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازهی
|
||||
lesson_complete بده — GuestAwareContentThrottle برای مهمانها کلید رو
|
||||
بر اساسِ IP میسازه، نه user.pk."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -26,24 +26,11 @@ from .serializers import (
|
||||
|
||||
class GuestAwareContentThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده
|
||||
از request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، برای هر
|
||||
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
|
||||
بگیره و محدودیتِ واقعی رو دور بزنه — درحالیکه هر دو endpoint اثرِ
|
||||
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
|
||||
xp/coins/progress).
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
answer/heart/progress/completion/idempotency دستنخوردهست.
|
||||
مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle
|
||||
و مشابههاش: چون GuestSessionView با هر deviceId جدید یک pk تازه میسازه،
|
||||
throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده میشه —
|
||||
درحالیکه answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن.
|
||||
برای همین فقط کاربرهای مهمان با IP کلید میخورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
@@ -165,18 +152,9 @@ class QuestionAnswerView(APIView):
|
||||
|
||||
# userId همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن —
|
||||
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
|
||||
# احرازهویتشده باشه با pk کاربر کلید میخوره (نه فقط IP)، پس تغییرِ
|
||||
# IP این محدودیت رو دور نمیزنه.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareContentThrottleِ مشترک استفاده میشه که برای کاربرهای
|
||||
# مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ
|
||||
# per-user قبلی رو دستنخورده نگه میداره.
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ درخواستهای
|
||||
# *متفاوت* که هرکدوم UserAnswer جدید میسازن و هارت/NodeProgress رو تغییر میدن؛
|
||||
# برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا).
|
||||
throttle_scope = 'question_answer'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@@ -274,20 +252,12 @@ class QuestionAnswerView(APIView):
|
||||
class LessonCompleteView(APIView):
|
||||
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
|
||||
|
||||
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده میشد که به
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
|
||||
# اجباریه و همیشه از request.user.id استفاده میشه.
|
||||
# userId همیشه از request.user.id گرفته میشود (نه ورودیِ کلاینت)، وگرنه کلاینت
|
||||
# میتونست بهجای هر کاربرِ دیگری xp/coins ثبت کنه.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserLessonCompletion جدید میسازن و سیگنالِ post_save (NodeProgress) +
|
||||
# بهروزرسانیِ UserStats رو اجرا میکنن. ScopedRateThrottle با pk
|
||||
# کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
|
||||
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
|
||||
# مشترک استفاده میشه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
|
||||
# endpoint رکوردِ completion ماندگار میسازه و xp/coins/progress رو
|
||||
# واقعاً تغییر میده (برخلافِ question_answer که فرکانسِ طبیعیاش بالاتره).
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره؛ برای همینه که
|
||||
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
|
||||
# (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار میسازه.
|
||||
throttle_scope = 'lesson_complete'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@@ -352,21 +322,12 @@ class LessonCompleteView(APIView):
|
||||
}
|
||||
return Response(LessonCompleteResponseSerializer(data).data)
|
||||
|
||||
# ⚛️ atomic: UserLessonCompletion.create پایینتر یک سیگنال post_save
|
||||
# رو تریگر میکنه (content/receivers.py → NodeProgress + احتمالاً
|
||||
# StatusChangeLog)؛ بهعلاوهی get_or_create/save روی UserStats.
|
||||
# بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی
|
||||
# xp/coins/progress_percent بهروز نشده — یعنی چون تکمیل هر درس
|
||||
# یکبارمصرفه، کاربر دیگه هیچوقت اون xp رو نمیگیره.
|
||||
#
|
||||
# ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic
|
||||
# و بدون قفل انجام میشد — دو درخواستِ همزمان (دابلکلیک/رتریِ
|
||||
# کلاینت) برای تکمیلِ همون درس میتونستن هر دو ببینن که هنوز کامل
|
||||
# نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ
|
||||
# UserStats کاربر قفل میشه (select_for_update) و چکِ
|
||||
# is_first_completion داخلِ همون قفل، بعد از لاکشدن، دوباره انجام
|
||||
# میشه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، بهدرستی
|
||||
# میبینه که دیگه first completion نیست.
|
||||
# atomic لازمه چون create پایینتر سیگنالِ post_save رو تریگر میکنه
|
||||
# (NodeProgress) و UserStats هم آپدیت میشه؛ بدون اون، fail شدنِ save
|
||||
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یکبارمصرف از دست رفته.
|
||||
# ردیفِ UserStats قفل میشه (select_for_update) و چکِ is_first_completion
|
||||
# داخلِ همون قفل انجام میشه تا دو درخواستِ همزمان (دابلکلیک) هردو
|
||||
# جایزهی کاملِ اول رو نگیرن.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
UserStats.objects.get_or_create(user_id=user_id)
|
||||
|
||||
Reference in New Issue
Block a user