cleanup: trim bloated multi-line comments across the project
This commit is contained in:
@@ -56,47 +56,19 @@ class UserAdmin(admin.ModelAdmin):
|
||||
list_filter = ('is_guest',)
|
||||
actions = ['send_sms_to_selected']
|
||||
|
||||
# permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ
|
||||
# has_change_permission فیلتر نمیکنه (فقط اکشنهایی که با پارامترِ
|
||||
# permissions تعریف شده باشن فیلتر میشن)، یعنی کاربری که فقط
|
||||
# view_user داره (مثلِ گروهِ Supervisor) میتونه با یک POSTِ مستقیم
|
||||
# همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمهی
|
||||
# اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action
|
||||
# خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو
|
||||
# فقط برای کاربرانِ دارایِ change_user در دسترس میذاره؛ برای بقیه
|
||||
# (مثلِ Supervisor) اصلاً در actions ثبتشده نیست، پس درخواستِ مستقیم
|
||||
# هم بیاثر میمونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»).
|
||||
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
|
||||
# میتونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
|
||||
# این پارامتر باعث میشه ModelAdmin فقط برای change_user در دسترس بذارتش.
|
||||
@admin.action(description='ارسال پیامک به کاربران انتخابشده', permissions=['change'])
|
||||
def send_sms_to_selected(self, request, queryset):
|
||||
"""
|
||||
قدمِ اولیهی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ
|
||||
معمولی روی UserAdmin که یک صفحهی میانیِ ساده (فرمِ متنِ پیامک) رو
|
||||
نشون میده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist
|
||||
انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال
|
||||
میکنه.
|
||||
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخابشده یا غیرفعال؛
|
||||
گیرنده با فیلدِ «target» انتخاب میشه (cutoffِ «غیرفعال» رو خودِ ادمین
|
||||
در فرم وارد میکنه، چیزی hardcode نیست).
|
||||
|
||||
گیرندهها با فیلدِ «target» در فرم انتخاب میشن:
|
||||
- 'selected' (پیشفرض): همون queryset که ادمین در changelist
|
||||
انتخاب کرده — رفتارِ قبلی، بدون تغییر.
|
||||
- 'inactive': کاربرانِ غیرفعال، با استفاده از
|
||||
accounts.services.get_inactive_users(since) — cutoff (تاریخ)
|
||||
رو خودِ ادمین در فرم وارد میکنه، هیچ عددِ روزی hardcode نشده.
|
||||
|
||||
عمداً محدود نگه داشته شده:
|
||||
- هیچ مدلِ کمپین/بدنهی جدیدی ساخته/persist نمیشه (نه رکوردی از
|
||||
پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه.
|
||||
- هیچ زمانبندی، صفِ پسزمینه، یا فیلترِ هدفگیریِ دیگهای (غیر از
|
||||
selected/inactive) نیست.
|
||||
- محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون
|
||||
request انجام میشه (نه صفِ پسزمینه)، اگر تعدادِ گیرندهها از
|
||||
settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی
|
||||
ارسال نمیکنه و فقط خطا نشون میده — تا یک انتخاب/تاریخِ خیلی
|
||||
بزرگ باعثِ تایماوتِ request یا overload شدنِ پروایدر نشه.
|
||||
- هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه
|
||||
که OTP و campaigns هم ازش استفاده میکنن.
|
||||
- کاربرانِ بدونِ شمارهتلفن (مثلِ مهمان) بهسادگی skip میشن.
|
||||
- شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف
|
||||
نمیکنه؛ فقط شمارشِ failed رو زیاد میکنه و برای بقیه ادامه میده.
|
||||
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پسزمینه)، پس
|
||||
اگر گیرندهها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
|
||||
هیچ پیامکی نمیفرسته تا request تایماوت نکنه یا provider overload نشه.
|
||||
شکستِ ارسال برای یک کاربر بقیه را متوقف نمیکنه.
|
||||
"""
|
||||
if 'apply' in request.POST:
|
||||
form = SendSMSForm(request.POST)
|
||||
@@ -123,10 +95,8 @@ class UserAdmin(admin.ModelAdmin):
|
||||
),
|
||||
level=messages.ERROR,
|
||||
)
|
||||
# بهجای return None، به رندرِ مشترکِ پایینِ تابع سقوط
|
||||
# میکنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون
|
||||
# داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر
|
||||
# استفاده میشه.
|
||||
# سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
|
||||
# یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ میشه، نه رندرِ مستقیم.
|
||||
else:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
@@ -158,12 +128,8 @@ class UserAdmin(admin.ModelAdmin):
|
||||
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
||||
level=messages.WARNING,
|
||||
)
|
||||
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در
|
||||
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک
|
||||
# ریدایرکتِ ۳۰۲ واقعی میشه (نه رندرِ مستقیمِ ۲۰۰)،
|
||||
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
|
||||
# حالا همالگو با شاخهی cap-exceeded، سقوط میکنیم به
|
||||
# رندرِ مشترکِ پایینِ تابع.
|
||||
# سقوط به رندرِ مشترکِ پایین (نه return None)، همالگو با
|
||||
# شاخهی cap-exceeded.
|
||||
else:
|
||||
form = SendSMSForm()
|
||||
|
||||
@@ -195,38 +161,15 @@ class OTPCodeAdmin(admin.ModelAdmin):
|
||||
list_filter = ('is_used',)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# صفحهی «ناظر» (Supervisor)
|
||||
# صفحهی «ناظر» (Supervisor): یک ایندکسِ read-only از لینکها به
|
||||
# changelistهای موجود (کاربران/پرداختها/اشتراکها)، داخلِ همون AdminSite
|
||||
# پیشفرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
|
||||
#
|
||||
# یک صفحهی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیشفرضِ جنگو
|
||||
# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی.
|
||||
# این صفحه صرفاً یک ایندکسِ گروهی از لینکهایی به همون changelistهای
|
||||
# ModelAdmin موجودِ پروژهست: کاربران (accounts.UserAdmin)، پرداختها
|
||||
# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراکها
|
||||
# (subscription.SubscriptionAdmin). هیچ منطقِ کسبوکار یا ModelAdmin
|
||||
# جدیدی اینجا ساخته نمیشه.
|
||||
#
|
||||
# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission
|
||||
# جنگو تأمین میشه، نه با پنهونکردنِ لینک:
|
||||
# - هر لینک در این صفحه فقط وقتی نشون داده میشه که کاربر پرمیشنِ
|
||||
# view_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
|
||||
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
|
||||
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
|
||||
# overrideای نشدن) طبقِ has_view_permission/has_change_permission/
|
||||
# has_add_permission/has_delete_permission پیشفرضِ ModelAdmin — که
|
||||
# مستقیماً از Permission مدلِ کاربر میخونن — چک میکنن؛ یعنی گروهِ
|
||||
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
|
||||
# view_* میگیره) حتی با دسترسیِ مستقیم به URL هم نمیتونه add/change/
|
||||
# delete کنه.
|
||||
# - خودِ صفحه هم مثلِ هر صفحهی دیگهی Admin با admin.site.admin_view
|
||||
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
|
||||
# ولی is_staff بهتنهایی برای *ورود به خودِ صفحه* کافی نیست — پایینتر
|
||||
# قبل از هر کارِ دیگهای عضویتِ کاربر در گروهِ Supervisor هم چک میشه
|
||||
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
|
||||
# PermissionDenied (۴۰۳) بالا میره. این یک سیستمِ پرمیشنِ جدید نیست؛
|
||||
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
|
||||
# میسازه) برای گیتکیپینگِ ورود به صفحه استفاده میکنه.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# read-only بودن با پنهونکردنِ لینک تضمین نمیشه، بلکه با پرمیشنِ استانداردِ
|
||||
# جنگو: هر لینک فقط با view_<model> نشون داده میشه و حتی دسترسیِ مستقیم به
|
||||
# URLِ change/add/delete هم توسطِ پرمیشنِ پیشفرضِ همون ModelAdminها رد میشه
|
||||
# (گروهِ Supervisor فقط view_* داره). is_staff بهتنهایی برای ورود کافی
|
||||
# نیست؛ عضویت در گروهِ Supervisor هم چک میشه، وگرنه PermissionDenied.
|
||||
|
||||
SUPERVISOR_GROUP_NAME = 'Supervisor'
|
||||
|
||||
|
||||
@@ -104,12 +104,9 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
)
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
# ⚠️ کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار
|
||||
# ناکافی، گیرندهی نامعتبر و…) HTTP 200 برمیگردونه؛ نتیجهی واقعی توی
|
||||
# بدنهی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ
|
||||
# status_code == 200 «موفقیت» در نظر گرفته نمیشه؛ بدنه parse میشه و
|
||||
# فقط return.status == 200 موفقیت محسوب میشه؛ هر پاسخِ ناقص/غیرمنتظره
|
||||
# هم شکستِ تحویل در نظر گرفته میشه (نه موفقیت).
|
||||
# کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
|
||||
# HTTP 200 برمیگردونه؛ نتیجهی واقعی توی body.return.status هست. فقط
|
||||
# اون فیلد موفقیت رو تعیین میکنه، نه status_code.
|
||||
try:
|
||||
body = response.json()
|
||||
provider_status = body['return']['status']
|
||||
@@ -158,23 +155,10 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
raise OTPDeliveryError(str(exc)) from exc
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Targeting helpers (internal, read-only)
|
||||
#
|
||||
# هدف: صرفاً کوئریهای قابلاستفادهی مجدد برای انتخاب زیرمجموعهای از
|
||||
# کاربران، تا فیچرهای آینده (مثلاً کمپینهای پیامی) بدونِ تکرارِ منطقِ
|
||||
# فیلتر، از همین توابع استفاده کنن. این ماژول:
|
||||
# - هیچ پیامی نمیفرسته (نه SMS، نه notification).
|
||||
# - هیچ جدول/مایگریشنِ جدیدی اضافه نمیکنه.
|
||||
# - هیچ background job/endpointای نداره؛ فقط QuerySetِ User برمیگردونه.
|
||||
# - هیچ رفتارِ موجودی رو تغییر نمیده (فقط SELECT).
|
||||
#
|
||||
# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و
|
||||
# بر اساسِ user_id انجام میشه — دقیقاً همون الگویی که payment/services.py
|
||||
# و discount/services.py برای صداکردنِ subscription استفاده میکنن؛ چون
|
||||
# accounts.User (طبقِ docstringِ خودش) هیچوقت FK واقعی به مدلهای اپهای
|
||||
# دیگه نمیزنه.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Targeting helpers (internal, read-only): کوئریهای قابلاستفادهی مجدد برای
|
||||
# انتخابِ زیرمجموعهای از کاربران (مثلاً برای کمپینهای پیامی)؛ فقط SELECT،
|
||||
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
|
||||
# user_id انجام میشه چون accounts.User هیچوقت FK واقعی به اپهای دیگه نمیزنه.
|
||||
|
||||
def get_all_users():
|
||||
"""همهی کاربران (مهمان و ثبتنامشده)."""
|
||||
|
||||
@@ -837,27 +837,13 @@ class GuestSessionThrottleTests(TestCase):
|
||||
}
|
||||
)
|
||||
def test_repeated_guest_session_requests_are_eventually_throttled(self):
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا
|
||||
# داشت:
|
||||
# ۱) `type` رو نمیفرستاد (پایینتر فیکس شده) — قراردادِ واقعیِ
|
||||
# API طبقِ README نیازش داره، پس هر درخواست قبل از throttle
|
||||
# با 400 رد میشد.
|
||||
# ۲) override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ
|
||||
# THROTTLE_RATES رو یکبار موقعِ import ماژولِ throttling
|
||||
# بهعنوانِ یک class attribute از api_settings.
|
||||
# DEFAULT_THROTTLE_RATES میگیره (همون دیکشنریِ اصلیِ
|
||||
# settings.py، با reference). override_settings یک دیکشنریِ
|
||||
# *جدید* برای settings.REST_FRAMEWORK میسازه، اما این
|
||||
# class attribute هنوز به همون دیکشنریِ قدیمی اشاره میکنه —
|
||||
# سیگنالِ setting_changed فقط api_settings رو reload میکنه،
|
||||
# نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمالشده
|
||||
# همیشه همون دیفالتِ 20/hour در settings.py میموند، نه
|
||||
# 3/hour، پس ۵ درخواست هیچوقت به سقف نمیرسیدن. برای اینکه
|
||||
# این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون
|
||||
# دیکشنریِ زندهی ScopedRateThrottle.THROTTLE_RATES رو
|
||||
# patch.dict میکنیم (که override_settings بالا هم برای
|
||||
# سازگاری/مستندسازیِ نیت نگه داشته میشه).
|
||||
# override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ THROTTLE_RATES رو
|
||||
# یکبار موقعِ import، بهعنوانِ class attribute، از دیکشنریِ اصلیِ
|
||||
# settings.py میگیره؛ override_settings دیکشنریِ settings رو عوض
|
||||
# میکنه ولی این class attribute هنوز به نسخهی قدیمی اشاره میکنه.
|
||||
# برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
|
||||
# زندهی ScopedRateThrottle.THROTTLE_RATES لازمه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self.client.post(
|
||||
|
||||
@@ -116,24 +116,10 @@ class RequestOTPView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواست برای همون شماره (burst
|
||||
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو میگیره، اما بهتنهایی
|
||||
# جلویِ این رو نمیگیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
|
||||
# از یک ثانیه مصرف بشن (که هم بهمعنایِ flood پیامکی روی یک شمارهی
|
||||
# مشخصه، هم میتونه با invalidate کردنِ پیدرپیِ OTPهای قبلی
|
||||
# (رفتارِ «فقط آخرین کد معتبره» که پایینتره) عملاً یک DoS روی
|
||||
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارندهی
|
||||
# ساعتی انجام میشه تا یک درخواستِ بلاکشده توسطِ cooldown سهمیهی
|
||||
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
|
||||
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
|
||||
#
|
||||
# کلید cooldown بهجای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
|
||||
# میکنه؛ اینطوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
|
||||
# هیچ خواندنِ ناامنی میشه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
|
||||
# cooldown مونده و اون رو هم در بدنهی پاسخ (retryAfter) و هم در
|
||||
# هدرِ استانداردِ Retry-After برگردوند — اینطوری frontend
|
||||
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع میکنه، نه
|
||||
# از رویِ یک عددِ hardcodeشدهی سمتِ کلاینت که مرجعِ امنیتی نیست.
|
||||
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (burst protection)؛
|
||||
# قبل از شمارندهی ساعتی چک میشه تا یک درخواستِ بلاکشده سهمیهی
|
||||
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره میکنه تا
|
||||
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
|
||||
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
||||
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
||||
now_epoch = timezone.now().timestamp()
|
||||
@@ -161,13 +147,9 @@ class RequestOTPView(APIView):
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
|
||||
# otp_request بالا. throttle IP-محور بهتنهایی کافی نیست چون یک
|
||||
# مهاجم میتونه درخواستهای تکراری برای یک شماره رو بین چند IP
|
||||
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
|
||||
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
|
||||
# وجود نداره مقدار میذاره (اتمیک روی سطح backend cache)، پس دو
|
||||
# درخواستِ همزمان نمیتونن هر دو از یک non-atomic check رد بشن.
|
||||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
@@ -202,13 +184,9 @@ class RequestOTPView(APIView):
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
|
||||
# خورده)، پس cooldownِ ۱۲۰ثانیهای که بالاتر برای همین درخواست
|
||||
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
|
||||
# اینصورت کاربر ۲ دقیقه معطل میمونه بدونِ اینکه اصلاً کدی
|
||||
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
|
||||
# IP-محور دستنخورده میمونن — اینها محدودیتِ حجمِ کلیان، نه
|
||||
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
|
||||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
||||
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
||||
cache.delete(phone_cooldown_key)
|
||||
raise
|
||||
|
||||
|
||||
@@ -63,24 +63,16 @@ class TrackConversionView(APIView):
|
||||
خودِ کاربرِ تازهارجاعشده (بعد از لاگین/ثبتنام) زده بشه، هویتش رو
|
||||
از توکن احرازشده (request.user) میگیریم، نه از بدنهی درخواست.
|
||||
|
||||
# ⚠️ اصلاح امنیتی: قبلاً این endpoint با AllowAny و یک
|
||||
# referredUserId دلخواه از سمت کلاینت کار میکرد؛ یعنی هر کلاینتِ
|
||||
# ناشناس میتونست برای هر کاربرِ واقعیِ دیگهای (بدون اطلاع یا
|
||||
# مشارکت خودش) بهجای او Conversion بسازه، یا حتی برای فرار از
|
||||
# قید unique روی referred_user_id، از قبل تمام userId های موجود
|
||||
# رو برای یک کد افیلیتِ جعلی «اشغال» کنه و مسیر ارجاع واقعی رو
|
||||
# برای همیشه مسدود کنه. با گرفتن هویت از request.user (نیازمند
|
||||
# احراز هویت)، دیگه امکان جعل/انتساب ارجاع به کاربر دیگری وجود
|
||||
# نداره؛ چک self-referral هم حفظ شده.
|
||||
# هویت از request.user گرفته میشه (نه بدنهی درخواست) چون یک
|
||||
# referredUserIdِ کلاینتدار یعنی هر کلاینتِ ناشناس میتونست برای هر
|
||||
# کاربرِ دیگهای Conversion بسازه یا با اشغالِ همهی userId ها (قیدِ
|
||||
# unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode
|
||||
# نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe) و حدسِ درست یک
|
||||
# Conversion واقعیِ متصل به کمیسیونِ affiliate میسازه. طبقِ همون
|
||||
# الگویِ بقیهی اپها، throttle_scope مجزا با GuestAwareAffiliateThrottle
|
||||
# اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان
|
||||
# دیگه bucket تازه نده.
|
||||
# referralCode نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe)، پس throttle
|
||||
# مجزا هم probe رو کند میکنه هم (با GuestAwareAffiliateThrottle) جلویِ
|
||||
# bucket تازه از چرخشِ حساب مهمان رو میگیره.
|
||||
throttle_scope = 'affiliate_track'
|
||||
throttle_classes = [GuestAwareAffiliateThrottle]
|
||||
|
||||
|
||||
@@ -54,15 +54,10 @@ class AskIdempotency(models.Model):
|
||||
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
|
||||
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||
# ⚠️ اصلاح: قبلاً suggestedFollowUps هیچجا ذخیره نمیشد و در مسیرِ
|
||||
# replay (idempotent retry) دوباره با فراخوانیِ generate_tutor_answer
|
||||
# بازسازی میشد. این فقط تا وقتی generate_tutor_answer یک stub خالی از
|
||||
# اثر جانبیه بیضرره؛ وقتی به یک AI provider واقعی وصل بشه، هر replay
|
||||
# یک درخواستِ واقعیِ دیگه به provider میزنه (هزینه/تأخیرِ تکراری، و
|
||||
# پیشنهادهای follow-up متفاوت در هر retry — دقیقاً برخلافِ هدفِ
|
||||
# idempotency). حالا خروجیِ follow-up های همون درخواستِ اولِ موفق،
|
||||
# همینجا ذخیره میشه تا replay بتونه بدونِ هیچ فراخوانیِ دیگری به
|
||||
# generate_tutor_answer، همون مقدارِ اصلی رو برگردونه.
|
||||
# ذخیره میشه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوبارهی
|
||||
# generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
|
||||
# واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
|
||||
# follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
|
||||
suggested_follow_ups = models.JSONField(default=list)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@@ -139,11 +139,8 @@ class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA
|
||||
# فقط سقفِ روزانه است و جلوی burst کوتاهمدت (مثلاً چند ده درخواست در چند
|
||||
# ثانیه به generate_tutor_answer) را نمیگیرد. throttle_scope مجزا
|
||||
# (ai_ask) این burst را محدود میکند، بدون اینکه به کوتای روزانه/idempotency
|
||||
# یا منطق AI دست بزند.
|
||||
# DAILY_AI_QUOTA فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||||
throttle_scope = 'ai_ask'
|
||||
throttle_classes = [GuestAwareAIThrottle]
|
||||
|
||||
|
||||
@@ -47,15 +47,10 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
|
||||
readonly_fields = fields
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
# صریحاً True: نباید به fallbackِ مبتنیبر codename پرمیشنِ
|
||||
# پیشفرضِ Django (has_perm('view_...') or has_perm('change_...'))
|
||||
# تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ
|
||||
# view/change روی این مدل رو نداره، حتی برای دیدنِ صفحهی
|
||||
# جزئیاتِ read-only هم 403 میگیره — یعنی مدل عملاً "view-only"
|
||||
# نیست بلکه کاملاً غیرقابلِدسترسه. این متد مستقل از
|
||||
# has_change_permission (که پایین همیشه False برمیگردونه)
|
||||
# تضمین میکنه که دیدن همیشه مجازه، صرفِنظر از پرمیشنهای
|
||||
# دیتابیسی.
|
||||
# صریحاً True: بدونِ override، fallbackِ پیشفرضِ Django به کاربرِ
|
||||
# staffِ بدونِ پرمیشنِ view/change حتی اجازهی دیدنِ این صفحهی
|
||||
# read-only رو هم نمیده — یعنی مدل عملاً غیرقابلِدسترس میشه، نه
|
||||
# فقط view-only.
|
||||
return True
|
||||
|
||||
def has_add_permission(self, request):
|
||||
|
||||
@@ -96,24 +96,12 @@ class CampaignDelivery(models.Model):
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
# جلوگیریِ race condition: اگه execute_campaign() برای همون
|
||||
# campaign همزمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو
|
||||
# میتونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ
|
||||
# already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار
|
||||
# send_sms/notify صدا زده بشه. این unique constraint دقیقاً
|
||||
# همون چیزی رو در سطحِ دیتابیس تضمین میکنه که چکِ اپلیکیشن
|
||||
# لایه فرض میکرد ولی گارانتی نمیداد: برای هر
|
||||
# (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ
|
||||
# «فعال یا موفق» (pending یا sent) میتونه وجود داشته باشه.
|
||||
# وقتی دو تلاشِ همزمان میخوان برای همون کاربر رکورد pending
|
||||
# بسازن، دومی روی این constraint با IntegrityError شکست
|
||||
# میخوره و در execute_campaign() بهعنوانِ «این کاربر توسطِ
|
||||
# یک اجرای دیگه claim شده» skip میشه — پس send_sms/notify
|
||||
# دوباره صدا زده نمیشه.
|
||||
#
|
||||
# status=failed عمداً از این constraint مستثنا شده تا رفتارِ
|
||||
# retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ
|
||||
# ناموفق) دستنخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب
|
||||
# نمیشه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز میذاره.
|
||||
# campaign همزمان اجرا بشه، این constraint تضمین میکنه حداکثر
|
||||
# یک رکوردِ «فعال یا موفق» (pending/sent) بهازای هر
|
||||
# (campaign, user_id, channel) باشه؛ تلاشِ دومِ همزمان با
|
||||
# IntegrityError شکست میخوره و بهعنوانِ claimشده skip میشه.
|
||||
# status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
|
||||
# بعد از شکست) دستنخورده بمونه.
|
||||
UniqueConstraint(
|
||||
fields=['campaign', 'user_id', 'channel'],
|
||||
condition=Q(status__in=['pending', 'sent']),
|
||||
|
||||
@@ -39,12 +39,10 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
|
||||
raise
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
|
||||
# نوعِ نوتیفیکیشنِ ثبتشده برای اعلانهای ناشی از کمپین. عمداً در
|
||||
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
|
||||
# choices فقط رویدادهاییان که notifications بهعنوانِ event-listener
|
||||
# بهشون گوش میده (طبق داکیومنتِ همون فایل)؛ اینجا notify() مستقیماً و
|
||||
# بدونِ رویداد صدا زده میشه، پس تغییرِ آن مدل لازم نیست — Notification.type
|
||||
# یک CharField معمولیه و مقداردهیاش خارج از choices هم مجاز است.
|
||||
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
|
||||
# choices فقط رویدادهاییه که notifications بهعنوانِ listener گوش میده،
|
||||
# اینجا notify() مستقیم صدا زده میشه؛ Notification.type یک CharField
|
||||
# معمولیه، مقداردهیِ خارج از choices مجازه.
|
||||
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
|
||||
|
||||
|
||||
|
||||
@@ -227,21 +227,11 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.ScopedRateThrottle',
|
||||
],
|
||||
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident)
|
||||
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و
|
||||
# بدونچک بهعنوانِ IP کلاینت قبول میکنه؛ یعنی هر کلاینتِ ناشناس میتونست
|
||||
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای
|
||||
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/
|
||||
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
|
||||
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
|
||||
# از طریقِ هدر بهجای deviceId جدید.
|
||||
# با NUM_PROXIES=1 (پیشفرض؛ برای دیپلویِ پشتِ یک لایهی پراکسی/LB که
|
||||
# خودش X-Forwarded-For رو ست/rewrite میکنه، نه پاسدادنِ مقدارِ
|
||||
# کلاینت)، DRF از انتهای زنجیرهی XFF دقیقاً همون تعداد hop رو
|
||||
# میخونه. اگه دیپلویِ پروداکشن تعداد لایهی پراکسیِ دیگهای داره
|
||||
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
|
||||
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه
|
||||
# خونده میشه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمیگرده.
|
||||
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدونچک بهعنوانِ IP
|
||||
# کلاینت قبول میکنه؛ یعنی هر کلاینت میتونست با XFF دلخواه، bucket
|
||||
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
||||
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
||||
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده میشه.
|
||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'otp_request': '5/hour',
|
||||
@@ -259,9 +249,6 @@ REST_FRAMEWORK = {
|
||||
'discount_redeem': '10/hour',
|
||||
'family_invite_accept': '10/hour',
|
||||
'family_invite_create': '5/hour',
|
||||
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
|
||||
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
|
||||
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
|
||||
'user_profile_write': '60/hour',
|
||||
'affiliate_track': '10/hour',
|
||||
'payment_webhook': '60/hour',
|
||||
@@ -292,13 +279,9 @@ SPECTACULAR_SETTINGS = {
|
||||
ACCESS_TOKEN_TTL_SECONDS = 3600
|
||||
REFRESH_TOKEN_TTL_DAYS = 30
|
||||
|
||||
# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای
|
||||
# هدفگیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و
|
||||
# synchronous توی همون request اجرا میشه (بدون صفِ پسزمینه)، پس این سقف
|
||||
# صرفاً جلویِ یک ارسالِ ناخواستهی خیلی بزرگ (که میتونه request رو تایماوت
|
||||
# کنه یا پروایدرِ پیامک رو overload کنه) رو میگیره؛ اگر تعدادِ گیرندهها
|
||||
# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمیکنه و از ادمین میخواد
|
||||
# بازه/انتخاب رو محدودتر کنه.
|
||||
# سقفِ گیرندهی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی
|
||||
# همون requestه (بدون صفِ پسزمینه)، پس این سقف جلویِ تایماوت/overloadِ
|
||||
# provider رو میگیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمیشه.
|
||||
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
|
||||
|
||||
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
|
||||
@@ -311,15 +294,9 @@ OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
|
||||
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
|
||||
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل
|
||||
# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو میگیره اما جلویِ
|
||||
# burst رو نمیگیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این
|
||||
# cooldown دقیقاً همون burst رو هدف میگیره؛ نه جایگزینِ سقفِ ساعتیه، نه
|
||||
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو میبندن.
|
||||
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
|
||||
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown
|
||||
# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه
|
||||
# باید با چیزی که فرانت به کاربر نشون میده یکی باشه.
|
||||
# حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا
|
||||
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
||||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
|
||||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||||
@@ -368,14 +345,10 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||||
# -----------------------------------------------------------------------
|
||||
# Bazaar In-App Billing (پرداخت درونبرنامهای کافهبازار — REST API v2)
|
||||
# -----------------------------------------------------------------------
|
||||
# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)،
|
||||
# صحتسنجی پرداخت بازار یک OAuth2 سرور-به-سروره: یک بار با CLIENT_ID/
|
||||
# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته میشه (یکبار،
|
||||
# دستی)؛ از اون به بعد بکاند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه
|
||||
# میگیره و با اون به endpoint وضعیت خرید/اشتراک درخواست میزنه.
|
||||
# ⚠️ کافهبازار اواخر ۱۴۰۳ روش صحتسنجی رو تغییر داد؛ قبل از اتصال واقعی،
|
||||
# حتماً آخرین مسیر endpointها رو از همون صفحهی رسمی/پنل devapi خودتون
|
||||
# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید).
|
||||
# صحتسنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یکبار دستی از پنل devapi
|
||||
# گرفته میشه، بعد بکاند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه میگیره.
|
||||
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
|
||||
# این روش رو قبلاً تغییر داده.
|
||||
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
|
||||
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
|
||||
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||||
@@ -388,18 +361,11 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Logging (آمادهسازیِ لاگهای موجود برای جمعآوریِ متمرکز — Loki/ELK-style)
|
||||
# Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware،
|
||||
# accounts.services، ...) فقط به rootِ پیشفرض میرسیدن که برای INFO عملاً
|
||||
# خروجیای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با
|
||||
# Promtail/Filebeat برای جمعآوریِ بعدی توسطِ Loki/ELK.
|
||||
# -----------------------------------------------------------------------
|
||||
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
|
||||
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
|
||||
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
|
||||
# پیشفرضِ کتابخانهی logging میرسیدن (که برای سطحِ INFO عملاً هیچ خروجیای
|
||||
# تولید نمیکنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
|
||||
# eventِ لاگی، همون پیامهای موجود رو روی stdout و بهصورتِ یک JSON در هر
|
||||
# خط (فرمتِ سازگار با agentِ جمعآوریِ بیرونی مثلِ Promtail/Filebeat که
|
||||
# بعداً میتونه به Loki/ELK بفرسته) چاپ میکنه. هیچ appِ جدید، ذخیرهسازیِ
|
||||
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
|
||||
# کدِ Django اضافه نشده.
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
@@ -420,16 +386,9 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
# ⚠️ افزوده شده (یکپارچهسازیِ Docker + Promtail/Loki/Grafana): وقتی
|
||||
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک
|
||||
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی
|
||||
# stdout چاپ میشدن، *علاوه* بر stdout در یک فایل هم نوشته میشن؛
|
||||
# Promtail همون فایل رو tail میکنه و به Loki میفرسته.
|
||||
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
|
||||
# پیامهای لاگ، نه محافظتهای حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
|
||||
# غیره در پیامها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمیکنه.
|
||||
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
|
||||
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout.
|
||||
# وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با
|
||||
# promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته میشن تا
|
||||
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
||||
LOG_DIR = os.environ.get('LOG_DIR', '')
|
||||
|
||||
if LOG_DIR:
|
||||
|
||||
@@ -52,15 +52,9 @@ class SecretKeyFailClosedTests(SimpleTestCase):
|
||||
)
|
||||
|
||||
def test_production_configured_secret_key_used(self):
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست فقط رفتارِ
|
||||
# SECRET_KEY رو میسنجه، اما چون CACHE_URL رو ست نمیکرد، وقتی
|
||||
# فیکسِ جداگانهی CACHE_URL fail-closed (پایینترِ همین فایل، نگاه
|
||||
# کنید به CacheUrlFailClosedTests) اضافه شد، همین import/reload با
|
||||
# DEBUG=False و بدونِ CACHE_URL بهجای رسیدن به assertion، با
|
||||
# ImproperlyConfigured مربوط به CACHE_URL (نه SECRET_KEY) fail
|
||||
# میشد — یک false negative روی چیزی که این تست اصلاً قرار نیست
|
||||
# بسنجه. یک CACHE_URL معتبر هم ست میکنیم تا این تست فقط دقیقاً
|
||||
# همون چیزی که اسمش میگه (SECRET_KEY) رو بسنجه.
|
||||
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
|
||||
# ImproperlyConfiguredِ CACHE_URL رخ میده، نه رسیدن به assertionِ
|
||||
# SECRET_KEY که این تست واقعاً میسنجه.
|
||||
settings_module = self._reload_settings_with_env(
|
||||
env_overrides={
|
||||
'DJANGO_DEBUG': '',
|
||||
@@ -196,24 +190,11 @@ class APIRequestLoggingMiddlewareTests(TestCase):
|
||||
self.assertEqual(records, [])
|
||||
|
||||
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
|
||||
# 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک
|
||||
# exceptionِ دستنخورده raise کنه، میانافزار باید همچنان یک خطِ
|
||||
# لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ
|
||||
# حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise
|
||||
# کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگهای رو
|
||||
# تغییر بده. مستقیماً خودِ میانافزار (نه کلِ چرخهی
|
||||
# exception-handlingِ Django) تست میشه، چون همون لایهایست که این
|
||||
# فیکس روش اعمال شده.
|
||||
#
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط
|
||||
# record.getMessage() رو چک میکرد. getMessage() خودش داخلی
|
||||
# msg %% args رو انجام میده، پس اگه شمارِ آرگومانها با
|
||||
# placeholderهای رشته جور نباشه، همونجا با یک exception (نه یک
|
||||
# پیامِ نادرست) fail میشد و اصلاً به assertion نمیرسید. برای اینکه
|
||||
# این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یکجوری
|
||||
# fail نشه)، حالا با logging.Formatter().format(record) دقیقاً
|
||||
# همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی میکنه رو صدا
|
||||
# میزنیم و روی متنِ نهاییِ فرمتشده assert میکنیم.
|
||||
# میانافزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
|
||||
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
|
||||
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
|
||||
# handlerِ واقعی طی میکنه رو صدا میزنیم (نه فقط getMessage()، که
|
||||
# یه mismatchِ %%-args رو با exceptionِ خودش میپوشونه، نه fail واقعی).
|
||||
request = RequestFactory().get('/api/some/failing/path')
|
||||
|
||||
def failing_get_response(inner_request):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,18 +27,11 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
|
||||
if not offer.is_active:
|
||||
return None
|
||||
|
||||
# ⚠️ اصلاح: قبلاً هر IntegrityError داخل کل بلوکِ atomic (که شاملِ
|
||||
# grant_free_subscription هم میشد) بهعنوان «duplicate UserOffer»
|
||||
# تفسیر میشد و None برمیگردوند — یعنی اگه یک IntegrityError واقعیِ
|
||||
# دیگه (مثلاً داخل grant_free_subscription) اتفاق میافتاد، خطا قورت
|
||||
# داده میشد و caller فکر میکرد «کاربر قبلاً این آفر رو گرفته»، در
|
||||
# حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از
|
||||
# UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته
|
||||
# میشه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create
|
||||
# رو rollback میکنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل
|
||||
# grant_free_subscription) عادی propagate میشن. atomic بودنِ کلِ
|
||||
# عملیات (UserOffer + Subscription + expires_at) هم دستنخورده باقی
|
||||
# مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده.
|
||||
# فقط IntegrityErrorِ ناشیِ از UniqueConstraintِ خودِ UserOffer.create
|
||||
# (duplicate واقعی) اینجا گرفته میشه، با یک savepoint مجزا که فقط
|
||||
# همون create رو rollback میکنه — وگرنه یک IntegrityErrorِ دیگه (مثلاً
|
||||
# داخلِ grant_free_subscription) قورت داده میشد و caller اشتباهاً فکر
|
||||
# میکرد کاربر قبلاً این آفر رو گرفته.
|
||||
with transaction.atomic():
|
||||
try:
|
||||
with transaction.atomic(): # savepoint مجزا فقط برای create
|
||||
|
||||
@@ -102,12 +102,7 @@ class RedeemOfferCodeThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# RedeemOfferCodeGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
@@ -299,12 +294,7 @@ class ValidateDiscountThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# ValidateDiscountGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000731')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
@@ -387,12 +377,7 @@ class RedeemDiscountThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# RedeemDiscountGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000301')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
other_order = Order.objects.create(
|
||||
|
||||
@@ -69,21 +69,11 @@ class ValidateDiscountView(APIView):
|
||||
# صورت کلاینت بدون احراز هویت میتواند کدهای تخفیف را probe کند.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمیگردونه (نه
|
||||
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویتشده میتونست
|
||||
# بینهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ
|
||||
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView
|
||||
# (offer_redeem) اضافه شد: چون کاربر احرازهویتشده، کلید بر اساسِ pk
|
||||
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
|
||||
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
|
||||
# نهایی امتحان کنه)، نه خودِ مصرف کد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از همون
|
||||
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
|
||||
# RedeemOfferCodeView هم استفاده میکنن) استفاده میشه که برای
|
||||
# کاربرهای مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای
|
||||
# غیرمهمان رفتارِ per-user قبلی رو دستنخورده نگه میداره.
|
||||
# این endpoint type/value کد رو برمیگردونه، پس بدونِ throttle یک
|
||||
# کاربر میتونست بینهایت کد رو probe کنه؛ نرخ کمی بالاتر از
|
||||
# offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
|
||||
# مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
|
||||
# مهمانها کلید رو بر اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
throttle_scope = 'discount_validate'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
@@ -117,23 +107,11 @@ class RedeemDiscountView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد
|
||||
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره —
|
||||
# کدِ ناموجود ۴۰۴ میگیره، کدِ موجود-ولی-نامعتبر ۴۰۰ میگیره، پس بدونِ
|
||||
# throttle یک کاربرِ احرازهویتشده میتونست کدهای تخفیف رو روی سفارشِ
|
||||
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون
|
||||
# حدسِ درست صرفاً اطلاعات لو نمیده؛ یک کدِ واقعی رو مصرف میکنه
|
||||
# (سهمِ max_uses مشترک بین همهی کاربرها رو میخوره) و باعثِ افتِ
|
||||
# واقعیِ مبلغِ سفارش میشه؛ ضمناً هر تلاش دو تا select_for_update و یک
|
||||
# تراکنشِ دیتابیسی اجرا میکنه که گرونتر از GET سبکِ validate است.
|
||||
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareDiscountThrottle استفاده میشه که برای کاربرهای مهمان
|
||||
# کلید رو بر اساسِ IP میسازه (دقیقاً همون الگویِ GuestAwareAIThrottle
|
||||
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
|
||||
# قبلی رو دستنخورده نگه میداره.
|
||||
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
|
||||
# درست یه کدِ واقعی رو مصرف میکنه (سهمِ max_uses مشترک) و مبلغِ سفارش
|
||||
# رو واقعاً کم میکنه، ضمن اینکه هر تلاش دو select_for_update و یه
|
||||
# تراکنشه. GuestAwareDiscountThrottle برای مهمانها کلید رو بر اساسِ IP
|
||||
# میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
throttle_scope = 'discount_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
@@ -200,24 +178,11 @@ class RedeemOfferCodeView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمیگردونن
|
||||
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویتشده میتونست
|
||||
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ
|
||||
# همون الگویِ ScopedRateThrottle که در بقیهی اکشنهایِ کمتکرار/حساسِ
|
||||
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در
|
||||
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود — بهخصوص اینجا
|
||||
# حساستره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
|
||||
# جلوی تکرارِ *همون* کاربر رو میگیره، نه کاربرهای مهمانِ جدید (یک
|
||||
# حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو دوباره از
|
||||
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
|
||||
# برای RedeemDiscountView اضافه شد استفاده میشه (دقیقاً همون الگویِ
|
||||
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
|
||||
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
|
||||
# اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
|
||||
# دستنخورده نگه میداره.
|
||||
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساستره چون
|
||||
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
|
||||
# کاربر رو میگیره — یک حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو
|
||||
# دوباره بگیره. GuestAwareDiscountThrottle برای مهمانها کلید رو بر
|
||||
# اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
throttle_scope = 'offer_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
|
||||
@@ -58,11 +58,7 @@ class FamilyLinkAcceptThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# این تست مخصوصِ کاربرهای *غیرمهمان* (ثبتنامشده) است — طبقِ فیکسِ
|
||||
# GuestAwareFamilyThrottle، حسابهای مهمان دیگه bucket جداگانه
|
||||
# ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایینتر)،
|
||||
# اما رفتارِ per-user برای کاربرهایِ ثبتنامشده باید دقیقاً مثلِ
|
||||
# قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(
|
||||
is_guest=False, name='', phone='+989120000099',
|
||||
)
|
||||
@@ -241,12 +237,8 @@ class FamilyLinkInviteThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.parent که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# FamilyLinkInviteGuestThrottleBypassTests پایینتر) — این تست
|
||||
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه.
|
||||
# کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
|
||||
registered_token = issue_access_token(registered_parent.id)
|
||||
|
||||
|
||||
@@ -67,17 +67,10 @@ class FamilyLinkInviteView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttle مخصوصی نداشت، درحالیکه
|
||||
# یک FamilyLink ماندگار میسازه و یک کدِ دعوتِ ۶رقمیِ تازه تولید میکنه.
|
||||
# بدونِ throttle، یک کاربرِ احرازهویتشده میتونست با فرستادنِ
|
||||
# پیدرپیِ شمارههای مختلف، تعدادِ زیادی FamilyLink/کدِ دعوت بسازه.
|
||||
# طبقِ همون الگویِ GuestAwareFamilyThrottleِ مشترک (که
|
||||
# FamilyLinkAcceptView هم استفاده میکنه): برای کاربرهای مهمان کلید
|
||||
# بر اساسِ IP و برای کاربرهای غیرمهمان بر اساسِ user.pk است، پس
|
||||
# چرخشِ حسابِ مهمان نمیتونه bucket تازه بگیره. یک throttle_scope
|
||||
# مجزا ('family_invite_create'، نه 'family_invite_accept') استفاده
|
||||
# شده چون تولید و مصرفِ کدِ دعوت باید مستقل از هم قابلتنظیم بمونن؛
|
||||
# منطقِ generation/authorization دستنخوردهست.
|
||||
# بدونِ throttle، یک کاربر میتونست با فرستادنِ پیدرپیِ شمارههای مختلف
|
||||
# تعدادِ زیادی FamilyLink بسازه. GuestAwareFamilyThrottle برای مهمانها
|
||||
# کلید رو بر اساسِ IP میسازه؛ scopeِ جدا از family_invite_accept چون
|
||||
# تولید و مصرفِ کدِ دعوت باید مستقل قابلتنظیم باشن.
|
||||
throttle_scope = 'family_invite_create'
|
||||
throttle_classes = [GuestAwareFamilyThrottle]
|
||||
|
||||
@@ -131,15 +124,9 @@ class FamilyLinkAcceptView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: inviteCode یک رازِ عددیِ ۶رقمی تولیدشدهی سرور است
|
||||
# (family/views.py::FamilyLinkInviteView)، و این endpoint دقیقاً همون
|
||||
# کد رو با select_for_update() جستوجو میکنه؛ بدونِ throttle، یک
|
||||
# کاربرِ احرازهویتشده میتونست بدونِ هیچ محدودیتی فضای ۶رقمی رو
|
||||
# brute-force کنه. طبقِ همون الگویِ ScopedRateThrottle که برای بقیهی
|
||||
# اندپوینتهایِ حدسپذیر پروژه اضافه شد، اینجا هم یک scope مجزا اضافه
|
||||
# شد (نه scope مشترک با discount/offer) تا نرخِ محدودسازی مستقل از اونها
|
||||
# قابلتنظیم باشه. چون DRF throttle check قبل از dispatch به post() در
|
||||
# APIView.initial() اجرا میشه، حدسهای بلاکشده اصلاً به select_for_update()
|
||||
# inviteCode یک رازِ عددیِ ۶رقمیه؛ بدونِ throttle یه کاربر میتونست فضای
|
||||
# ۶رقمی رو brute-force کنه. throttle در APIView.initial() قبل از
|
||||
# post() اجرا میشه، پس حدسهای بلاکشده اصلاً به select_for_update()
|
||||
# نمیرسن.
|
||||
throttle_scope = 'family_invite_accept'
|
||||
throttle_classes = [GuestAwareFamilyThrottle]
|
||||
|
||||
@@ -8,12 +8,9 @@ class Order(models.Model):
|
||||
"""
|
||||
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق')]
|
||||
|
||||
# دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرینپال/زیبال و مشابه) و
|
||||
# 'bazaar' (پرداخت درونبرنامهای اپلیکیشن بازار). این دو منطق پرداخت و
|
||||
# صحتسنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به-
|
||||
# سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار
|
||||
# دیگه در Transaction.gateway (که فقط برای گزارشگیریِ درگاه واقعی زرینپال/
|
||||
# زیبال باقی میمونه).
|
||||
# 'gateway' و 'bazaar' منطق و صحتسنجیِ کاملاً متفاوتی دارن (webhook در
|
||||
# برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست
|
||||
# از Transaction.gateway (که فقط برای گزارشگیریِ درگاهِ واقعیه).
|
||||
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درونبرنامهای بازار')]
|
||||
|
||||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||
@@ -48,18 +45,11 @@ class Transaction(models.Model):
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
constraints = [
|
||||
# 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای
|
||||
# غیرخالی: این قید سطح DB تضمین میکنه یک gateway_ref_id مشخص
|
||||
# (مثلاً Authority زرینپال یا purchaseToken بازار) در کل جدول
|
||||
# Transaction فقط میتونه متعلق به یک Order باشه — هم جلوی
|
||||
# duplicate/idempotency برای همون Order رو میگیره (چون در کل
|
||||
# جدول حداکثر یک ردیف با این (gateway, ref) میتونه وجود داشته
|
||||
# باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو
|
||||
# (نگاه کنید به apply_order_payment_result). این قید، قیدِ
|
||||
# قدیمیترِ (order, gateway_ref_id, status) رو هم در برمیگیره
|
||||
# (چون سختگیرانهتره)، برای همین دیگه لازم نیست جدا نگه داشته
|
||||
# بشه. gateway_ref_id__gt='' یعنی رفرنسهای خالی (که provider
|
||||
# واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمیشن.
|
||||
# یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای غیرخالی:
|
||||
# یک ref مشخص (مثلاً Authorityِ زرینپال) فقط میتونه متعلقِ یک
|
||||
# Order باشه — هم جلوی duplicate رو میگیره هم جلوی reuse/replay
|
||||
# برای Orderِ دیگه (نگاه کنید به apply_order_payment_result).
|
||||
# gateway_ref_id__gt='' یعنی refِ خالی مشمول نیست.
|
||||
models.UniqueConstraint(
|
||||
fields=['gateway', 'gateway_ref_id'],
|
||||
condition=models.Q(gateway_ref_id__gt=''),
|
||||
|
||||
@@ -56,12 +56,9 @@ class SecretDefaultsFailClosedTests(TestCase):
|
||||
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
|
||||
|
||||
def test_debug_mode_local_fallback_still_valid(self):
|
||||
# settings.py این مقادیر رو یکبار موقعِ import، بر اساسِ DEBUG واقعیِ
|
||||
# همون پروسه محاسبه میکنه (نه چیزی که override_settings در تست
|
||||
# میتونه بعداً toggle کنه). برای تستِ صریحِ همین منطق، ماژولِ
|
||||
# config.settings رو با DJANGO_DEBUG=1 و بدونِ ستبودنِ
|
||||
# INTERNAL_SERVICE_KEY/PAYMENT_WEBHOOK_SECRET دوباره reload میکنیم
|
||||
# و مطمئن میشیم دیفالتِ محلیِ قبلیِ dev هنوز برقراره.
|
||||
# settings.py این مقادیر رو یکبار موقعِ import محاسبه میکنه، نه
|
||||
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
|
||||
# منطق باید خودِ ماژول رو reload کرد.
|
||||
from config import settings as settings_module
|
||||
|
||||
env_overrides = {'DJANGO_DEBUG': '1'}
|
||||
@@ -227,12 +224,8 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# BazaarVerifyPurchaseGuestThrottleBypassTests پایینتر) — این تست
|
||||
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه.
|
||||
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
|
||||
@@ -23,25 +23,10 @@ from .services import apply_order_payment_result, create_order
|
||||
|
||||
|
||||
class GuestAwareBazaarThrottle(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 'bazaar_verify' بگیره — درحالیکه
|
||||
هر درخواستِ پذیرفتهشده یک فراخوانیِ سرور-به-سرور به API بازار رو
|
||||
تریگر میکنه.
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
verify/order ownership/transaction/apply-payment دستنخوردهست.
|
||||
"""
|
||||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||||
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازهی bazaar_verify
|
||||
بده، چون هر درخواستِ پذیرفتهشده یه فراخوانیِ سرور-به-سرور به بازاره.
|
||||
برای مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
@@ -121,14 +106,10 @@ class PaymentWebhookView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [HasWebhookSecret]
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالیکه فقط
|
||||
# با مقایسهی یک هدرِ ثابت (X-Webhook-Secret) محافظت میشه؛ بدونِ
|
||||
# throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ
|
||||
# orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالیکه یک حدسِ
|
||||
# درست باعثِ paid شدنِ واقعیِ سفارش و فعالسازیِ اشتراک میشه. چون
|
||||
# این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک
|
||||
# ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ
|
||||
# GuestAware*Throttle (که نیازمندِ request.user است).
|
||||
# این endpoint فقط با مقایسهی هدرِ ثابتِ X-Webhook-Secret محافظت میشه،
|
||||
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
|
||||
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
|
||||
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
|
||||
throttle_scope = 'payment_webhook'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@@ -182,19 +163,11 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار
|
||||
# (verify_purchase) رو تریگر میکنه؛ بدون throttle مخصوص، یک کاربر
|
||||
# میتونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس
|
||||
# بازار و بکاند بندازه. throttle_scope مجزا (bazaar_verify) جدا از
|
||||
# نرخهای دیگه (مثل otp_*) تنظیم شده.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareBazaarThrottle استفاده میشه که برای کاربرهای مهمان کلید
|
||||
# رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ per-user
|
||||
# قبلی رو دستنخورده نگه میداره. نرخ هم از ۲۰/hour به ۱۰/hour
|
||||
# سختگیرانهتر شد چون هر درخواستِ پذیرفتهشده یک تماسِ سرور-به-سرورِ
|
||||
# واقعی با بازار میزنه.
|
||||
# این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
|
||||
# تریگر میکنه، برای همین throttle_scope مجزا و نرخِ سختگیرانهتر (۱۰/hour)
|
||||
# داره. کاربرهای مهمان با هر deviceId یک pk تازه میگیرن، پس
|
||||
# GuestAwareBazaarThrottle برای اونها بر اساسِ IP کلید میسازه تا چرخشِ
|
||||
# حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ میشه.
|
||||
throttle_scope = 'bazaar_verify'
|
||||
throttle_classes = [GuestAwareBazaarThrottle]
|
||||
|
||||
@@ -208,13 +181,10 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
|
||||
# ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست
|
||||
# پشتسرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic
|
||||
# و زیر select_for_update روی Order انجام میشد؛ یعنی ردیف Order (و
|
||||
# هر تراکنش دیگهای که بخواد همون Order یا Subscription مرتبط رو لمس
|
||||
# کنه) تا برگشتِ پاسخ بازار قفل میموند. حالا verify_purchase قبل از
|
||||
# باز شدن atomic/lock صدا زده میشه؛ لاک فقط برای اعمال نتیجه (چند
|
||||
# میلیثانیه) گرفته میشه.
|
||||
# verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشتسرهم) عمداً *قبل* از
|
||||
# بازشدنِ atomic/lock صدا زده میشه، نه داخلش — وگرنه ردیفِ Order (و هر
|
||||
# تراکنشِ دیگهای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
|
||||
# قفل میموند. لاک فقط برای اعمالِ نتیجه (چند میلیثانیه) گرفته میشه.
|
||||
order_lookup = get_object_or_404(
|
||||
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||||
)
|
||||
|
||||
@@ -14,31 +14,21 @@ class Plan(models.Model):
|
||||
price = models.PositiveIntegerField()
|
||||
currency = models.CharField(max_length=10, default='IRT')
|
||||
discount_percent = models.PositiveSmallIntegerField(default=0)
|
||||
popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
|
||||
# ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد durationای برای Plan نداره، در
|
||||
# نتیجهی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک
|
||||
# قاعدهی stub (چک کردن کلمهی «سالانه»/yearly در عنوان) حدس میزد که
|
||||
# شکننده و وابسته به متن title بود. duration_days اینجا بهعنوان منبع
|
||||
# صریح و قابلاتکای مدت اشتراک اضافه شده؛ پیشفرض ۳۰ روز.
|
||||
duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده
|
||||
popular = models.BooleanField(default=False)
|
||||
# منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو
|
||||
# از رویِ متنِ title (مثلاً کلمهی «سالانه») حدس بزنه.
|
||||
duration_days = models.PositiveIntegerField(default=30)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل
|
||||
active/expired/canceled بود. اما در جریان POST /api/subscription/purchase
|
||||
یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته
|
||||
میشه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز
|
||||
پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و بهعنوان
|
||||
پیشفرض ساخت (بهجای active در سند) تنظیم شده؛ فعالسازی واقعی توسط
|
||||
subscription.services.activate_subscription بعد از webhook پرداخت انجام
|
||||
میشه.
|
||||
"""
|
||||
"""وضعیتِ pending برای فاصلهی بینِ ساختنِ Subscription (در purchase) و
|
||||
تاییدِ پرداخت (webhook)؛ فعالسازیِ واقعی توسطِ
|
||||
subscription.services.activate_subscription انجام میشه."""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده
|
||||
('pending', 'در انتظار پرداخت'),
|
||||
('active', 'فعال'),
|
||||
('expired', 'منقضی'),
|
||||
('canceled', 'لغوشده'),
|
||||
@@ -50,19 +40,11 @@ class Subscription(models.Model):
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||||
# 🔒 اصلاحِ رگرسیونِ concurrency در PurchaseView.post: چون Subscription
|
||||
# فقط یک ردیف در ازای هر user_id داره، دو درخواستِ همزمانِ خریدِ همون
|
||||
# کاربر (مثلاً با همون plan) میتونن دقیقاً به یک وضعیتِ میانیِ یکسان
|
||||
# برسن (status='pending', همون plan_id, order_id=None) — یعنی مقایسهی
|
||||
# مقادیرِ فیلدها (که قبلاً بهعنوانِ CAS guard در cleanup استفاده میشد)
|
||||
# نمیتونه بینِ «ردیفِ نوشتهشده توسطِ خودِ همین درخواست» و «ردیفِ
|
||||
# نوشتهشده توسطِ یک درخواستِ همزمانِ دیگه» تمایز بذاره. version یک
|
||||
# شمارندهی صریحه که PurchaseView.post هر بار که واقعاً روی این ردیف
|
||||
# مینویسه (با UPDATE اتمیکِ version=F('version')+1، بدونِ نیاز به
|
||||
# select_for_update/نگهداشتنِ قفل) افزایشش میده؛ پس حتی وقتی محتوای
|
||||
# semantic فیلدهای دیگه بینِ دو نویسندهی همزمان یکسانه، version همیشه
|
||||
# بینِ نوشتههای مجزا فرق میکنه و میتونه بهعنوانِ CAS token قابلاتکا
|
||||
# برای cleanup استفاده بشه.
|
||||
# چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ همزمانِ
|
||||
# همون کاربر میتونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسهی
|
||||
# فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ
|
||||
# version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو
|
||||
# از نوشتنِ همزمانِ دیگری تمیز میده.
|
||||
version = models.PositiveIntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -362,12 +362,9 @@ class PurchaseThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# PurchaseGuestThrottleBypassTests پایینتر) — این تست فقط رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه که
|
||||
# باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان لازمه، نه self.user، چون کلیدِ bucketِ مهمانها
|
||||
# عمداً بر اساسِ IP است (نگاه کنید به PurchaseGuestThrottleBypassTests)
|
||||
# و این تست رفتارِ per-user برای ثبتنامشدهها را میسنجد.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
|
||||
@@ -21,27 +21,10 @@ from .serializers import (
|
||||
|
||||
|
||||
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle
|
||||
(discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای
|
||||
درخواستِ احرازهویتشده از request.user.pk بهعنوانِ کلیدِ throttle
|
||||
استفاده میکنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ
|
||||
مهمانِ تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ
|
||||
حسابهای مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک
|
||||
bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ
|
||||
درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر میشد. این
|
||||
endpoint حساسِ مالیه: هر درخواستِ پذیرفتهشده وارد جریانِ purchase
|
||||
میشه، یک Subscription میسازه/آپدیت میکنه و create_order() رو صدا
|
||||
میزنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی میشه).
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
purchase/subscription/order/Idempotency-Key/plan-validation دستنخوردهست.
|
||||
"""
|
||||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||||
حساب مهمان نباید bucket تازهی purchase بده — این endpoint حساسِ مالیه
|
||||
(Subscription میسازه/آپدیت میکنه، create_order رو صدا میزنه). برای
|
||||
مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
@@ -85,20 +68,12 @@ class PurchaseView(APIView):
|
||||
to create an Order."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# scope کیدهای cache رو با pk کاربرِ احرازهویتشده میسازه (نه فقط IP)؛
|
||||
# چون Idempotency-Key بهتنهایی جلوی حجمِ بالای درخواستهای *متفاوت*
|
||||
# (با کلیدهای idempotency متفاوت) رو نمیگیره، این throttle مستقل و
|
||||
# مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user
|
||||
# احرازهویتشده باشه (is_authenticated=True) بهجای IP از
|
||||
# request.user.pk بهعنوانِ ident استفاده میکنه، پس تغییرِ IP از
|
||||
# همون اکانت این محدودیت رو دور نمیزنه.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwarePurchaseThrottle استفاده میشه که برای کاربرهای مهمان
|
||||
# کلید رو بر اساسِ IP میسازه (دقیقاً همون الگویِ GuestAwareAIThrottle،
|
||||
# GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای
|
||||
# کاربرهای غیرمهمان رفتارِ per-user قبلی رو دستنخورده نگه میداره.
|
||||
# این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
|
||||
# کلید رو میگیره، نه حجمِ درخواستهای متفاوت. کاربرهای مهمان با هر
|
||||
# deviceId یک pk تازه میگیرن، پس کلیدِ per-user ساده قابلدورزدنه؛
|
||||
# GuestAwarePurchaseThrottle برای مهمانها بر اساسِ IP کلید میسازه
|
||||
# (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای
|
||||
# کاربرهای غیرمهمان رفتارِ per-user را حفظ میکنه.
|
||||
throttle_scope = 'purchase'
|
||||
throttle_classes = [GuestAwarePurchaseThrottle]
|
||||
|
||||
|
||||
@@ -54,12 +54,8 @@ class BugReportCreateThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه
|
||||
# که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
@@ -131,11 +127,8 @@ class TicketCreateThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
|
||||
# عمداً بر اساسِ IP کلید میخورن (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر)؛ این تست رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه.
|
||||
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
@@ -208,11 +201,8 @@ class TicketMessageCreateThrottleTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
|
||||
# عمداً بر اساسِ IP کلید میخورن (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر)؛ این تست رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه.
|
||||
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
registered_ticket = Ticket.objects.create(
|
||||
|
||||
@@ -60,12 +60,9 @@ class BugReportCreateView(APIView):
|
||||
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ
|
||||
# احرازهویتشده میتونه تعدادِ نامحدودی BugReport با درخواستهای
|
||||
# پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید میخوره (نه IP).
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه
|
||||
# میگیرن، از GuestAwareSupportThrottle استفاده میشه تا چرخشِ حساب
|
||||
# مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا).
|
||||
# بدونِ idempotency/throttle، یک کاربر میتونست تعدادِ نامحدودی BugReport
|
||||
# بسازه؛ GuestAwareSupportThrottle برای مهمانها کلید رو بر اساسِ IP
|
||||
# میسازه تا چرخشِ حساب bucket تازه نده.
|
||||
throttle_scope = 'support_create'
|
||||
throttle_classes = [GuestAwareSupportThrottle]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user