diff --git a/CLAUDE.md b/CLAUDE.md index 41eb69b..0d2fa0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,7 @@ OAuth path beside the Pishkhan token (server still uses OAuth); `birthYear` alon `PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/ -`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values; -`DEMO_GUEST_DEVICE_IDS` defaults to `frontend-test-device` (remove with the demo data). +`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values. ## Before any real user: read `docs/security-before-production.md` @@ -121,7 +120,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. **Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern. -**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Prod is ArvanCloud → nginx → gunicorn, so the default is **2**; it was 1 until 2026-09-24, which made every guest share ArvanCloud's IP bucket. `/api/health` returns `clientIp` — it should be your own public IP. Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings. +**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Requests without a login are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Prod is ArvanCloud → nginx → gunicorn, so the default is **2**; it was 1 until 2026-09-24, which made every visitor share ArvanCloud's IP bucket. `/api/health` returns `clientIp` — it should be your own public IP. Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings. **`POST /api/ai/ask`** is real RAG (Qdrant retrieval filtered by chapter → GapGPT generation) whenever `OPENAI_API_KEY` and `QDRANT_API_KEY` are set, which they are on the server since 2026-09-23; without them it falls back to a deterministic stub. Entry point is `generate_tutor_answer` in `ai_tutor/views.py`; `usedChunks` in the response names the book chunks the answer was grounded on. diff --git a/accounts/admin.py b/accounts/admin.py index d6ba425..4c6b565 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -49,36 +49,10 @@ class SendSMSForm(forms.Form): return cleaned_data -class DeviceIdForSuperuserOnly: - """ - شناسه‌ی دستگاهِ حساب‌های مهمانِ قدیمی (که دیگر ساخته نمی‌شوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر - آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor). - """ - - def get_list_display(self, request): - cols = super().get_list_display(request) - return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id') - - def get_search_fields(self, request): - cols = super().get_search_fields(request) - return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id') - - def get_exclude(self, request, obj=None): - exclude = tuple(super().get_exclude(request, obj) or ()) - return exclude if request.user.is_superuser else exclude + ('device_id',) - - def lookup_allowed(self, lookup, value, request=None): - # جنگو هر فیلترِ querystring روی فیلدِ خودِ مدل را می‌پذیرد (?device_id__startswith=a)؛ - # بی‌این، با شمردنِ نتیجه شناسه حرف‌به‌حرف درمی‌آید. جنگو برای فیلترِ ردشده ۴۰۰ می‌دهد. - if lookup.split('__')[0] == 'device_id' and not (request and request.user.is_superuser): - return False - return super().lookup_allowed(lookup, value, request) - - @admin.register(User) -class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): - list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') - search_fields = ('name', 'phone', 'device_id') +class UserAdmin(admin.ModelAdmin): + list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at') + search_fields = ('name', 'phone') @admin.display(description='شناسه‌ی نمایشی') def public_id(self, obj): @@ -94,15 +68,13 @@ class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): if user_id: results |= queryset.filter(pk=user_id) return results, may_have_duplicates - list_filter = ('is_guest',) actions = ['send_sms_to_selected'] def get_readonly_fields(self, request, obj=None): # شماره کلیدِ ورود است: کارمندی با change_user که شماره‌ی کاربری را به شماره‌ی خودش عوض - # کند، کد می‌گیرد و واردِ همان حساب می‌شود. is_guest هم همین‌طور ورود با شناسه‌ی دستگاه را - # روی حسابِ شماره‌دار باز می‌کند. هر دو فقط برای ابرکاربر قابلِ تغییرند. + # کند، کد می‌گیرد و واردِ همان حساب می‌شود؛ پس فقط ابرکاربر آن را عوض می‌کند. fields = tuple(super().get_readonly_fields(request, obj)) - return fields if request.user.is_superuser else fields + ('phone', 'is_guest') + return fields if request.user.is_superuser else fields + ('phone',) # permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor) # می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛ @@ -198,8 +170,8 @@ class ProfileAdmin(admin.ModelAdmin): @admin.register(Session) -class SessionAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): - list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked') +class SessionAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'expires_at', 'revoked') list_filter = ('revoked',) diff --git a/accounts/management/commands/seed_test_data.py b/accounts/management/commands/seed_test_data.py index 07eac27..3613525 100644 --- a/accounts/management/commands/seed_test_data.py +++ b/accounts/management/commands/seed_test_data.py @@ -1,7 +1,7 @@ """ یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging می‌سازه، طوری که اجرای مکرر (repeat) هیچ رکورد تکراری‌ای نسازه — همه‌چیز با get_or_create روی -یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام +یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام می‌شه، نه با create ساده. هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۰ اپ (accounts، content، @@ -45,7 +45,6 @@ from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token # یکسان و بازه‌ی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده می‌مونه. MAIN_PHONE = '09120000001' REFERRED_PHONE = '09120000004' -GUEST_DEVICE_ID = 'seed-guest-device-0001' SEED_TAG = '[SEED]' @@ -107,9 +106,6 @@ class Command(BaseCommand): phones = [MAIN_PHONE, REFERRED_PHONE] user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True)) - guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first() - if guest: - user_ids.append(guest.pk) if user_ids: UserAnswer.objects.filter(user_id__in=user_ids).delete() @@ -140,17 +136,13 @@ class Command(BaseCommand): OTPCode.objects.filter(phone__in=phones).delete() Session.objects.filter(user__phone__in=phones).delete() - if guest: - Session.objects.filter(user=guest).delete() User.objects.filter(phone__in=phones).delete() - if guest: - guest.delete() self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...') # -- seed ------------------------------------------------------------- def _seed(self): - main_user, guest_user, referred_user = self._seed_accounts() + main_user, referred_user = self._seed_accounts() self._seed_content_and_progress(main_user) self._seed_characters() self._seed_ai_tutor(main_user) @@ -161,8 +153,7 @@ class Command(BaseCommand): self._seed_support(main_user) tokens = [ - ('کاربر اصلی (ثبت‌نام‌شده)', main_user, issue_access_token(main_user.pk)), - ('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)), + ('کاربر اصلی', main_user, issue_access_token(main_user.pk)), ] return {'tokens': tokens} @@ -170,7 +161,7 @@ class Command(BaseCommand): def _seed_accounts(self): main_user, _ = User.objects.get_or_create( phone=MAIN_PHONE, - defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None), + defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None), ) Profile.objects.get_or_create( user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی') @@ -179,33 +170,17 @@ class Command(BaseCommand): user=main_user, refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'), defaults=dict( - device_id='seed-device-main', - expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), - revoked=False, - ), - ) - - guest_user, _ = User.objects.get_or_create( - device_id=GUEST_DEVICE_ID, - is_guest=True, - defaults=dict(name=f'{SEED_TAG} کاربر مهمان'), - ) - Session.objects.get_or_create( - user=guest_user, - refresh_token=hash_refresh_token(f'seed-refresh-{GUEST_DEVICE_ID}'), - defaults=dict( - device_id=GUEST_DEVICE_ID, expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), revoked=False, ), ) referred_user, _ = User.objects.get_or_create( - phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده', is_guest=False) + phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده') ) - self.stdout.write(self.style.SUCCESS('accounts: 3 کاربر (اصلی/مهمان/معرفی‌شده) آماده شد.')) - return main_user, guest_user, referred_user + self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفی‌شده) آماده شد.')) + return main_user, referred_user # -- content + progress -------------------------------------------------- def _seed_content_and_progress(self, main_user): diff --git a/accounts/migrations/0008_remove_guest_login.py b/accounts/migrations/0008_remove_guest_login.py new file mode 100644 index 0000000..ccb982b --- /dev/null +++ b/accounts/migrations/0008_remove_guest_login.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.17 on 2026-10-05 14:25 + +from django.db import migrations + + +def refuse_if_guests_remain(apps, schema_editor): + """ + ورودِ مهمان حذف شده؛ ستونِ is_guest که برود، حسابِ مهمان بی‌هیچ نشانه‌ای یک کاربرِ بی‌شماره + می‌شود. پس تا مهمانی مانده، migration عمداً می‌شکند: اول مهمان‌ها را پاک کنید + (manage.py delete_users)، بعد migrate. + """ + User = apps.get_model('accounts', 'User') + left = User.objects.filter(is_guest=True).count() + if left: + raise RuntimeError( + f'{left} حسابِ مهمان (is_guest=True) هنوز در دیتابیس هست؛ پیش از این migration با ' + f'delete_users پاکشان کنید، وگرنه به کاربرِ بی‌شماره تبدیل می‌شوند.' + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_hash_refresh_tokens'), + ] + + operations = [ + migrations.RunPython(refuse_if_guests_remain, migrations.RunPython.noop), + migrations.RemoveConstraint( + model_name='user', + name='uniq_guest_device_id', + ), + migrations.RemoveField( + model_name='session', + name='device_id', + ), + migrations.RemoveField( + model_name='user', + name='device_id', + ), + migrations.RemoveField( + model_name='user', + name='is_guest', + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 2a5c06f..0c1070b 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -5,7 +5,7 @@ from django.db import models class User(models.Model): """ - کاربر اصلی سیستم (مهمان یا ثبت‌نام‌شده با شماره). + کاربر اصلی سیستم: ثبت‌نام‌شده با شماره‌ی موبایل (تنها راه ورود). توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه. @@ -13,14 +13,11 @@ class User(models.Model): این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه. """ phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True) - device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True) name = models.CharField(max_length=100, blank=True) avatar = models.CharField(max_length=100, blank=True) - is_guest = models.BooleanField(default=True) grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade created_at = models.DateTimeField(auto_now_add=True) - # آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ - # مهمانِ واقعی). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری + # آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری # می‌شه؛ در حال حاضر هیچ منطقِ دیگه‌ای به این فیلد وابسته نیست. last_activity_at = models.DateTimeField(null=True, blank=True) # access tokenهای صادرشده پیش از این لحظه رد می‌شوند (خروج از حساب). توکنِ @@ -30,17 +27,9 @@ class User(models.Model): class Meta: ordering = ['-created_at'] - constraints = [ - # جلوگیری از ساخت دو کاربر مهمان هم‌زمان با یک device_id یکسان. - models.UniqueConstraint( - fields=['device_id'], - condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''), - name='uniq_guest_device_id', - ), - ] def __str__(self): - return self.name or self.phone or f"guest-{self.pk}" + return self.name or self.phone or f"user-{self.pk}" @property def is_authenticated(self): @@ -77,7 +66,6 @@ class Session(models.Model): """ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions') refresh_token = models.CharField(max_length=200, unique=True) - device_id = models.CharField(max_length=64, blank=True) expires_at = models.DateTimeField() revoked = models.BooleanField(default=False) diff --git a/accounts/serializers.py b/accounts/serializers.py index 1232b4d..32d4d23 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -66,9 +66,6 @@ class OTPCodeField(serializers.CharField): class LoginRequestSerializer(serializers.Serializer): phone = IranMobileField() otp = OTPCodeField() - # فرانتِ فعلی نمی‌فرستد؛ اگر بیاید روی Session.device_id می‌نشیند. null یا بیش از ۶۴ نویسه ۴۰۰ است - # (پیش‌تر null به NOT NULL می‌خورد و ۵۰۰ می‌داد). - deviceId = serializers.CharField(max_length=64, required=False, allow_blank=True) class RefreshRequestSerializer(serializers.Serializer): @@ -99,7 +96,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer): ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی که فرستاده شوند تغییر می‌کنند (partial update واقعی). - `phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض + `phone` عمداً اینجا نیست: شماره فقط از مسیرِ OTP عوض می‌شود و مهمان‌بودن را کاربر نباید بتواند خودش تغییر دهد. """ name = serializers.CharField(max_length=100, required=False, allow_blank=True) @@ -195,7 +192,6 @@ class ProfileResponseSerializer(serializers.Serializer): phone = serializers.CharField(allow_null=True) name = serializers.CharField() avatar = serializers.CharField() - isGuest = serializers.BooleanField() gradeId = serializers.IntegerField(allow_null=True) birthYear = serializers.IntegerField(allow_null=True) birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.') @@ -203,10 +199,8 @@ class ProfileResponseSerializer(serializers.Serializer): class DeleteAccountRequestSerializer(serializers.Serializer): - # کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره). - otp = OTPCodeField(required=False, allow_blank=True) - # مهمان شماره‌ای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد. - confirm = serializers.CharField(max_length=10, required=False, allow_blank=True) + # کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره). + otp = OTPCodeField() class DeleteAccountResponseSerializer(serializers.Serializer): diff --git a/accounts/signals.py b/accounts/signals.py index e806500..9314c66 100644 --- a/accounts/signals.py +++ b/accounts/signals.py @@ -1,5 +1,5 @@ """ -سیگنال ثبت‌نامِ واقعی کاربر (نه guest session). سایر اپ‌ها (مثلاً discount +سیگنال ثبت‌نامِ واقعی کاربر. سایر اپ‌ها (مثلاً discount برای اختصاص خودکار آفر) به‌جای import کردن مستقیم از accounts، فقط این سیگنال رو در apps.py خودشون connect می‌کنن؛ این یعنی accounts هیچ‌وقت لازم نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای diff --git a/accounts/testing.py b/accounts/testing.py deleted file mode 100644 index cec1d6a..0000000 --- a/accounts/testing.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -ابزارِ تست: یک حسابِ مهمانِ قدیمی با نشست می‌سازد. - -ورودِ مهمان (`POST /api/auth/session`) حذف شد، اما ردیف‌های مهمانی که پیش‌تر ساخته شده‌اند -(حساب‌های دمو و کاربرانِ قبلی) هنوز در دیتابیس‌اند و رفتارِ ارتقایشان به حسابِ شماره‌دار -(ورود با شماره، حفظ پیشرفت، حذف حساب) باید تست شود. این تابع همان ردیفی را می‌سازد که آن اندپوینت می‌ساخت. -""" -import secrets - -from django.conf import settings -from django.utils import timezone - -from .authentication import issue_access_token -from .models import Session, User, hash_refresh_token - - -def guest_session(device_id): - """مثلِ پاسخِ قدیمیِ اندپوینت: userId, token, refreshToken, isNewUser, isGuest.""" - user, is_new = User.objects.get_or_create(device_id=device_id, is_guest=True, defaults={'name': ''}) - user.last_activity_at = timezone.now() - user.save(update_fields=['last_activity_at']) - refresh = secrets.token_hex(32) - Session.objects.create( - user=user, refresh_token=hash_refresh_token(refresh), device_id=device_id, - expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), - ) - return { - 'userId': str(user.id), 'token': issue_access_token(user.id), - 'refreshToken': refresh, 'isNewUser': is_new, 'isGuest': True, - } diff --git a/accounts/tests.py b/accounts/tests.py index c1fd202..197a397 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -14,7 +14,6 @@ from rest_framework.throttling import ScopedRateThrottle from .authentication import issue_access_token from .models import OTPCode, Profile, Session, User, hash_refresh_token -from .testing import guest_session from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms @@ -162,7 +161,6 @@ class SendOtpSmsTests(TestCase): self.assertIn('provider_status=401', message) - class RequestOTPViewTests(TestCase): """ تست‌های سطح API برای RequestOTPView: مطمئن می‌شیم رفتارِ dev قبلی حفظ @@ -286,12 +284,11 @@ class RefreshTokenReplayTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(is_guest=True, device_id='device-refresh-test') + self.user = User.objects.create(phone='09121230201') self.raw = 'test-refresh-token-abc123' self.session = Session.objects.create( user=self.user, refresh_token=hash_refresh_token(self.raw), - device_id='device-refresh-test', expires_at=timezone.now() + timezone.timedelta(days=30), revoked=False, ) @@ -760,14 +757,13 @@ class RefreshTokenThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test') + self.user = User.objects.create(phone='09121230202') def _create_session(self, token_suffix): raw = f'test-refresh-token-{token_suffix}' session = Session.objects.create( user=self.user, refresh_token=hash_refresh_token(raw), - device_id='device-refresh-throttle-test', expires_at=timezone.now() + timezone.timedelta(days=30), revoked=False, ) @@ -824,28 +820,6 @@ class RefreshTokenThrottleTests(TestCase): self.assertFalse(blocked_session.revoked) -class GuestSessionThrottleTests(TestCase): - """ - 🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی - GuestSessionView: درخواست‌های تکراری از همون کلاینت (IP) باید بعد از - حدِ مجاز با ۴۲۹ رد بشن. - """ - - def setUp(self): - self.client = APIClient() - cache.clear() - - def test_guest_login_endpoint_is_gone(self): - # ورودِ مهمان حذف شده: هر کاربر باید با شماره ثبت‌نام کند. قدیمی‌ها باید ۴۰۴ - # بگیرند، نه حسابِ مهمانِ ساخته‌شده. - before = User.objects.count() - response = self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': 'device-x'}, format='json', - ) - self.assertEqual(response.status_code, 404) - self.assertEqual(User.objects.count(), before) - - class UserProfileViewTests(TestCase): """تست‌های GET /api/user/profile — پروفایل کاربر جاری.""" @@ -862,8 +836,8 @@ class UserProfileViewTests(TestCase): response = self._get_profile() self.assertEqual(response.status_code, 401) - def test_returns_profile_for_guest_without_existing_profile_row(self): - user = User.objects.create(device_id='device-profile-1', is_guest=True, name='') + def test_returns_profile_for_user_without_existing_profile_row(self): + user = User.objects.create(phone='09000000055', name='') token = issue_access_token(user.id) response = self._get_profile(token) @@ -871,8 +845,7 @@ class UserProfileViewTests(TestCase): self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data['userId'], str(user.id)) - self.assertEqual(data['isGuest'], True) - self.assertIsNone(data['phone']) + self.assertEqual(data['phone'], '09000000055') self.assertIsNone(data['gradeId']) self.assertIsNone(data['birthYear']) self.assertIsNone(data['birthDate']) @@ -882,7 +855,7 @@ class UserProfileViewTests(TestCase): def test_returns_existing_profile_fields_for_registered_user(self): user = User.objects.create( - phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2, + phone='09120000001', name='علی', avatar='avatar.png', grade_id=2, ) Profile.objects.create(user=user, birth_year=1384, bio='سلام') token = issue_access_token(user.id) @@ -894,15 +867,14 @@ class UserProfileViewTests(TestCase): self.assertEqual(data['phone'], '09120000001') self.assertEqual(data['name'], 'علی') self.assertEqual(data['avatar'], 'avatar.png') - self.assertEqual(data['isGuest'], False) self.assertEqual(data['gradeId'], 2) self.assertEqual(data['birthYear'], 1384) self.assertEqual(data['bio'], 'سلام') def test_does_not_leak_other_users_profile(self): - other = User.objects.create(device_id='device-other', is_guest=True, name='') + other = User.objects.create(phone='09000000056', name='') Profile.objects.create(user=other, bio='محرمانه') - user = User.objects.create(device_id='device-profile-2', is_guest=True, name='') + user = User.objects.create(phone='09000000057', name='') token = issue_access_token(user.id) response = self._get_profile(token) @@ -932,13 +904,13 @@ class SendSmsAdminActionTests(TestCase): # inactive/never-active موردنظرِ خودشون رو صریحاً می‌سازن. now = timezone.now() self.user1 = User.objects.create( - name='u1', is_guest=False, phone='0910000001', last_activity_at=now, + name='u1', phone='0910000001', last_activity_at=now, ) self.user2 = User.objects.create( - name='u2', is_guest=False, phone='0910000002', last_activity_at=now, + name='u2', phone='0910000002', last_activity_at=now, ) - self.guest_no_phone = User.objects.create( - name='guest', is_guest=True, phone=None, last_activity_at=now, + self.no_phone_user = User.objects.create( + name='no-phone', phone=None, last_activity_at=now, ) self.changelist_url = reverse('admin:accounts_user_changelist') @@ -963,10 +935,10 @@ class SendSmsAdminActionTests(TestCase): mock_send_sms.assert_not_called() @patch('accounts.admin.send_sms') - def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms): + def test_apply_sends_sms_to_users_with_phone_and_skips_users_without(self, mock_send_sms): response = self.client.post(self.changelist_url, { 'action': 'send_sms_to_selected', - '_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk], + '_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk], 'apply': 'ارسال پیامک', 'message': 'سلام از طرف ادمین', }) @@ -1030,14 +1002,14 @@ class SendSmsAdminActionTests(TestCase): """ now = timezone.now() active_user = User.objects.create( - name='active', is_guest=False, phone='0910000009', last_activity_at=now, + name='active', phone='0910000009', last_activity_at=now, ) inactive_user = User.objects.create( - name='inactive', is_guest=False, phone='0910000010', + name='inactive', phone='0910000010', last_activity_at=now - timezone.timedelta(days=90), ) never_active_user = User.objects.create( - name='never-active', is_guest=False, phone='0910000011', last_activity_at=None, + name='never-active', phone='0910000011', last_activity_at=None, ) cutoff_date = (now - timezone.timedelta(days=30)).date() @@ -1085,7 +1057,7 @@ class SendSmsAdminActionTests(TestCase): 'inactive' هم مثلِ 'selected' حفظ بشه. """ User.objects.create( - name='inactive-no-phone', is_guest=True, phone=None, + name='inactive-no-phone', phone=None, last_activity_at=timezone.now() - timezone.timedelta(days=90), ) cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date() @@ -1115,7 +1087,7 @@ class SendSmsAdminActionTests(TestCase): """ response = self.client.post(self.changelist_url, { 'action': 'send_sms_to_selected', - '_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk], + '_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk], 'apply': 'ارسال پیامک', 'message': 'متن', 'target': 'selected', @@ -1137,11 +1109,11 @@ class SendSmsAdminActionTests(TestCase): """ now = timezone.now() User.objects.create( - name='inactive-1', is_guest=False, phone='0910000021', + name='inactive-1', phone='0910000021', last_activity_at=now - timezone.timedelta(days=90), ) User.objects.create( - name='inactive-2', is_guest=False, phone='0910000022', + name='inactive-2', phone='0910000022', last_activity_at=now - timezone.timedelta(days=90), ) cutoff_date = (now - timezone.timedelta(days=30)).date() @@ -1185,7 +1157,7 @@ class SendSmsAdminActionTests(TestCase): """ response = self.client.post(self.changelist_url, { 'action': 'send_sms_to_selected', - '_selected_action': [self.user1.pk, self.guest_no_phone.pk], + '_selected_action': [self.user1.pk, self.no_phone_user.pk], 'apply': 'ارسال پیامک', 'message': 'متن', 'target': 'selected', @@ -1226,7 +1198,7 @@ class SupervisorGroupAccessTests(TestCase): is_staff=True, ) - self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099') + self.target_user = User.objects.create(name='target', phone='0910000099') self.plan = Plan.objects.create(title='یک‌ماهه', price=10000) self.order = Order.objects.create( user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1', @@ -1333,7 +1305,7 @@ class SupervisorGroupAccessTests(TestCase): original_name = self.target_user.name response = self.client.post( reverse('admin:accounts_user_change', args=[self.target_user.pk]), - {'name': 'دستکاری‌شده', 'is_guest': 'on'}, + {'name': 'دستکاری‌شده'}, ) self.assertEqual(response.status_code, 403) self.target_user.refresh_from_db() @@ -1422,7 +1394,7 @@ class AuthUserRowCacheTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n') + self.user = User.objects.create(phone='09000000058', name='n') self.token = issue_access_token(self.user.id) # هر endpointِ authenticated؛ /api/user/hearts سبک است. self.url = '/api/user/hearts' @@ -1492,7 +1464,7 @@ class UpdateUserProfileTests(TestCase): def setUp(self): self.client = APIClient() cache.clear() - self.user = User.objects.create(device_id='device-patch-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000059', name='') self.token = issue_access_token(self.user.id) def tearDown(self): @@ -1536,13 +1508,12 @@ class UpdateUserProfileTests(TestCase): def test_empty_body_is_rejected(self): self.assertEqual(self._patch({}).status_code, 400) - def test_phone_and_guest_flag_cannot_be_changed(self): - response = self._patch({'name': 'رضا', 'phone': '09120000000', 'isGuest': False}) + def test_phone_cannot_be_changed(self): + response = self._patch({'name': 'رضا', 'phone': '09120000000'}) self.assertEqual(response.status_code, 200) self.user.refresh_from_db() - self.assertIsNone(self.user.phone) - self.assertTrue(self.user.is_guest) + self.assertNotEqual(self.user.phone, '09120000000') def test_out_of_range_birth_year_is_rejected(self): self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400) @@ -1660,8 +1631,11 @@ class RefreshTokenStoredHashedTests(TestCase): def test_issued_tokens_are_stored_only_as_hash(self): client = APIClient() - body = guest_session('hash-dev') - raw = body['refreshToken'] + user = User.objects.create(phone='09121230203') + raw = 'raw-token-for-hash-test' + Session.objects.create(user=user, refresh_token=hash_refresh_token(raw), + expires_at=timezone.now() + timezone.timedelta(days=1)) + self.assertFalse(Session.objects.filter(refresh_token=raw).exists()) self.assertFalse(Session.objects.filter(refresh_token=raw).exists()) self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists()) @@ -1674,7 +1648,7 @@ class RefreshTokenStoredHashedTests(TestCase): from django.apps import apps as global_apps - user = User.objects.create(is_guest=True, device_id='hash-mig') + user = User.objects.create(phone='09121230204') Session.objects.create(user=user, refresh_token='old-raw-token', expires_at=timezone.now() + timezone.timedelta(days=1)) import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None) diff --git a/accounts/tests_audit_fixes.py b/accounts/tests_audit_fixes.py index 2aac3ba..cf83752 100644 --- a/accounts/tests_audit_fixes.py +++ b/accounts/tests_audit_fixes.py @@ -9,7 +9,6 @@ from django.test import TestCase, override_settings from rest_framework.test import APIClient from accounts.authentication import issue_access_token -from accounts.testing import guest_session from accounts.models import OTPCode, User from content.models import Choice, Grade, Lesson, Node, Question, Unit from progress.models import UserHearts, UserStats @@ -21,26 +20,35 @@ def _client(user): return client +def _login(phone): + """ورودِ واقعی با پیامکِ (ماک‌شده) و کد؛ پاسخِ login را برمی‌گرداند.""" + with patch('accounts.views.secrets.randbelow', return_value=123456), \ + patch('accounts.views.send_otp_sms'): + APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') + return APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json').json() + + +@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) class LogoutTests(TestCase): def setUp(self): cache.clear() def test_logout_kills_the_current_token_and_its_refresh(self): - guest = guest_session('dev-out') + session = _login('09121234901') client = APIClient() - client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}') + client.credentials(HTTP_AUTHORIZATION=f'Bearer {session["token"]}') self.assertEqual(client.get('/api/user/profile').status_code, 200) time.sleep(1.1) # زمانِ توکن ثانیه‌ای است؛ توکن باید پیش از خروج صادر شده باشد - response = client.post('/api/auth/logout', {'refreshToken': guest['refreshToken']}, format='json') + response = client.post('/api/auth/logout', {'refreshToken': session['refreshToken']}, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(client.get('/api/user/profile').status_code, 401) - refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': guest['refreshToken']}, format='json') + refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': session['refreshToken']}, format='json') self.assertEqual(refreshed.status_code, 401) # ورودِ دوباره کار می‌کند - again = guest_session('dev-out') + again = _login('09121234901') fresh = APIClient() fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}') self.assertEqual(fresh.get('/api/user/profile').status_code, 200) @@ -66,49 +74,9 @@ class OtpTests(TestCase): self.assertEqual(right.status_code, 400) -@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, - TEST_LOGIN_PHONES={'09120000101': '4321'}) -class GuestSignupKeepsProgressTests(TestCase): - def setUp(self): - cache.clear() - guest = guest_session('dev-up') - self.guest_id = int(guest['userId']) - UserStats.objects.create(user_id=self.guest_id, xp=120) - self.client = APIClient() - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}') - - def _login(self, phone, otp): - return self.client.post('/api/auth/login', {'phone': phone, 'otp': otp}, format='json') - - def test_the_guest_becomes_the_registered_user(self): - with patch('accounts.views.secrets.randbelow', return_value=654321), \ - patch('accounts.views.send_otp_sms'): - self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json') - body = self._login('09121234002', '654321').json() - - self.assertEqual(int(body['userId']), self.guest_id) - user = User.objects.get(pk=self.guest_id) - self.assertEqual((user.phone, user.is_guest), ('09121234002', False)) - self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120) - - def test_an_existing_account_is_not_merged(self): - existing = User.objects.create(phone='09121234003', is_guest=False) - with patch('accounts.views.secrets.randbelow', return_value=111222), \ - patch('accounts.views.send_otp_sms'): - self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json') - body = self._login('09121234003', '111222').json() - self.assertEqual(int(body['userId']), existing.id) - self.assertTrue(User.objects.get(pk=self.guest_id).is_guest) - - def test_demo_test_phones_never_absorb_a_guest(self): - body = self._login('09120000101', '4321').json() - self.assertNotEqual(int(body['userId']), self.guest_id) - self.assertTrue(User.objects.get(pk=self.guest_id).is_guest) - - class DisplayNameTests(TestCase): def test_contact_details_are_stripped_from_the_name(self): - user = User.objects.create(phone='09121234004', is_guest=False) + user = User.objects.create(phone='09121234004') body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'}, format='json').json() self.assertEqual(body['name'], 'سارا') @@ -122,7 +90,7 @@ class HeartsTests(TestCase): self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'), type='multiple_choice', prompt='؟') self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True) - self.user = User.objects.create(phone='09121234005', is_guest=False) + self.user = User.objects.create(phone='09121234005') UserHearts.objects.create(user_id=self.user.id, current_hearts=0) def _answer(self): diff --git a/accounts/tests_birth_date.py b/accounts/tests_birth_date.py index f8adb28..d88584b 100644 --- a/accounts/tests_birth_date.py +++ b/accounts/tests_birth_date.py @@ -47,7 +47,7 @@ class JalaliConversionTests(SimpleTestCase): class ProfileBirthDateApiTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(device_id='birth-date-device', is_guest=True) + self.user = User.objects.create(phone='09000000053') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -94,7 +94,7 @@ class PublicIdTests(TestCase): def test_profile_shows_a_six_digit_code_that_maps_back_to_the_user(self): from accounts.public_id import decode - user = User.objects.create(device_id='public-id-device', is_guest=True) + user = User.objects.create(phone='09000000054') client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') code = client.get('/api/user/profile').json()['publicId'] diff --git a/accounts/tests_delete_account.py b/accounts/tests_delete_account.py index 1c3b9d7..7ee8e45 100644 --- a/accounts/tests_delete_account.py +++ b/accounts/tests_delete_account.py @@ -22,7 +22,7 @@ def _client(user): class DeleteAccountTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09121115001', is_guest=False) + self.user = User.objects.create(phone='09121115001') UserStats.objects.create(user_id=self.user.id, xp=50) self.client = _client(self.user) @@ -53,12 +53,6 @@ class DeleteAccountTests(TestCase): # توکنِ قبلی دیگر به هیچ کاربری نمی‌رسد. self.assertIn(self.client.get('/api/user/profile').status_code, (401, 403)) - def test_guest_confirms_by_typing_the_word(self): - guest = User.objects.create(device_id='guest-del', is_guest=True) - client = _client(guest) - self.assertEqual(client.post('/api/user/delete', {'confirm': 'باشه'}, format='json').status_code, 400) - self.assertEqual(client.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200) - self.assertFalse(User.objects.filter(pk=guest.pk).exists()) @override_settings(TEST_LOGIN_PHONES={'09121115001': '4321'}) def test_demo_accounts_cannot_be_deleted(self): diff --git a/accounts/tests_delete_users.py b/accounts/tests_delete_users.py index 8ccaa0e..aa0b506 100644 --- a/accounts/tests_delete_users.py +++ b/accounts/tests_delete_users.py @@ -13,8 +13,8 @@ from progress.models import UserStats class DeleteUsersTests(TestCase): def setUp(self): - self.doomed = User.objects.create(phone='09121110001', is_guest=False) - self.kept = User.objects.create(phone='09121110002', is_guest=False) + self.doomed = User.objects.create(phone='09121110001') + self.kept = User.objects.create(phone='09121110002') for user in (self.doomed, self.kept): Profile.objects.create(user=user) UserStats.objects.create(user_id=user.id, xp=10) @@ -51,12 +51,10 @@ class DeleteUsersTests(TestCase): self.assertFalse(Order.objects.filter(user_id=self.doomed.pk).exists()) @override_settings(TEST_LOGIN_PHONES={'09121110002': '1234'}) - def test_real_phones_keeps_demo_numbers_and_guests(self): - guest = User.objects.create(device_id='guest-x', is_guest=True) + def test_real_phones_keeps_demo_numbers(self): self.run_command('--real-phones', '--yes') self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists()) self.assertTrue(User.objects.filter(pk=self.kept.pk).exists()) - self.assertTrue(User.objects.filter(pk=guest.pk).exists()) def test_skip_paid_keeps_the_payer_and_deletes_the_rest(self): Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k2') diff --git a/accounts/tests_guest_refresh.py b/accounts/tests_guest_refresh.py deleted file mode 100644 index a699df9..0000000 --- a/accounts/tests_guest_refresh.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -مهمان هم refresh token می‌گیرد. - -قبلاً مهمان فقط توکنِ یک‌ساعته داشت؛ بعد از یک ساعت همه‌ی درخواست‌ها ۴۰۱ -می‌شدند و اپ که هنوز خودش را واردشده می‌دید، بی‌صدا خطا می‌خورد. -""" -from django.core.cache import cache -from django.test import TestCase - -from accounts.testing import guest_session - - -class GuestRefreshTokenTests(TestCase): - def setUp(self): - cache.clear() - - def _guest(self, device='device-abc'): - return guest_session(device) - - def test_guest_session_has_a_refresh_token(self): - body = self._guest() - self.assertTrue(body['refreshToken']) - - def test_the_refresh_token_renews_the_same_guest(self): - body = self._guest() - - renewed = self.client.post( - '/api/auth/refresh', {'refreshToken': body['refreshToken']}, - content_type='application/json', - ) - - self.assertEqual(renewed.status_code, 200) - profile = self.client.get( - '/api/user/profile', HTTP_AUTHORIZATION=f"Bearer {renewed.json()['token']}", - ).json() - self.assertEqual(profile['userId'], body['userId']) - self.assertTrue(profile['isGuest']) - - def test_each_guest_session_gets_its_own_refresh_token(self): - self.assertNotEqual(self._guest()['refreshToken'], self._guest()['refreshToken']) diff --git a/accounts/tests_otp_limits.py b/accounts/tests_otp_limits.py index 6b22e3b..c5ab20e 100644 --- a/accounts/tests_otp_limits.py +++ b/accounts/tests_otp_limits.py @@ -36,7 +36,7 @@ class PhoneNormalizationTests(TestCase): self.assertEqual(set(OTPCode.objects.values_list('phone', flat=True)), {'09121112233'}) def test_login_with_another_spelling_reaches_the_same_account(self): - User.objects.create(phone='09121112244', is_guest=False) + User.objects.create(phone='09121112244') with patch('accounts.views.secrets.randbelow', return_value=246810), \ patch('accounts.views.send_otp_sms'): _request('+989121112244') diff --git a/accounts/tests_security_review.py b/accounts/tests_security_review.py index 72d181c..81520bd 100644 --- a/accounts/tests_security_review.py +++ b/accounts/tests_security_review.py @@ -14,7 +14,6 @@ from django.utils import timezone from rest_framework.test import APIClient from accounts.authentication import issue_access_token -from accounts.testing import guest_session from accounts.models import OTPCode, User from config.log_formatters import JSONLogFormatter @@ -30,33 +29,6 @@ def _otp_row(phone, code='482913'): return code -def _guest(device): - return guest_session(device) - - -class GuestUpgradeRevokesEarlierSessionsTests(TestCase): - """کسی که پیش از ثبتِ شماره با شناسه‌ی دستگاه نشست گرفته بود، بعد از آن به حسابِ شماره‌دار نمی‌رسید.""" - - def setUp(self): - cache.clear() - - def test_pre_upgrade_tokens_stop_working(self): - attacker = _guest('shared-device') - victim = _guest('shared-device') - login = _bearer(victim['token']).post('/api/auth/login', - {'phone': '09121230001', 'otp': _otp_row('09121230001')}, - format='json') - self.assertEqual(login.status_code, 200) - self.assertEqual(login.json()['userId'], victim['userId']) - - refresh = APIClient().post('/api/auth/refresh', {'refreshToken': attacker['refreshToken']}, format='json') - self.assertEqual(refresh.status_code, 401) - # توکنِ دسترسیِ پیش از ثبت هم (اگر همان ثانیه صادر نشده باشد) رد می‌شود؛ نشستِ تازه کار می‌کند. - self.assertEqual(_bearer(login.json()['token']).get('/api/user/profile').status_code, 200) - fresh = APIClient().post('/api/auth/refresh', {'refreshToken': login.json()['refreshToken']}, format='json') - self.assertEqual(fresh.status_code, 200) - - class AccountDeletionKeepsLimitsTests(TestCase): def setUp(self): cache.clear() @@ -66,23 +38,25 @@ class AccountDeletionKeepsLimitsTests(TestCase): question_credits=20, hearts_days=7, duration_days=0) DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1) - def _guest_redeems(self, device): - s = _guest(device) - c = _bearer(s['token']) + def _user_redeems(self, phone): + user = User.objects.create(phone=phone) + c = _bearer(issue_access_token(user.id)) p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json') return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json') def test_single_use_code_stays_used_after_delete(self): - c1, first = self._guest_redeems('g-1') + c1, first = self._user_redeems('09121230101') self.assertEqual(first.status_code, 200) - self.assertEqual(c1.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200) - _, again = self._guest_redeems('g-2') + OTPCode.objects.create(phone='09121230101', code='777777', + expires_at=timezone.now() + timezone.timedelta(minutes=5)) + self.assertEqual(c1.post('/api/user/delete', {'otp': '777777'}, format='json').status_code, 200) + _, again = self._user_redeems('09121230102') self.assertEqual(again.status_code, 400) @override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) def test_per_phone_sms_cap_survives_delete(self): phone = '09121230009' - user = User.objects.create(phone=phone, is_guest=False) + user = User.objects.create(phone=phone) for i in range(10): OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True, expires_at=timezone.now() - timezone.timedelta(minutes=i + 1)) @@ -95,14 +69,6 @@ class AccountDeletionKeepsLimitsTests(TestCase): r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') self.assertEqual(r.status_code, 429) - def test_public_demo_guest_cannot_be_deleted(self): - demo = User.objects.create(device_id='frontend-test-device', is_guest=True, name='demo') - s = _guest('frontend-test-device') - self.assertEqual(int(s['userId']), demo.id) - r = _bearer(s['token']).post('/api/user/delete', {'confirm': 'حذف'}, format='json') - self.assertEqual(r.status_code, 403) - self.assertTrue(User.objects.filter(pk=demo.id).exists()) - class DisplayNameContactFilterTests(TestCase): def test_contacts_are_removed_and_names_kept(self): @@ -151,35 +117,6 @@ class KavenegarKeyNotLoggedTests(TestCase): self.assertNotIn(FAKE_KEY, out) -class SupervisorDoesNotSeeDeviceIdsTests(TestCase): - """شناسه‌ی دستگاه برای مهمان رمزِ ورود است؛ کارمندِ فقط‌خواندنی آن را نمی‌بیند.""" - - def setUp(self): - call_command('setup_supervisor_group', verbosity=0) - self.guest = User.objects.create(device_id='secret-device-123', is_guest=True) - from accounts.models import Session - Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='secret-device-123', - expires_at=timezone.now() + timezone.timedelta(days=1)) - staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True) - staff.groups.add(Group.objects.get(name='Supervisor')) - # Supervisor نشست‌ها را نمی‌بیند؛ این‌جا اجازه‌ی دیدن داده می‌شود تا ستونِ آن صفحه هم سنجیده شود. - from django.contrib.auth.models import Permission - staff.user_permissions.add(Permission.objects.get(codename='view_session', content_type__app_label='accounts')) - self.client.force_login(staff) - - def test_hidden_in_list_search_and_form(self): - listing = self.client.get('/admin/accounts/user/') - self.assertEqual(listing.status_code, 200) - self.assertNotContains(listing, 'secret-device-123') - # متنِ جستجو در خودِ صفحه تکرار می‌شود؛ نتیجه نباید همان مهمان باشد. - search = self.client.get('/admin/accounts/user/', {'q': 'secret-device-123'}) - self.assertNotContains(search, f'/admin/accounts/user/{self.guest.pk}/change/') - form = self.client.get(f'/admin/accounts/user/{self.guest.pk}/change/') - self.assertNotContains(form, 'secret-device-123') - sessions = self.client.get('/admin/accounts/session/') - self.assertNotContains(sessions, 'secret-device-123') - - def _staff_with(*perms, username='staff1'): from django.contrib.auth.models import Permission staff = get_user_model().objects.create_user(username, f'{username}@example.com', 'pw-for-tests-only', is_staff=True) @@ -188,53 +125,24 @@ def _staff_with(*perms, username='staff1'): return staff -class DeviceIdNotGuessableByAdminFilterTests(TestCase): - """فیلترِ querystringِ changelist (?device_id__startswith=…) شناسه‌ی دستگاه را برای غیرِ ابرکاربر لو نمی‌دهد.""" - - def setUp(self): - call_command('setup_supervisor_group', verbosity=0) - self.guest = User.objects.create(device_id='abc-secret-999', is_guest=True) - from accounts.models import Session - Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='abc-secret-999', - expires_at=timezone.now() + timezone.timedelta(days=1)) - staff = _staff_with(('accounts', 'view_session')) - staff.groups.add(Group.objects.get(name='Supervisor')) - self.client.force_login(staff) - - def test_filter_on_device_id_is_refused(self): - for lookup in ('device_id__startswith', 'device_id__regex', 'device_id'): - r = self.client.get('/admin/accounts/user/', {lookup: 'abc'}) - self.assertEqual(r.status_code, 400, lookup) - self.assertNotContains(r, f'/admin/accounts/user/{self.guest.pk}/change/', status_code=400) - r = self.client.get('/admin/accounts/session/', {'device_id__startswith': 'abc'}) - self.assertEqual(r.status_code, 400) - - def test_superuser_still_filters(self): - root = get_user_model().objects.create_superuser('root', 'root@example.com', 'pw-for-tests-only') - self.client.force_login(root) - r = self.client.get('/admin/accounts/user/', {'device_id__startswith': 'abc'}) - self.assertContains(r, f'/admin/accounts/user/{self.guest.pk}/change/') - - class StaffCannotSwapLoginPhoneTests(TestCase): """کارمندی با change_user شماره‌ی کاربر را عوض نمی‌کند؛ وگرنه با شماره‌ی خودش کد می‌گیرد و واردِ آن حساب می‌شود.""" def setUp(self): cache.clear() - self.victim = User.objects.create(phone='09120000077', is_guest=False, name='victim') + self.victim = User.objects.create(phone='09120000077', name='victim') self.client.force_login(_staff_with(('accounts', 'change_user'), ('accounts', 'view_user'))) def tearDown(self): cache.clear() - def test_phone_and_is_guest_are_read_only(self): + def test_phone_is_read_only(self): r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', { - 'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': '', 'is_guest': 'on'}) + 'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': ''}) self.assertEqual(r.status_code, 302) self.victim.refresh_from_db() self.assertEqual(self.victim.name, 'renamed') self.assertEqual(self.victim.phone, '09120000077') - self.assertFalse(self.victim.is_guest) login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')}, format='json') self.assertNotEqual(login.json().get('userId'), str(self.victim.pk)) @@ -270,7 +178,7 @@ class StaffAccountsAdminSuperuserOnlyTests(TestCase): class UnicodeInputsAreNormalisedOrRejectedTests(TestCase): - """رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCII، پایه‌ی ناموجود و deviceIdِ null: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا.""" + """رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCIIو پایه‌ی ناموجود: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا.""" def setUp(self): cache.clear() @@ -290,20 +198,15 @@ class UnicodeInputsAreNormalisedOrRejectedTests(TestCase): r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json') self.assertEqual(r.status_code, 400, code) - def test_login_device_id_null_is_400(self): - phone = '09120000098' - code = _otp_row(phone) - r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code, 'deviceId': None}, format='json') - self.assertEqual(r.status_code, 400) def test_unknown_or_huge_grade_is_400(self): - user = User.objects.create(phone='09120000097', is_guest=False) + user = User.objects.create(phone='09120000097') app = _bearer(issue_access_token(user.id)) for gid in (99999, 10 ** 30): self.assertEqual(app.post('/api/user/grade', {'gradeId': gid}, format='json').status_code, 400, gid) def test_birth_date_with_superscript_digit_is_400(self): - user = User.objects.create(phone='09120000096', is_guest=False) + user = User.objects.create(phone='09120000096') r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json') self.assertEqual(r.status_code, 400) @@ -315,19 +218,3 @@ class UnicodeInputsAreNormalisedOrRejectedTests(TestCase): self.assertEqual(clean('علی رضایی'), 'علی رضایی') -class DemoGuestNotUpgradableTests(TestCase): - """مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمی‌شود؛ شماره‌ی تازه حسابِ تازه می‌گیرد.""" - - def test_demo_guest_keeps_its_identity(self): - cache.clear() - self.addCleanup(cache.clear) - s = _guest('frontend-test-device') - demo_id = int(s['userId']) - phone = '09120000095' - code = _otp_row(phone) - r = _bearer(s['token']).post('/api/auth/login', {'phone': phone, 'otp': code}, format='json') - self.assertEqual(r.status_code, 200) - self.assertNotEqual(int(r.json()['userId']), demo_id) - demo = User.objects.get(pk=demo_id) - self.assertIsNone(demo.phone) - self.assertTrue(demo.is_guest) diff --git a/accounts/views.py b/accounts/views.py index 15e6842..fd545a8 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -38,30 +38,6 @@ from .serializers import ( logger = logging.getLogger(__name__) -class GuestAwareAccountThrottle(ScopedRateThrottle): - """ - همان الگویِ GuestAwareContentThrottle (content/views.py)، - GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون ورودِ مهمان (حذف‌شده؛ حساب‌های مهمانِ قبلی هنوز هستند) - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک - ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی - بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن) - از همین کلید بر اساسِ IP برای مهمان‌ها استفاده می‌شه؛ رفتارِ - throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دست‌نخورده - می‌مونه. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - class RequestOTPView(APIView): """ POST /api/auth/otp/request — درخواستِ کدِ ورود. @@ -300,28 +276,7 @@ class LoginView(APIView): return error with transaction.atomic(): - user = User.objects.filter(phone=phone).first() - created = False - guest = _upgradable_guest(request, phone) if user is None else None - if guest is not None: - # مهمانی که ثبت‌نام می‌کند همان کاربر می‌ماند: امتیاز، استریک، - # جزیره‌ها و گفتگوها همه با همین user_id ذخیره شده‌اند. پیش‌تر یک - # کاربرِ تازه و خالی ساخته می‌شد و پیشرفتِ مهمان جا می‌ماند. - guest.phone = phone - # هر نشستی که پیش از این با شناسه‌ی دستگاه گرفته شده بود (گوشیِ مشترک، کسی که - # شناسه را دیده) نباید به حسابِ شماره‌دار برسد؛ فقط نشستِ همین ورود می‌ماند. - guest.tokens_valid_after = timezone.now() - guest.save(update_fields=['phone', 'tokens_valid_after']) - Session.objects.filter(user=guest, revoked=False).update(revoked=True) - user, created = guest, True - elif user is None: - user, created = User.objects.get_or_create( - phone=phone, - defaults={'is_guest': False, 'name': ''}, - ) - if user.is_guest: - user.is_guest = False - user.save(update_fields=['is_guest']) + user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''}) # ثبتِ فعالیتِ کاربر برای ورودِ موفق. user.last_activity_at = timezone.now() @@ -336,7 +291,6 @@ class LoginView(APIView): Session.objects.create( user=user, refresh_token=hash_refresh_token(refresh_token), - device_id=serializer.validated_data.get('deviceId', ''), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) access_token = issue_access_token(user.id) @@ -356,27 +310,6 @@ def _count_failed_attempt(phone: str) -> None: active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True) -def _upgradable_guest(request, phone: str): - """ - مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد. - - شماره‌های TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS عمداً کنار گذاشته می‌شوند: - هر دو حسابِ نمایشیِ مشترک‌اند و ورود نباید یکی را به دیگری یا به شماره‌ی یک نفر تبدیل کند. - """ - if phone in settings.TEST_LOGIN_PHONES: - return None - user = getattr(request, 'user', None) - if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest: - return None - # مهمانِ نمایشیِ عمومی (DEMO_GUEST_DEVICE_IDS) هم مالِ یک نفر نیست: بدونِ این، هر کس از - # نشستِ آن با شماره‌ی خودش وارد می‌شد کلِ حساب و داده‌ی دمو را به شماره‌ی خودش می‌برد و - # نشستِ بقیه را باطل می‌کرد. آن کاربر حسابِ تازه‌ی خودش را می‌گیرد. - if user.device_id in settings.DEMO_GUEST_DEVICE_IDS: - return None - return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first() - - -DELETE_CONFIRM_WORD = 'حذف' # سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند). # Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این # کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد. @@ -394,9 +327,8 @@ class DeleteAccountView(APIView): - کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس). توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید بتواند حساب را برای همیشه پاک کند. - - مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است. - داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند. - - حساب‌های دمو (TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. + - حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. - بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده. """ @@ -417,21 +349,13 @@ class DeleteAccountView(APIView): v = serializer.validated_data user = request.user - if (user.phone and user.phone in settings.TEST_LOGIN_PHONES) or ( - user.is_guest and user.device_id in settings.DEMO_GUEST_DEVICE_IDS): + if user.phone in settings.TEST_LOGIN_PHONES: return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN) - if user.phone: - if not v.get('otp'): - return Response({'detail': 'برای حذفِ حساب، کدی را که به شماره‌ات پیامک شد وارد کن.'}, - status=status.HTTP_400_BAD_REQUEST) - error = verify_phone_otp(user.phone, v['otp']) - if error is not None: - return error - elif (v.get('confirm') or '').strip() != DELETE_CONFIRM_WORD: - return Response({'detail': f'برای حذفِ حساب کلمه‌ی «{DELETE_CONFIRM_WORD}» را بنویس.'}, - status=status.HTTP_400_BAD_REQUEST) + error = verify_phone_otp(user.phone, v['otp']) + if error is not None: + return error - logger.info('Account deleted by its owner: user_id=%s guest=%s', user.id, user.is_guest) + logger.info('Account deleted by its owner: user_id=%s', user.id) # کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود. delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False) return Response(DeleteAccountResponseSerializer({'success': True}).data) @@ -510,7 +434,6 @@ class RefreshTokenView(APIView): new_session = Session.objects.create( user_id=old_session.user_id, refresh_token=hash_refresh_token(new_refresh_token), - device_id=old_session.device_id, expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) @@ -527,11 +450,10 @@ class SetUserGradeView(APIView): """POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)""" permission_classes = [IsAuthenticated] - # بدونِ throttle، یک کاربرِ احرازهویت‌شده (از جمله مهمان) می‌تواند - # نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. کلیدِ شمارش برای مهمان‌ها - # باید IP باشد نه pk — دلیلش در GuestAwareAccountThrottle بالا. + # بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تواند + # نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. throttle_scope = 'user_profile_write' - throttle_classes = [GuestAwareAccountThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=SetGradeRequestSerializer, @@ -560,7 +482,6 @@ def _profile_payload(user, profile) -> dict: 'phone': user.phone, 'name': user.name, 'avatar': user.avatar, - 'isGuest': user.is_guest, 'gradeId': user.grade_id, 'birthYear': profile.birth_year, 'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None, @@ -581,7 +502,7 @@ class UserProfileView(APIView): permission_classes = [IsAuthenticated] # نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد. throttle_scope = 'user_profile_write' - throttle_classes = [GuestAwareAccountThrottle] + throttle_classes = [ScopedRateThrottle] def get_throttles(self): # GET خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند. diff --git a/affiliate/views.py b/affiliate/views.py index 11e07d2..38c5ba2 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -15,42 +15,6 @@ from .serializers import ( ) -class GuestAwareAffiliateThrottle(ScopedRateThrottle): - """ - همان الگویِ GuestAwareDiscountThrottle - و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ - سادهٔ per-user رو دور می‌زد. اینجا حساس‌تره چون referralCode یک - شناسه‌ی حدس‌زدنی‌ست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست - یک Conversion واقعی می‌سازه که به کمیسیونِ مالیِ affiliate صاحبِ کد - منجر می‌شه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم - guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال - می‌داد. برای کاربرهای مهمان کلیدِ cache از pk به IP کلاینت تغییر - می‌کنه؛ رفتارِ throttle برای کاربرهای غیرمهمان (per-user، مثلِ - قبل) دست‌نخورده می‌مونه. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - -def _reject_guest(user): - """ - مهمان نه کدِ معرف می‌گیرد نه معرفی‌شده حساب می‌شود: حسابِ مهمان با هر - deviceId تازه ساخته می‌شود، پس شمردنش فقط آمار را باد می‌کرد. بعد از ورود - با شماره، همان کاربر (با همان پیشرفت) می‌تواند کد را ثبت کند. - """ - if getattr(user, 'is_guest', False): - raise PermissionDenied('برنامه‌ی معرفی بعد از ورود با شماره‌ی موبایل فعال می‌شود.') - - class AffiliateMeView(APIView): """GET /api/affiliate/me — حساب و کد ارجاع کاربر""" @@ -58,7 +22,6 @@ class AffiliateMeView(APIView): @extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)']) def get(self, request): - _reject_guest(request.user) account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id) paid = account.conversions.filter(status='paid') data = { @@ -88,10 +51,9 @@ class TrackConversionView(APIView): permission_classes = [IsAuthenticated] # referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle - # مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ - # bucket تازه از چرخشِ حساب مهمان رو می‌گیره. + # مجزا probe رو کند می‌کنه. throttle_scope = 'affiliate_track' - throttle_classes = [GuestAwareAffiliateThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)'] @@ -100,7 +62,6 @@ class TrackConversionView(APIView): serializer = TrackRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data - _reject_guest(request.user) account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode']) referred_user_id = request.user.id diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py index 23b01f9..7aa1943 100644 --- a/ai_tutor/tests.py +++ b/ai_tutor/tests.py @@ -29,7 +29,7 @@ class AskIdempotencyFollowUpsTests(TestCase): # بینِ تست‌ها پاک نمی‌شه) می‌تونه از یک تستِ قبلی باقی بمونه. cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000021', name='') self.token = issue_access_token(self.user.id) def _ask(self, idempotency_key): @@ -83,7 +83,7 @@ class AskAIGenerationFailureTests(TestCase): # ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000022', name='') self.token = issue_access_token(self.user.id) def _ask(self, idempotency_key=None): @@ -177,7 +177,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase): # ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000023', name='') self.token = issue_access_token(self.user.id) def test_losing_request_cleans_up_and_returns_winners_result(self): @@ -254,9 +254,9 @@ class AskAIConversationApiContractTests(TestCase): # ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000024', name='') self.token = issue_access_token(self.user.id) - self.other_user = User.objects.create(device_id='device-contract-2', is_guest=True, name='') + self.other_user = User.objects.create(phone='09000000025', name='') self.other_token = issue_access_token(self.other_user.id) def test_no_conversation_id_creates_new_conversation(self): @@ -348,7 +348,7 @@ class GenerationBoundaryContextTests(TestCase): # فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای # RAG/Qdrantِ آینده‌ست، نه تاریخچه. client = APIClient() - user = User.objects.create(device_id='device-boundary-grade-1', is_guest=True, name='') + user = User.objects.create(phone='09000000026', name='') token = issue_access_token(user.id) with patch( @@ -376,7 +376,7 @@ class GenerationBoundaryContextTests(TestCase): # پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال # مجازن). client = APIClient() - user = User.objects.create(device_id='device-boundary-1', is_guest=True, name='') + user = User.objects.create(phone='09000000027', name='') token = issue_access_token(user.id) conversation = Conversation.objects.create(user_id=user.id) @@ -432,7 +432,7 @@ class AskAIViewThrottleTests(TestCase): def setUp(self): self.client = APIClient() cache.clear() - self.user = User.objects.create(device_id='device-ai-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000028', name='') self.token = issue_access_token(self.user.id) def _ask(self, idempotency_key): @@ -456,18 +456,8 @@ class AskAIViewThrottleTests(TestCase): self.assertEqual(throttled_response.status_code, 429) -class AskAIViewGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='ai_ask' برای هر - کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک - مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket - throttle تازه برای AskAIView بگیره. حالا GuestAwareAIThrottle برای - کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session - هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه - نمی‌ده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) - دست‌نخورده مونده. - """ +class AskAIViewPerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() @@ -482,35 +472,17 @@ class AskAIViewGuestThrottleBypassTests(TestCase): HTTP_IDEMPOTENCY_KEY=idempotency_key, ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '2/hour'}): - guest1 = User.objects.create(device_id='device-guest-rot-1', is_guest=True, name='') - response1 = self._ask(issue_access_token(guest1.id), 'guest-rot-key-1') - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-guest-rot-2', is_guest=True, name='') - response2 = self._ask(issue_access_token(guest2.id), 'guest-rot-key-2') - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. - guest3 = User.objects.create(device_id='device-guest-rot-3', is_guest=True, name='') - response3 = self._ask(issue_access_token(guest3.id), 'guest-rot-key-3') - - self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000101') + user_a = User.objects.create(name='', phone='+989120000101') response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1') self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000102') + user_b = User.objects.create(name='', phone='+989120000102') response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1') self.assertEqual(response_b.status_code, 200) @@ -522,7 +494,7 @@ class StubNeverReachesProductionTests(TestCase): @override_settings(DEBUG=False, OPENAI_API_KEY='', QDRANT_API_KEY='', RAG_FORCE_MOCK=False) def test_missing_keys_are_a_503_and_cost_nothing(self): cache.clear() - user = User.objects.create(device_id='device-no-keys', is_guest=True, name='') + user = User.objects.create(phone='09000000029', name='') response = APIClient().post( '/api/ai/ask', {'userQuestion': '۲+۲ چند است؟'}, format='json', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}', @@ -539,7 +511,7 @@ class GlobalDailyCapTests(TestCase): cache.clear() codes = [] for i in range(3): - user = User.objects.create(device_id=f'cap-{i}', is_guest=True, name='') + user = User.objects.create(phone=f'0900{i:07d}', name='') codes.append(APIClient().post( '/api/ai/ask', {'userQuestion': f'۲+{i} چند است؟'}, format='json', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}', diff --git a/ai_tutor/tests_credit_concurrency.py b/ai_tutor/tests_credit_concurrency.py index c785159..874ee94 100644 --- a/ai_tutor/tests_credit_concurrency.py +++ b/ai_tutor/tests_credit_concurrency.py @@ -32,7 +32,7 @@ def _slow_answer(*args, **kwargs): class ConcurrentCreditTests(TransactionTestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007201', is_guest=False) + self.user = User.objects.create(phone='09120007201') def tearDown(self): cache.clear() diff --git a/ai_tutor/tests_quota.py b/ai_tutor/tests_quota.py index 617692a..802e8cc 100644 --- a/ai_tutor/tests_quota.py +++ b/ai_tutor/tests_quota.py @@ -19,7 +19,7 @@ from subscription.services import ai_daily_quota class DailyQuotaTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120006001', is_guest=False) + self.user = User.objects.create(phone='09120006001') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') diff --git a/ai_tutor/tests_security_review.py b/ai_tutor/tests_security_review.py index d9af357..6157595 100644 --- a/ai_tutor/tests_security_review.py +++ b/ai_tutor/tests_security_review.py @@ -60,7 +60,7 @@ class _Base(TestCase): def setUp(self): cache.clear() reset_clients_cache() - self.user = User.objects.create(phone='09120009901', is_guest=False) + self.user = User.objects.create(phone='09120009901') self.c = APIClient() self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -97,7 +97,7 @@ class LateOffTopicMarkerTests(_Base): q = 'سوال درباره‌ی چیزی' with patch('rag.services.clients.get_openai_client', return_value=fake): streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk') - other = User.objects.create(phone='09120009902', is_guest=False) + other = User.objects.create(phone='09120009902') c2 = APIClient() c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}') second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json') diff --git a/ai_tutor/views.py b/ai_tutor/views.py index a1192af..1a88960 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -104,39 +104,6 @@ def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool } -class GuestAwareAIThrottle(ScopedRateThrottle): - """ - ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده - (مهمان یا نه) از request.user.pk به‌عنوانِ کلیدِ throttle استفاده - می‌کنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ - تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های - مهمان، برای هر حساب یک bucket مستقلِ throttle (و همین‌طور کوتای - روزانه‌ی AskAIView که هم بر اساسِ user_id شمارش می‌شه) بگیره — یعنی - نرخِ واقعیِ استفاده از این endpoint گرون‌قیمت به‌جایِ محدود به نرخِ - ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session) - محدود می‌شد. - - این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از - pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده - می‌کنه) تغییر می‌ده؛ یعنی همه‌ی حساب‌های مهمانِ ساخته‌شده از یک IP - مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوض‌کردنِ حساب - نمی‌شه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان - (ثبت‌نام‌شده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی - می‌مونه؛ منطقِ سقفِ روزانه (daily_quota) و بقیه‌ی view دست‌نخورده‌ست. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - def _is_db_ref_id(value) -> bool: """ ⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API به‌صورتِ @@ -295,7 +262,7 @@ class AskAIView(APIView): # daily_quota فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛ # این throttle_scope مجزا اون burst رو محدود می‌کنه. throttle_scope = 'ai_ask' - throttle_classes = [GuestAwareAIThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=AskRequestSerializer, diff --git a/campaigns/tests.py b/campaigns/tests.py index f743bf2..f6ff1ea 100644 --- a/campaigns/tests.py +++ b/campaigns/tests.py @@ -52,8 +52,8 @@ class ExecuteCampaignIdempotencyTests(TestCase): """ def setUp(self): - self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001') - self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002') + self.user1 = User.objects.create(name='u1', phone='0910000001') + self.user2 = User.objects.create(name='u2', phone='0910000002') def test_in_app_second_run_skips_already_sent_users(self): campaign = MessageCampaign.objects.create( @@ -187,7 +187,7 @@ class CampaignDeliveryConcurrencyTests(TestCase): """ def setUp(self): - self.user = User.objects.create(name='u', is_guest=False, phone='0910000003') + self.user = User.objects.create(name='u', phone='0910000003') self.campaign = MessageCampaign.objects.create( title='t', body='b', channel='in_app', target_filter={'type': 'all'}, ) @@ -261,7 +261,7 @@ class ExecuteCampaignRealConcurrencyTests(TransactionTestCase): """ def setUp(self): - self.user = User.objects.create(name='u', is_guest=False, phone='0910000004') + self.user = User.objects.create(name='u', phone='0910000004') self.campaign = MessageCampaign.objects.create( title='t', body='b', channel='in_app', target_filter={'type': 'all'}, ) @@ -306,7 +306,7 @@ class InAppDeliveryConsistencyTests(TestCase): """ def setUp(self): - self.user = User.objects.create(name='u', is_guest=False, phone='0910000005') + self.user = User.objects.create(name='u', phone='0910000005') self.campaign = MessageCampaign.objects.create( title='t', body='b', channel='in_app', target_filter={'type': 'all'}, ) @@ -437,7 +437,7 @@ class CampaignDeliveryAdminReadOnlyTests(TestCase): username='admin3', email='admin3@example.com', password='pass12345', ) self.client.force_login(self.staff_user) - self.user = User.objects.create(name='u', is_guest=False, phone='0910000009') + self.user = User.objects.create(name='u', phone='0910000009') self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms') self.delivery = CampaignDelivery.objects.create( campaign=self.campaign, user_id=self.user.id, channel='sms', diff --git a/config/settings.py b/config/settings.py index d12e4aa..15dfcd6 100644 --- a/config/settings.py +++ b/config/settings.py @@ -462,7 +462,7 @@ REST_FRAMEWORK = { ], # بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP # کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket - # تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این + # تازه از throttleهای IP-محور بگیره. این # مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً # Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه. # @@ -579,7 +579,7 @@ SPECTACULAR_SETTINGS = { '\n' '## قراردادها\n' '\n' - '- نامِ فیلدها `camelCase` است (`deviceId`, `refreshToken`, `nodeId`).\n' + '- نامِ فیلدها `camelCase` است (`refreshToken`, `nodeId`).\n' '- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n' '- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n' '- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — ' @@ -724,11 +724,6 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]: TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', '')) -# مهمان‌های نمایشی که با حذفِ حساب پاک نمی‌شوند (همان کاری که TEST_LOGIN_PHONES برای شماره‌ها می‌کند). -# پیش‌فرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسه‌اش در ریپو و مستندات آمده و هر کسی -# می‌تواند با آن وارد شود. با پاک شدنِ داده‌ی دمو پیش از کاربرِ واقعی، این پیش‌فرض هم برداشته شود. -DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device'] - # ----------------------------------------------------------------------- # OTP SMS delivery (accounts/services.py: send_otp_sms) # ----------------------------------------------------------------------- diff --git a/config/tests.py b/config/tests.py index fcf91f2..3d36712 100644 --- a/config/tests.py +++ b/config/tests.py @@ -171,7 +171,7 @@ class APIRequestLoggingMiddlewareTests(TestCase): self.client = APIClient() def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self): - user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='') + user = User.objects.create(phone='09000000005', name='') token = issue_access_token(user.id) from content.models import Grade diff --git a/config/tests_audit_round2.py b/config/tests_audit_round2.py index 3a1592d..c10116b 100644 --- a/config/tests_audit_round2.py +++ b/config/tests_audit_round2.py @@ -30,7 +30,7 @@ def _client(user): class _Base(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120008001', is_guest=False) + self.user = User.objects.create(phone='09120008001') self.client = _client(self.user) self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365) @@ -57,18 +57,6 @@ class OfferNeverShortensSubscription(_Base): self.assertEqual(response.status_code, 200, response.content) self.assertEqual(Subscription.objects.get(user_id=self.user.id).expires_at, before) - @override_settings(DEV_MASTER_OTP='000000') - def test_guest_who_paid_keeps_the_year_after_registering(self): - Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, - auto_assign_on_signup=True) - guest = User.objects.create(device_id='audit-2-device', is_guest=True) - guest_client = _client(guest) - self.pay(self.buy(guest_client), ref='R2') - response = guest_client.post('/api/auth/login', {'phone': '09125550001', 'otp': '000000'}, - format='json') - self.assertEqual(response.status_code, 200, response.content) - days = (Subscription.objects.get(user_id=guest.id).expires_at - timezone.now()).days - self.assertGreater(days, 360) def test_trial_still_works_for_a_user_without_subscription(self): Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK') diff --git a/content/tests.py b/content/tests.py index e3b8b48..55e6aab 100644 --- a/content/tests.py +++ b/content/tests.py @@ -20,7 +20,7 @@ class LessonCompleteImpossibleCountTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-complete-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000011', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') @@ -79,7 +79,7 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-complete-2', is_guest=True, name='') + self.user = User.objects.create(phone='09000000012', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه دوم', icon='icon', color='#fff') @@ -130,7 +130,7 @@ class QuestionAnswerThrottleTests(TestCase): from django.core.cache import cache cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-answer-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000013', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') @@ -173,13 +173,8 @@ class QuestionAnswerThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ - # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به - # QuestionAnswerGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین - # می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000701') + # کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد. + registered_user = User.objects.create(name='', phone='+989120000701') registered_token = issue_access_token(registered_user.id) def _answer_as(remote_addr): @@ -253,7 +248,7 @@ class LessonCompleteThrottleTests(TestCase): from django.core.cache import cache cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-complete-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000014', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') @@ -294,13 +289,8 @@ class LessonCompleteThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که - # مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ - # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به - # LessonCompleteGuestThrottleBypassTests پایین‌تر) — این تست فقط - # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین - # می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702') + # کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد. + registered_user = User.objects.create(name='', phone='+989120000702') registered_token = issue_access_token(registered_user.id) def _complete_as(remote_addr): @@ -358,10 +348,8 @@ class LessonCompleteThrottleTests(TestCase): self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at']) -class QuestionAnswerGuestThrottleBypassTests(TestCase): - """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید - bucket تازه‌ی question_answer بده — GuestAwareContentThrottle برای - مهمان‌ها کلید رو بر اساسِ IP می‌سازه، نه user.pk.""" +class QuestionAnswerPerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): from django.core.cache import cache @@ -387,58 +375,23 @@ class QuestionAnswerGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '2/min'}): - guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='') - response1 = self._answer(issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='') - response2 = self._answer(issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. - guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='') - response3 = self._answer(issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000711') + user_a = User.objects.create(name='', phone='+989120000711') response_a = self._answer(issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000712') + user_b = User.objects.create(name='', phone='+989120000712') response_b = self._answer(issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) - def test_blocked_guest_request_creates_no_answer_or_hearts_change(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه - # یا هارت/progress رو تغییر بده. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}): - guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='') - first_response = self._answer(issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 200) - guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='') - blocked_response = self._answer(issue_access_token(guest2.id)) - - self.assertEqual(blocked_response.status_code, 429) - self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0) - self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists()) - - -class LessonCompleteGuestThrottleBypassTests(TestCase): - """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازه‌ی - lesson_complete بده — GuestAwareContentThrottle برای مهمان‌ها کلید رو - بر اساسِ IP می‌سازه، نه user.pk.""" +class LessonCompletePerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): from django.core.cache import cache @@ -462,54 +415,20 @@ class LessonCompleteGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '2/hour'}): - guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='') - response1 = self._complete(issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='') - response2 = self._complete(issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. - guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='') - response3 = self._complete(issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000721') + user_a = User.objects.create(name='', phone='+989120000721') response_a = self._complete(issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000722') + user_b = User.objects.create(name='', phone='+989120000722') response_b = self._complete(issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) - def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion - # بسازه یا UserStats/NodeProgress رو تغییر بده. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}): - guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='') - first_response = self._complete(issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 200) - - guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='') - blocked_response = self._complete(issue_access_token(guest2.id)) - - self.assertEqual(blocked_response.status_code, 429) - self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0) - self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists()) - self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists()) - class QuestionAnswerMascotMoodTests(TestCase): """پاسخِ /answer باید mascotMood بده: درست → happy، غلط → encourage @@ -519,7 +438,7 @@ class QuestionAnswerMascotMoodTests(TestCase): from django.core.cache import cache cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-mascot-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000015', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه اول', icon='i', color='#fff') unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) @@ -574,7 +493,7 @@ class HomeViewScalabilityTests(TestCase): def setUp(self): _django_cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-home-scale', is_guest=True, name='') + self.user = User.objects.create(phone='09000000016', name='') self.token = issue_access_token(self.user.id) def _grade_with_nodes(self, n_nodes): @@ -661,7 +580,7 @@ class UnlimitedHeartsSubscriptionTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='dev-unlimited-hearts', is_guest=True, name='') + self.user = User.objects.create(phone='09000000017', name='') self.token = issue_access_token(self.user.id) grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') @@ -717,7 +636,7 @@ class ReadThrottleTests(TestCase): def setUp(self): _django_cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-read-throttle', is_guest=True, name='') + self.user = User.objects.create(phone='09000000018', name='') self.token = issue_access_token(self.user.id) self.grade = Grade.objects.create(title='g', icon='i', color='#fff') @@ -742,7 +661,7 @@ class HomeGradeFallbackTests(TestCase): def setUp(self): _django_cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-home-fallback', is_guest=True, name='') + self.user = User.objects.create(phone='09000000019', name='') self.token = issue_access_token(self.user.id) self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800') @@ -805,7 +724,7 @@ class NodeProgressUnitParamTests(TestCase): from accounts.authentication import issue_access_token from accounts.models import User - user = User.objects.create(device_id='unit-param', is_guest=True) + user = User.objects.create(phone='09000000020') response = APIClient().get('/api/progress/nodes?unit=abc', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') self.assertEqual(response.status_code, 400) diff --git a/content/tests_flow.py b/content/tests_flow.py index 0719699..551565c 100644 --- a/content/tests_flow.py +++ b/content/tests_flow.py @@ -13,7 +13,7 @@ from django.test import TestCase from rest_framework.test import APIClient from accounts.models import User -from accounts.testing import guest_session +from accounts.authentication import issue_access_token from content.models import Choice, Grade, Lesson, Node, Question, Unit @@ -46,8 +46,8 @@ class BasicLearningFlowTests(TestCase): self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self): - # ۱. حسابِ مهمانِ قدیمی (ورودِ مهمان حذف شده؛ این ردیف‌ها هنوز هستند). - session = guest_session('flow-device-1') + # ۱. کاربرِ ثبت‌نام‌شده با شماره (توکنِ ورود). + session = {'token': issue_access_token(User.objects.create(phone='09000000101', name='').id)} self._auth(session['token']) # ۲. صفحه‌ی «بیا باهم آشنا بشیم»: اسم + پایه. @@ -110,7 +110,7 @@ class BasicLearningFlowTests(TestCase): def test_completing_twice_with_the_same_key_does_not_double_count(self): from content.models import UserLessonCompletion - session = guest_session('flow-device-2') + session = {'token': issue_access_token(User.objects.create(phone='09000000102', name='').id)} self._auth(session['token']) self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') self.client.post( @@ -144,7 +144,7 @@ class BasicLearningFlowTests(TestCase): به‌روز می‌شود. بدونِ دانستنِ این، توسعه‌دهنده‌ی فرانت دنبالِ باگی می‌گردد که وجود ندارد. """ - session = guest_session('flow-device-4') + session = {'token': issue_access_token(User.objects.create(phone='09000000104', name='').id)} self._auth(session['token']) self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') @@ -162,7 +162,7 @@ class BasicLearningFlowTests(TestCase): self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0) def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self): - session = guest_session('flow-device-3') + session = {'token': issue_access_token(User.objects.create(phone='09000000103', name='').id)} self._auth(session['token']) home = self.client.get('/api/home') @@ -191,7 +191,7 @@ class LessonWithoutQuestionsTests(TestCase): ) Choice.objects.create(question=question, value='الف', is_correct=True) - user = User.objects.create(device_id='empty-lesson', is_guest=True, grade_id=grade.id) + user = User.objects.create(phone='09000000008', grade_id=grade.id) from accounts.authentication import issue_access_token self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') @@ -315,7 +315,7 @@ class LessonCompletionNotificationTests(TestCase): self.correct = Choice.objects.create(question=question, value='الف', is_correct=True) self.question = question - self.user = User.objects.create(device_id='notif-on-complete', is_guest=True, grade_id=grade.id) + self.user = User.objects.create(phone='09000000009', grade_id=grade.id) from accounts.authentication import issue_access_token self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') diff --git a/content/tests_frontend_contract.py b/content/tests_frontend_contract.py index 7f04ae6..55ce741 100644 --- a/content/tests_frontend_contract.py +++ b/content/tests_frontend_contract.py @@ -67,7 +67,7 @@ class SkillMapTests(TestCase): self.assertEqual(rows['ویدیوی توان']['kind'], 'video') self.assertEqual(rows['ویدیوی توان']['meta'], '12 دقیقه') - def test_done_is_false_for_a_guest_and_true_after_completion(self): + def test_done_is_false_when_signed_out_and_true_after_completion(self): self.assertFalse(any(r['done'] for r in self._lessons()['lessons'])) UserLessonCompletion.objects.create(user_id=self.user.id, lesson=self.quiz, diff --git a/content/tests_hearts_gate.py b/content/tests_hearts_gate.py index 8866eba..b4ffe61 100644 --- a/content/tests_hearts_gate.py +++ b/content/tests_hearts_gate.py @@ -32,7 +32,7 @@ def _client(user): class _World: def build(self, phone): cache.clear() - self.user = User.objects.create(phone=phone, is_guest=False) + self.user = User.objects.create(phone=phone) self.client = _client(self.user) grade = Grade.objects.create(title='پایه نهم', icon='', color='') self.node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1), diff --git a/content/tests_security_review.py b/content/tests_security_review.py index 2b8154d..e8850db 100644 --- a/content/tests_security_review.py +++ b/content/tests_security_review.py @@ -33,7 +33,7 @@ class SupervisorCannotEditContentTests(TestCase): self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?') ok = Choice.objects.create(question=self.q, value='1', is_correct=True) Choice.objects.create(question=self.q, value='2', is_correct=False) - self.student = User.objects.create(phone='09120009999', is_guest=False) + self.student = User.objects.create(phone='09120009999') UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True) def test_question_import_with_replace_is_refused(self): @@ -64,7 +64,7 @@ class NonAsciiDigitsAre400Tests(TestCase): def setUp(self): cache.clear() - user = User.objects.create(device_id='digits', is_guest=True) + user = User.objects.create(phone='09000000010') self.app = APIClient() self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') @@ -97,7 +97,7 @@ class JunkGradeIdsDoNotFlushCacheTests(TestCase): patcher = mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'read': '100000/min'}) patcher.start() self.addCleanup(patcher.stop) - user = User.objects.create(phone='09120009911', is_guest=False) + user = User.objects.create(phone='09120009911') self.app = APIClient() self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') @@ -122,7 +122,7 @@ class ProgressNodesInputAndQueriesTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120009912', is_guest=False) + self.user = User.objects.create(phone='09120009912') self.app = APIClient() self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') diff --git a/content/views.py b/content/views.py index 410611e..a94438a 100644 --- a/content/views.py +++ b/content/views.py @@ -49,27 +49,6 @@ from .serializers import ( logger = logging.getLogger(__name__) -class GuestAwareContentThrottle(ScopedRateThrottle): - """ - مشترک با GuestAwareAIThrottle/GuestAwareDiscountThrottle - و مشابه‌هاش: چون GuestSessionView با هر deviceId جدید یک pk تازه می‌سازه، - throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده می‌شه — - درحالی‌که answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن. - برای همین فقط کاربرهای مهمان با IP کلید می‌خورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - # --- کشِ داده‌ی شبهِ‌ثابتِ برنامه‌ی درسی ------------------------------------- # گریدها و درختِ unit/node/lesson تقریباً هیچ‌وقت عوض نمی‌شوند ولی روی هر بار # باز شدنِ اپ خوانده می‌شوند. یک نسخه‌کلیدِ مشترک نگه می‌داریم که با هر تغییرِ @@ -172,7 +151,7 @@ class GradesListView(APIView): """GET /api/grades — لیست پایه‌ها""" throttle_scope = 'read' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) def get(self, request): @@ -260,7 +239,7 @@ class HomeView(APIView): # userId همیشه از request.user.id (توکن معتبر) گرفته می‌شود، نه ورودی کلاینت (IDOR). permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[ @@ -438,9 +417,9 @@ class QuestionAnswerView(APIView): permission_classes = [IsAuthenticated] # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های # *متفاوت* که هرکدوم UserAnswer جدید می‌سازن و هارت/NodeProgress رو تغییر می‌دن؛ - # برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). + # برای همین throttle لازمه. throttle_scope = 'question_answer' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=AnswerRequestSerializer, @@ -556,11 +535,11 @@ class LessonCompleteView(APIView): # می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه. permission_classes = [IsAuthenticated] # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که - # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ در + # throttle لازمه. نرخ در # settings (`lesson_complete`، الان ۱۲۰/hour) کمتر از question_answer است # چون این endpoint رکوردِ ماندگار می‌سازه. throttle_scope = 'lesson_complete' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=LessonCompleteRequestSerializer, @@ -823,7 +802,7 @@ class NodeQuizView(APIView): """ throttle_scope = 'read' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] DEFAULT_SIZE = 4 MAX_SIZE = 20 @@ -909,7 +888,7 @@ class NodeQuizCompleteView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'node_complete' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=NodeQuizCompleteRequestSerializer, @@ -1047,7 +1026,7 @@ class NodeLessonsView(APIView): """ throttle_scope = 'read' - throttle_classes = [GuestAwareContentThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=NodeLessonsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, node_id: int): diff --git a/discount/tests.py b/discount/tests.py index a7da18d..daebfbe 100644 --- a/discount/tests.py +++ b/discount/tests.py @@ -23,7 +23,7 @@ class ValidateDiscountPermissionTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-validate-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000038', name='') self.token = issue_access_token(self.user.id) self.discount = DiscountCode.objects.create( code='SUMMER10', type='percent', value=10, max_uses=5, expires_at=None, @@ -71,7 +71,7 @@ class RedeemOfferCodeThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-offer-redeem-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000039', name='') self.token = issue_access_token(self.user.id) def _redeem(self, code): @@ -103,7 +103,7 @@ class RedeemOfferCodeThrottleTests(TestCase): def test_throttle_is_scoped_per_user_not_shared_globally(self): # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). - other_user = User.objects.create(is_guest=False, name='', phone='+989120000701') + other_user = User.objects.create(name='', phone='+989120000701') other_token = issue_access_token(other_user.id) with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}): @@ -148,22 +148,8 @@ class RedeemOfferCodeThrottleTests(TestCase): mock_redeem.assert_not_called() -class RedeemOfferCodeGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='offer_redeem' برای هر - کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView با - هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه برای probe/brute-force کردنِ کدهای آفر بگیره. چون Offer.code - سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ *همون* کاربر رو - می‌گیره، این دورزدن می‌تونست به یک حسابِ مهمانِ چرخشی اجازه بده همون - آفرِ فعال رو دوباره از apply_offer() بگیره و اشتراکِ رایگان/آزمایشیِ - مربوطه رو دوباره اعمال کنه. حالا GuestAwareDiscountThrottle برای - کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session هم - استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. - رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده - مونده. - """ +class RedeemOfferCodePerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() @@ -177,81 +163,21 @@ class RedeemOfferCodeGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '2/hour'}): - guest1 = User.objects.create(device_id='device-offer-guest-rot-1', is_guest=True, name='') - response1 = self._redeem('INVALID-ROT-1', issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 400) - - guest2 = User.objects.create(device_id='device-offer-guest-rot-2', is_guest=True, name='') - response2 = self._redeem('INVALID-ROT-2', issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 400) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی - # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های - # مهمانِ ساخته‌شده. - guest3 = User.objects.create(device_id='device-offer-guest-rot-3', is_guest=True, name='') - response3 = self._redeem('INVALID-ROT-3', issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) - - def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self): - # حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر - # (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP - # مشترک متصل است. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}): - guest1 = User.objects.create(device_id='device-offer-guest-rot-4', is_guest=True, name='') - response1 = self._redeem('INVALID-ROT-4', issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 400) - - guest2 = User.objects.create(device_id='device-offer-guest-rot-5', is_guest=True, name='') - blocked = self._redeem('INVALID-ROT-5', issue_access_token(guest2.id)) - self.assertEqual(blocked.status_code, 429) - - guest3 = User.objects.create(device_id='device-offer-guest-rot-6', is_guest=True, name='') - still_blocked = self._redeem('INVALID-ROT-6', issue_access_token(guest3.id)) - - self.assertEqual(still_blocked.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000801') + user_a = User.objects.create(name='', phone='+989120000801') response_a = self._redeem('INVALID-REG-A', issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 400) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000802') + user_b = User.objects.create(name='', phone='+989120000802') response_b = self._redeem('INVALID-REG-B', issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 400) - def test_blocked_guest_request_does_not_call_redeem_offer_code(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید redeem_offer_code() - # رو صدا بزنه — throttle باید *قبل* از رسیدن به منطقِ redemption/ - # apply_offer رد کنه، حتی با کدِ درست. - offer = Offer.objects.create( - name='Guest Rotation Offer', kind='free_full', code='GUESTROTBLOCK', is_active=True, - ) - - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}): - guest1 = User.objects.create(device_id='device-offer-guest-rot-7', is_guest=True, name='') - first_response = self._redeem('INVALID-ROT-7', issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 400) - - # حسابِ مهمانِ تازه‌ی دوم، حتی با کدِ درست، باید بلاک بشه - # (bucket مشترکِ IP از قبل مصرف شده) و نباید به - # redeem_offer_code() برسه. - guest2 = User.objects.create(device_id='device-offer-guest-rot-8', is_guest=True, name='') - with patch('discount.views.redeem_offer_code') as mock_redeem: - blocked_response = self._redeem(offer.code, issue_access_token(guest2.id)) - self.assertEqual(blocked_response.status_code, 429) - mock_redeem.assert_not_called() - class ValidateDiscountThrottleTests(TestCase): """ @@ -266,7 +192,7 @@ class ValidateDiscountThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-validate-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000040', name='') self.token = issue_access_token(self.user.id) self.discount = DiscountCode.objects.create( code='THROTTLE10', type='percent', value=10, max_uses=5, expires_at=None, @@ -295,7 +221,7 @@ class ValidateDiscountThrottleTests(TestCase): def test_throttle_is_scoped_per_user_not_shared_globally(self): # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). - other_user = User.objects.create(is_guest=False, name='', phone='+989120000731') + other_user = User.objects.create(name='', phone='+989120000731') other_token = issue_access_token(other_user.id) with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}): @@ -343,7 +269,7 @@ class RedeemDiscountThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-redeem-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000041', name='') self.token = issue_access_token(self.user.id) self.order = Order.objects.create( user_id=self.user.id, amount=10000, idempotency_key='order-redeem-throttle-1', @@ -378,7 +304,7 @@ class RedeemDiscountThrottleTests(TestCase): def test_throttle_is_scoped_per_user_not_shared_globally(self): # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره). - other_user = User.objects.create(is_guest=False, name='', phone='+989120000301') + other_user = User.objects.create(name='', phone='+989120000301') other_token = issue_access_token(other_user.id) other_order = Order.objects.create( user_id=other_user.id, amount=5000, idempotency_key='order-redeem-throttle-2', @@ -427,32 +353,13 @@ class RedeemDiscountThrottleTests(TestCase): self.assertEqual(self.order.amount, 10000) -class RedeemDiscountGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_redeem' برای - هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه برای probe/brute-force کردنِ کدهای تخفیف بگیره. چون DiscountCode - یک رکوردِ سراسری با max_uses مشترک است، این دورزدن صرفاً افشای - اطلاعات نبود؛ به مهاجم guessهای واقعیِ بیشتری روی یک کدِ سراسری - می‌داد که هرکدوم می‌تونست یک Redemption واقعی بسازه و مبلغِ سفارش رو - کم کنه. حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو - بر اساسِ IP (همون get_ident که guest_session هم استفاده می‌کنه) - می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle - برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. - """ +class RedeemDiscountPerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() self.client = APIClient() - def _make_guest_with_order(self, device_id, amount=10000): - guest = User.objects.create(device_id=device_id, is_guest=True, name='') - order = Order.objects.create( - user_id=guest.id, amount=amount, idempotency_key=f'order-{device_id}', - ) - return guest, order def _redeem(self, code, order_id, token): return self.client.post( @@ -462,58 +369,20 @@ class RedeemDiscountGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '2/hour'}): - guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-1') - response1 = self._redeem('PROBE-ROT-1', order1.id, issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 404) - - guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-2') - response2 = self._redeem('PROBE-ROT-2', order2.id, issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 404) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی - # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های - # مهمانِ ساخته‌شده. - guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-3') - response3 = self._redeem('PROBE-ROT-3', order3.id, issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) - - def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self): - # حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر - # (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP - # مشترک متصل است. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}): - guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-4') - response1 = self._redeem('PROBE-ROT-4', order1.id, issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 404) - - guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-5') - blocked = self._redeem('PROBE-ROT-5', order2.id, issue_access_token(guest2.id)) - self.assertEqual(blocked.status_code, 429) - - guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-6') - still_blocked = self._redeem('PROBE-ROT-6', order3.id, issue_access_token(guest3.id)) - - self.assertEqual(still_blocked.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000401') + user_a = User.objects.create(name='', phone='+989120000401') order_a = Order.objects.create( user_id=user_a.id, amount=10000, idempotency_key='order-redeem-reg-a', ) response_a = self._redeem('PROBE-REG-A', order_a.id, issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 404) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000402') + user_b = User.objects.create(name='', phone='+989120000402') order_b = Order.objects.create( user_id=user_b.id, amount=10000, idempotency_key='order-redeem-reg-b', ) @@ -521,42 +390,9 @@ class RedeemDiscountGuestThrottleBypassTests(TestCase): self.assertEqual(response_b.status_code, 404) - def test_blocked_guest_request_does_not_mutate_order_or_create_redemption(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید مبلغِ سفارش رو - # تغییر بده یا Redemption بسازه — throttle باید *قبل* از رسیدن به - # select_for_update()/منطقِ redemption رد کنه، حتی با کدِ درست. - discount = DiscountCode.objects.create( - code='REDEEMGUESTROTBLOCK', type='fixed', value=1000, max_uses=5, expires_at=None, - ) - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}): - guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-7') - first_response = self._redeem('PROBE-ROT-7', order1.id, issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 404) - - # حسابِ مهمانِ تازه‌ی دوم، حتی با کدِ درست روی سفارشِ خودش، باید - # بلاک بشه (bucket مشترکِ IP از قبل مصرف شده). - guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-8') - blocked_response = self._redeem(discount.code, order2.id, issue_access_token(guest2.id)) - - self.assertEqual(blocked_response.status_code, 429) - order2.refresh_from_db() - self.assertEqual(order2.amount, 10000) - self.assertEqual(discount.redemptions.count(), 0) - - -class ValidateDiscountGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_validate' برای - هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود، دقیقاً همون مشکلی که - RedeemDiscountGuestThrottleBypassTests برای discount_redeem اثبات کرده. - چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) - می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک - bucket throttle تازه برای probe کردنِ type/value کدهای تخفیف بگیره. - حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP - می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle - برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. - """ +class ValidateDiscountPerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() @@ -570,34 +406,16 @@ class ValidateDiscountGuestThrottleBypassTests(TestCase): '/api/discount/validate', {'code': code}, HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '2/hour'}): - guest1 = User.objects.create(device_id='device-validate-guest-rot-1', is_guest=True, name='') - response1 = self._validate(self.discount.code, issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-validate-guest-rot-2', is_guest=True, name='') - response2 = self._validate(self.discount.code, issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. - guest3 = User.objects.create(device_id='device-validate-guest-rot-3', is_guest=True, name='') - response3 = self._validate(self.discount.code, issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000741') + user_a = User.objects.create(name='', phone='+989120000741') response_a = self._validate(self.discount.code, issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000742') + user_b = User.objects.create(name='', phone='+989120000742') response_b = self._validate(self.discount.code, issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) diff --git a/discount/views.py b/discount/views.py index 96e7f30..4bc3c54 100644 --- a/discount/views.py +++ b/discount/views.py @@ -20,40 +20,6 @@ from .serializers import ( from .services import redeem_offer_code -class GuestAwareDiscountThrottle(ScopedRateThrottle): - """ - همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا هم هست. - ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از - request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون - GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) - می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان (تا سقفِ - guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope - 'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون - DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همه‌ی کاربرهاست؛ - یک حدسِ درست صرفاً اطلاعات لو نمی‌ده، بلکه یک Redemption واقعی - می‌سازه و مبلغِ سفارش رو کم می‌کنه — یعنی throttle دورزده‌شده به - مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری می‌ده. - - دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ - cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی - حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب - دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان - (ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ - lookup/redemption/order دست‌نخورده‌ست. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - def _is_usable(discount: DiscountCode) -> bool: # ⚠️ هر Redemption شمرده می‌شود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس # سبدِ رهاشده سهمِ max_uses را برای همیشه می‌سوزاند و خودِ همان کاربر هم @@ -73,11 +39,9 @@ class ValidateDiscountView(APIView): # این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک # کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از - # offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle - # مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای - # مهمان‌ها کلید رو بر اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده. + # offer_redeem چون این یک چکِ read-only پیش از خریده. throttle_scope = 'discount_validate' - throttle_classes = [GuestAwareDiscountThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)], @@ -112,10 +76,9 @@ class RedeemDiscountView(APIView): # همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ # درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش # رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه - # تراکنشه. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر اساسِ IP - # می‌سازه تا چرخشِ حساب bucket تازه نده. + # تراکنشه. throttle_scope = 'discount_redeem' - throttle_classes = [GuestAwareDiscountThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer, tags=['Discount (discount)']) @@ -209,11 +172,9 @@ class RedeemOfferCodeView(APIView): # بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون # Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون - # کاربر رو می‌گیره — یک حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو - # دوباره بگیره. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر - # اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده. + # کاربر رو می‌گیره. throttle_scope = 'offer_redeem' - throttle_classes = [GuestAwareDiscountThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer, diff --git a/docs/architecture.md b/docs/architecture.md index 69173a6..94cb63d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,11 +26,7 @@ **کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل می‌شود** (`OTPCode.failed_attempts`، شمارش در دیتابیس، نه در کشِ per-worker). -**مهمانی که با شماره وارد می‌شود همان کاربر می‌ماند.** اگر درخواستِ ورود توکنِ -مهمان را همراه دارد و آن شماره هنوز حسابی ندارد، به همان ردیفِ مهمان شماره داده -می‌شود؛ امتیاز، استریک، جزیره‌ها و گفتگوها سرِ جایشان می‌مانند. شماره‌ای که از قبل -حساب دارد ادغام نمی‌شود (حسابِ موجود باز می‌شود و مهمان دست‌نخورده می‌ماند)، و -`TEST_LOGIN_PHONES` هرگز مهمانی را جذب نمی‌کنند تا حساب‌های دمو عوض نشوند. +**ورود فقط با شماره‌ی موبایل و کدِ پیامکی است.** ورودِ مهمان وجود ندارد؛ شماره‌ی تازه حسابِ تازه می‌سازد. **نامِ نمایشی** در جدولِ رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود؛ شماره، لینک و آیدی هنگامِ ذخیره از آن حذف می‌شود (`accounts.serializers.clean_display_name`). @@ -53,13 +49,13 @@ اندپوینتِ جدیدِ حساس یعنی **هر دو**: یک `throttle_scope` روی ویو و یک ردیفِ متناظر در تنظیمات. -کاربرانِ مهمان با IP محدود می‌شوند. `NUM_PROXIES` تعیین می‌کند چند hop از +درخواست‌های بی‌ورود (مثلاً درخواستِ کدِ ورود) با IP محدود می‌شوند. `NUM_PROXIES` تعیین می‌کند چند hop از `X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود. روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر -می‌دید؛ یعنی همه‌ی مهمان‌ها یک سهمِ مشترک داشتند. `clientIp` در `/api/health` +می‌دید؛ یعنی همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک داشتند. `clientIp` در `/api/health` نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد. ⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را @@ -105,7 +101,7 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵) (`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت): `commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` می‌شود. -مهمان نه کد می‌گیرد نه معرفی‌شده حساب می‌شود (۴۰۳). تسویه‌ی پورسانت با معرف هنوز +تسویه‌ی پورسانت با معرف هنوز دستی و از پنلِ ادمین است. ## دستیارِ هوشِ مصنوعی (RAG) diff --git a/docs/operations.md b/docs/operations.md index 278580e..b8eb0fe 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -111,7 +111,6 @@ curl https://mathapi.tadrisino2.ir/api/health | `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | | `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته می‌شود نه پایانِ درس/جزیره‌ی بی‌جواب. `0` خاموشش می‌کند | | `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند (در DEBUG هم؛ پیش‌فرض ندارد) | -| `DEMO_GUEST_DEVICE_IDS` | فقط `frontend-test-device` با «حذفِ حساب» پاک نمی‌شود. با پاک شدنِ داده‌ی دمو، پیش‌فرض هم از `config/settings.py` برداشته شود | **اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` به‌عنوانِ @@ -250,12 +249,9 @@ curl -X POST https://mathapi.tadrisino2.ir/api/auth/login \ - کدِ تصادفی بگذارید، نه `1234`، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی خالی‌اش کنید. -### حسابِ مهمانِ تستِ فرانت +### حسابِ نمایشی -ورودِ مهمان (`POST /api/auth/session`) حذف شده و دیگر با `frontend-test-device` توکن نمی‌دهد. ردیفِ -کاربرِ شماره‌ی ۶ («حساب تست فرانت»، `is_guest=True`) با ۲۴ ردیفِ `Notification` و ۲۰ ردیفِ -`LeaderboardEntry` هنوز روی پروداکشن است؛ برای دیدنِ آن صفحه‌ها با حسابِ دموی شماره‌دار -(`TEST_LOGIN_PHONES`) وارد شوید. +حسابِ «علی رضایی» (کاربرِ ۱۰) پُر از داده است و با شماره‌ی مالکِ پروژه وصل می‌شود؛ بقیه‌ی دموها با `TEST_LOGIN_PHONES` وارد می‌شوند. ⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلان‌ها و لیدربردِ ساختگی را diff --git a/docs/security-before-production.md b/docs/security-before-production.md index a41cb00..5c891fc 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -23,21 +23,19 @@ |---|---|---|---| | ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور | | ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور | -| ۳ | داده‌ی دمو و حسابِ مهمانِ نمایشیِ `frontend-test-device` | بالا | دیتابیسِ سرور | +| ۳ | داده‌ی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور | | ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | | ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | -| ۱۰ | حساب‌های مهمانِ قدیمی (دمو و کاربرانِ پیش از حذفِ ورودِ مهمان) هنوز در دیتابیس‌اند | کم | دیتابیسِ سرور | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | -| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | +| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | -| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی | | ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی | | ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی | | ۲۶ | پایانِ درس و جزیره به گفته‌ی کلاینت است (جزیره‌ی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه | @@ -78,22 +76,14 @@ - **رفع:** `TEST_LOGIN_PHONES` را از `.env` سرور پاک کنید و ری‌استارت کنید. - **چطور بسنجید:** `/api/health` دیگر هشدارِ «شماره‌های تستِ ورود با کدِ ثابت فعال‌اند» ندارد. -## ۳. داده‌ی دمو و مهمانِ عمومی +## ۳. داده‌ی دمو روی دیتابیسِ پروداکشن -- **افزوده ۲۰۲۶-۰۹-۲۵:** کاربرِ ۱۷ (`09000000001`، «کاربر تست»، `deviceId = - deploy-check-20260925`) از تستِ دیپلویِ پیامک، و کاربرِ ۱۸ (مهمان، `deploy-check-ai-20260925`) - از تستِ دستیار؛ همراهِ بقیه پاک شوند. -- **چیست:** کاربرهای ۶ (`deviceId = frontend-test-device`) و ۱۰ تا ۱۵، با درس، گفتگو، - تیکت، خانواده و زیرمجموعه، روی دیتابیسِ پروداکشن ساخته شده‌اند. `frontend-test-device` - در ریپو نوشته شده. `POST /api/auth/session` حذف شده، پس با آن دیگر توکن نمی‌دهد؛ ولی خودِ - ردیف‌ها (با `is_guest=True`) تا پاک نشوند در دیتابیس‌اند. -- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس - می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما). -- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ - واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ - `DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete` - می‌بندد) هم برداشته شود. -- **چطور بسنجید:** در پنل یا shell، `User.objects.filter(is_guest=True).count()` پس از پاک‌سازی باید صفر باشد. +- **چیست:** کاربرهای ۱۰ تا ۱۵ و ۲۵ (حساب‌های دمو با کدِ ثابت، بند ۲) با درس، گفتگو، تیکت و + سفارش روی دیتابیسِ پروداکشن ساخته شده‌اند. حسابِ ۱۰ («علی رضایی»، با ۷ سفارش) حسابِ نمایشیِ اصلی است. +- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی. +- **رفع:** پیش از کاربرِ واقعی با `delete_users --ids … --yes` (پیش از آن بکاپ) پاک شوند. + `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند). +- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد. ## ۴. `/api/health` عمومی و پرجزئیات @@ -138,13 +128,13 @@ - **چیست:** `http://78.38.35.249/api/health` با `Host: mathapi.tadrisino2.ir` پاسخ می‌دهد (تأیید: HTTP 200). `NUM_PROXIES=2` فرض می‌کند همیشه دو پروکسی جلوی ماست. - **خطر:** کسی که IP را بداند `X-Forwarded-For` را جعل می‌کند و محدودیت‌های IP-محور - (مهمان، OTP، ادمین) را دور می‌زند. + (OTP، ادمین) را دور می‌زند. - **رفع (ادمینِ سرور):** پورتِ ۸۰/۴۴۳ فقط برای رنج‌های ArvanCloud، یا `set_real_ip_from` + `real_ip_header` در nginx. - **بازتولید شد ۲۰۲۶-۱۰-۰۴:** `curl -H 'Host: mathapi.tadrisino2.ir' -H 'X-Forwarded-For: 1.2.3.4' http://78.38.35.249/api/health` جواب داد `clientIp: 1.2.3.4`. یعنی هر سقفِ IP-محور (ورودِ ادمین - ۱۰ در ۱۵ دقیقه، درخواست و ورودِ OTP، ساختِ مهمان، دستیارِ مهمان) با عوض کردنِ این هدر در هر + ۱۰ در ۱۵ دقیقه، درخواست و ورودِ OTP) با عوض کردنِ این هدر در هر درخواست دور زده می‌شود، و حفاظتِ DDoS/WAFِ آروان هم برای این مسیر وجود ندارد. سقف‌های per-phone و per-code که در دیتابیس شمرده می‌شوند متأثر نیستند. پورت‌های ۸۰ و ۴۴۳ هر دو باز بودند. @@ -157,16 +147,6 @@ - **رفع:** `CACHE_URL=redis://127.0.0.1:6379/1` (db0 مالِ اپِ دیگری است) و ری‌استارت. - **چطور بسنجید:** `/api/health` → `checks.cache.backend` نباید `LocMemCache` باشد. -## ۱۰. حساب‌های مهمانِ قدیمی - -- **چیست:** ورودِ مهمان (`POST /api/auth/session`) حذف شده و هر کاربرِ تازه با شماره ثبت‌نام می‌کند. - ردیف‌های مهمانِ پیشین (`User.is_guest=True`، شامل دموها) در دیتابیس مانده‌اند و کدِ ارتقای مهمان به - حسابِ شماره‌دار (`LoginView`) و throttleهای مهمان‌محور هنوز برایشان کار می‌کنند. -- **خطر:** مهمانِ قدیمی هنوز با `refreshToken` ذخیره‌شده در مرورگرش وارد می‌ماند؛ شناسه‌ی دستگاه دیگر - راهِ ورود نیست. -- **رفع:** پس از پاک‌سازیِ دموها (بند ۳) و دیدنِ این‌که مهمانِ واقعی نمانده، کدِ مهمان - (`is_guest`، `GuestAware*Throttle`، ارتقا) حذف شود. تا آن روز عمداً نگه داشته شده. - ## ۱۱. کدِ OTP بی‌هش در دیتابیس - **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود. @@ -238,17 +218,6 @@ - **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'` و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`. -## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک - -- **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا - ساختنِ مهمانِ تازه سقف را دور نزند. -- **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹. - دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳ - workerِ بی‌Redis تا حدودِ ۶۰). -- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد. - سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود - می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد. - ## ۲۴. پر کردنِ سقفِ سراسریِ پیامک - **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر @@ -304,8 +273,7 @@ - **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بی‌ورود)، `progress/nodes`، `user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope` ندارند؛ کلِ بانکِ سوال بی‌ورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.) -- **چرا باز مانده:** سقفِ مهمان‌ها بر اساسِ IP است (مورد ۲۳)؛ افزودنِ سقف به این مسیرها برای کلاسی - که پشتِ یک وای‌فای با مهمان کار می‌کند ۴۲۹ می‌سازد. همراهِ تصمیمِ مورد ۲۳ بسته شود. +- **رفع:** برای هر کدام `throttle_scope` و ردیفِ نرخ در `config/settings.py` اضافه شود. ## ۳۱. لپ‌تاپِ توسعه روی شبکه‌ی محلی @@ -324,7 +292,7 @@ | حفاظ | تست | |---|---| -| ورودِ مهمان وجود ندارد (`POST /api/auth/session` ۴۰۴ می‌دهد و کاربر نمی‌سازد)؛ حسابِ مهمانِ قدیمی همچنان refresh و ارتقا دارد | `accounts/tests.py` (`test_guest_login_endpoint_is_gone`)، `accounts/tests_guest_refresh.py` | +| ورودِ مهمان وجود ندارد؛ مدلِ `User` ستونِ `is_guest`/`device_id` ندارد و migrationِ حذفشان تا مهمانی مانده باشد می‌شکند | `accounts/migrations/0008_remove_guest_login.py` | | پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` | | لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) | | توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) | @@ -337,8 +305,7 @@ | کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` | | actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` | | ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` | -| شناسه‌ی دستگاهِ مهمان فقط برای ابرکاربر در پنل دیده می‌شود؛ ثبتِ شماره نشست‌های قبلیِ مهمان را باطل می‌کند | `accounts/tests_security_review.py` | -| حذفِ حساب سابقه‌ی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه می‌دارد؛ مهمانِ نمایشی حذف نمی‌شود | `accounts/tests_security_review.py` | +| حذفِ حساب سابقه‌ی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه می‌دارد؛ حسابِ دمو حذف نمی‌شود | `accounts/tests_security_review.py` | | صافیِ اطلاعاتِ تماس در نامِ نمایشی (شماره با هر جداکننده، آیدیِ شبکه‌ی اجتماعی) | `accounts/tests_security_review.py` | | کلیدِ کاوه‌نگار و توکنِ خریدِ بازار در لاگِ خطای شبکه نمی‌آیند | `accounts/tests_security_review.py` | | برگشت از زرین‌پال بی‌`Authority` ۴۰۰؛ خریدِ دومِ بازار روی سفارشِ پرداخت‌شده ۴۰۹؛ آنچه داده یا پس گرفته می‌شود از خودِ سفارش است، نه پلنِ فعلی | `payment/tests_security_review.py` | @@ -352,9 +319,8 @@ | کدِ تخفیفِ وسطِ رفتن به درگاه، مبلغِ authority را با سفارش ناهمخوان نمی‌کند | `payment/tests_security_review.py` (`DiscountDuringPaymentRequestTests`) | | پرداختِ بسته‌ای که پلنش پاک شده از عددهای ثبت‌شده‌ی سفارش اعتبار می‌دهد (و اگر نداشت ERROR لاگ می‌شود)؛ پلن فقط با IRT ذخیره می‌شود | `subscription/tests_security_review.py` (`PaidPackWithDeletedPlanTests`، `PlanCurrencyIsTomanOnlyTests`) | | توکنِ بازار (`refresh_token`، `pending_state`) با فیلترِ querystringِ پنل حرف‌به‌حرف درنمی‌آید | `payment/tests_security_review.py` (`BazaarTokenNotGuessableByAdminFilterTests`) | -| `device_id` با فیلترِ querystringِ پنل برای غیرِ ابرکاربر درنمی‌آید؛ شماره و `is_guest` را فقط ابرکاربر عوض می‌کند؛ حساب‌های کارمند و گروه‌ها فقط با ابرکاربر ساخته/تغییر می‌کنند | `accounts/tests_security_review.py` (`DeviceIdNotGuessableByAdminFilterTests`، `StaffCannotSwapLoginPhoneTests`، `StaffAccountsAdminSuperuserOnlyTests`) | +| شماره‌ی کاربر را فقط ابرکاربر عوض می‌کند؛ حساب‌های کارمند و گروه‌ها فقط با ابرکاربر ساخته/تغییر می‌کنند | `accounts/tests_security_review.py` (`StaffCannotSwapLoginPhoneTests`، `StaffAccountsAdminSuperuserOnlyTests`) | | لاگِ تحویلِ کمپین اجازه‌ی view می‌خواهد (هر کارمندی نمی‌بیند) | `campaigns/tests_security_review.py` | -| هر رقمِ یونیکد (تمام‌عرض، ریاضی…) در شماره به لاتین برمی‌گردد؛ کدِ غیرِ ASCII، `deviceId` null، پایه‌ی ناموجود یا عظیم و تاریخِ تولدِ با «²» ۴۰۰ است نه ۵۰۰؛ صافیِ نامِ نمایشی ارقامِ یونیکد و جداکننده‌ی دلخواه را می‌گیرد | `accounts/tests_security_review.py` (`UnicodeInputsAreNormalisedOrRejectedTests`) | -| مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمی‌شود | `accounts/tests_security_review.py` (`DemoGuestNotUpgradableTests`) | +| هر رقمِ یونیکد (تمام‌عرض، ریاضی…) در شماره به لاتین برمی‌گردد؛ کدِ غیرِ ASCII، پایه‌ی ناموجود یا عظیم و تاریخِ تولدِ با «²» ۴۰۰ است نه ۵۰۰؛ صافیِ نامِ نمایشی ارقامِ یونیکد و جداکننده‌ی دلخواه را می‌گیرد | `accounts/tests_security_review.py` (`UnicodeInputsAreNormalisedOrRejectedTests`) | | `/api/home?gradeId=` ناموجود کش نمی‌شود (کشِ پُر شمارنده‌ی سقفِ ورودِ ادمین را پاک می‌کرد)؛ `/api/progress/nodes` یک کوئری است و `unit` عظیم ۴۰۰ | `content/tests_security_review.py` (`JunkGradeIdsDoNotFlushCacheTests`، `ProgressNodesInputAndQueriesTests`) | diff --git a/notifications/tests.py b/notifications/tests.py index 7a92e18..520341a 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -93,7 +93,7 @@ class NotificationListCapTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-notif-cap', is_guest=True, name='n') + self.user = User.objects.create(phone='09000000065', name='n') self.token = issue_access_token(self.user.id) def test_returns_at_most_100_newest_with_correct_unread_count(self): @@ -120,7 +120,7 @@ class NotificationPayloadShapeTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='dev-notif-shape', is_guest=True, name='n') + self.user = User.objects.create(phone='09000000066', name='n') self.token = issue_access_token(self.user.id) def test_list_item_carries_body_and_created_at(self): diff --git a/notifications/views.py b/notifications/views.py index f371c44..d1bd913 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -15,26 +15,12 @@ from .serializers import ( NOTIFICATIONS_MAX = 100 -class GuestAwareReadThrottle(ScopedRateThrottle): - """همون الگوی GuestAware*Throttle: مهمان با IP کلید می‌خوره.""" - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - return self.cache_format % {'scope': self.scope, 'ident': ident} - - class NotificationListView(APIView): """GET /api/notifications — فهرست اعلان‌های کاربر (جدیدترین اول)""" permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)']) def get(self, request): @@ -75,7 +61,7 @@ class NotificationMarkAllReadView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(request=None, responses=MarkAllReadResponseSerializer, tags=['Notifications (notifications)']) diff --git a/payment/tests.py b/payment/tests.py index a31235c..80d46a6 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -152,7 +152,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase): (throttle_scope='bazaar_verify'): هر درخواستِ پذیرفته‌شده یک تماسِ سرور-به-سرورِ واقعی با API بازار می‌زنه؛ بدونِ throttle مخصوص، یک کاربرِ احرازهویت‌شده می‌تونست با ارسالِ مکررِ purchaseToken بارِ زیادی - روی سرویسِ بازار/بک‌اند بندازه. GuestAwareBazaarThrottle با pk کاربرِ + روی سرویسِ بازار/بک‌اند بندازه. throttle با pk کاربرِ احرازهویت‌شده کلید می‌خوره (نه IP)، پس تغییرِ IP این محدودیت رو دور نمی‌زنه. """ @@ -160,7 +160,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-bazaar-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000007', name='') self.token = issue_access_token(self.user.id) def _make_order(self, key): @@ -211,7 +211,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase): def test_changing_client_ip_does_not_bypass_the_user_limit(self): # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751') + registered_user = User.objects.create(name='', phone='+989120000751') registered_token = issue_access_token(registered_user.id) with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}): @@ -256,29 +256,13 @@ class BazaarVerifyPurchaseThrottleTests(TestCase): self.assertFalse(Transaction.objects.filter(order=second_order).exists()) -class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='bazaar_verify' برای - هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه برای تریگر کردنِ تماسِ سرور-به-سرورِ verify_purchase بگیره. حالا - GuestAwareBazaarThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP - می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle - برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. - """ +class BazaarVerifyPurchasePerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() self.client = APIClient() - def _make_guest_with_order(self, device_id, key): - guest = User.objects.create(device_id=device_id, is_guest=True, name='') - order = Order.objects.create( - user_id=guest.id, amount=10000, provider='bazaar', - bazaar_product_sku='sku_one_time', idempotency_key=key, - ) - return guest, order def _verify(self, order_id, purchase_token, token): return self.client.post( @@ -288,32 +272,13 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '2/hour'}): - with patch('payment.views.bazaar.verify_purchase') as mock_verify: - mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) - - guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-1', 'order-bazaar-rot-1') - response1 = self._verify(order1.id, 'token-rot-1', issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-2', 'order-bazaar-rot-2') - response2 = self._verify(order2.id, 'token-rot-2', issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. - guest3, order3 = self._make_guest_with_order('device-bazaar-guest-rot-3', 'order-bazaar-rot-3') - response3 = self._verify(order3.id, 'token-rot-3', issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}): with patch('payment.views.bazaar.verify_purchase') as mock_verify: mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) - user_a = User.objects.create(is_guest=False, name='', phone='+989120000761') + user_a = User.objects.create(name='', phone='+989120000761') order_a = Order.objects.create( user_id=user_a.id, amount=10000, provider='bazaar', bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a', @@ -321,7 +286,7 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase): response_a = self._verify(order_a.id, 'token-reg-a', issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000762') + user_b = User.objects.create(name='', phone='+989120000762') order_b = Order.objects.create( user_id=user_b.id, amount=10000, provider='bazaar', bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b', @@ -330,26 +295,6 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase): self.assertEqual(response_b.status_code, 200) - def test_blocked_guest_request_does_not_call_bazaar_or_mutate_order(self): - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}): - with patch('payment.views.bazaar.verify_purchase') as mock_verify: - mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) - - guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-4', 'order-bazaar-rot-4') - first_response = self._verify(order1.id, 'token-rot-4', issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 200) - - mock_verify.reset_mock() - guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-5', 'order-bazaar-rot-5') - blocked_response = self._verify(order2.id, 'token-rot-5', issue_access_token(guest2.id)) - self.assertEqual(blocked_response.status_code, 429) - mock_verify.assert_not_called() - - order2.refresh_from_db() - self.assertEqual(order2.status, 'pending') - self.assertEqual(order2.bazaar_purchase_token, '') - self.assertFalse(Transaction.objects.filter(order=order2).exists()) - class ZarinpalClientTests(TestCase): """ diff --git a/payment/tests_audit_fixes.py b/payment/tests_audit_fixes.py index 808d8e7..df87b67 100644 --- a/payment/tests_audit_fixes.py +++ b/payment/tests_audit_fixes.py @@ -23,7 +23,7 @@ from subscription.models import Plan, Subscription class _Base(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007001', is_guest=False) + self.user = User.objects.create(phone='09120007001') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') self.month = Plan.objects.create(title='ماهانه', price=49000, duration_days=30) @@ -137,7 +137,7 @@ class ReconcileTests(_Base): class AffiliateTests(_Base): def setUp(self): super().setUp() - self.referrer = User.objects.create(phone='09120007002', is_guest=False) + self.referrer = User.objects.create(phone='09120007002') self.account = AffiliateAccount.objects.create(user_id=self.referrer.id, referral_code='SARA10', commission_rate=0.1) @@ -157,11 +157,3 @@ class AffiliateTests(_Base): me = referrer.get('/api/affiliate/me').json() self.assertEqual((me['referredCount'], me['paidCount'], me['totalCommission']), (1, 1, 4900)) - def test_guests_take_no_part(self): - guest = User.objects.create(device_id='dev-aff', is_guest=True) - client = APIClient() - client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(guest.id)}') - self.assertEqual(client.post('/api/affiliate/track', {'referralCode': 'SARA10'}, - format='json').status_code, 403) - self.assertEqual(client.get('/api/affiliate/me').status_code, 403) - self.assertFalse(Conversion.objects.exists()) diff --git a/payment/tests_bazaar_pack.py b/payment/tests_bazaar_pack.py index ad0ebdb..533ce8c 100644 --- a/payment/tests_bazaar_pack.py +++ b/payment/tests_bazaar_pack.py @@ -26,7 +26,7 @@ from .models import Order class BazaarPackTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007101', is_guest=False) + self.user = User.objects.create(phone='09120007101') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') self.pack = Plan.objects.get(kind='pack', question_credits=20) @@ -74,7 +74,7 @@ class BazaarSkuComesFromTheOrderTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007102', is_guest=False) + self.user = User.objects.create(phone='09120007102') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -105,7 +105,7 @@ class BazaarPurchaseFromTheAppTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007103', is_guest=False) + self.user = User.objects.create(phone='09120007103') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') self.pack = Plan.objects.get(kind='pack', question_credits=20) diff --git a/payment/tests_invoices.py b/payment/tests_invoices.py index 44f9cfa..e583627 100644 --- a/payment/tests_invoices.py +++ b/payment/tests_invoices.py @@ -25,7 +25,7 @@ def _client(user): class InvoiceTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007001', is_guest=False) + self.user = User.objects.create(phone='09120007001') self.client = _client(self.user) self.plan = Plan.objects.create(title='سالانه', price=299000, duration_days=365) @@ -80,7 +80,7 @@ class InvoiceTests(TestCase): def test_someone_else_sees_nothing(self): self.buy() - stranger = User.objects.create(phone='09120007002', is_guest=False) + stranger = User.objects.create(phone='09120007002') self.assertEqual(self.invoices(_client(stranger)), []) def test_orders_from_before_the_snapshot_have_no_list_price(self): diff --git a/payment/tests_refund.py b/payment/tests_refund.py index e5772f0..536defe 100644 --- a/payment/tests_refund.py +++ b/payment/tests_refund.py @@ -27,7 +27,7 @@ from .services import OrderStateError, apply_order_payment_result, cancel_pendin class _Base(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120007401', is_guest=False) + self.user = User.objects.create(phone='09120007401') self.client_app = APIClient() self.client_app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') self.pack = Plan.objects.get(kind='pack', question_credits=20) @@ -107,7 +107,7 @@ class RefundServiceTests(_Base): self.assertEqual(sub.status, 'canceled') def test_the_referrers_commission_for_this_order_is_undone(self): - referrer = User.objects.create(phone='09120007402', is_guest=False) + referrer = User.objects.create(phone='09120007402') account = AffiliateAccount.objects.create(user_id=referrer.id, referral_code='REF1', commission_rate=0.1) Conversion.objects.create(affiliate_account=account, referred_user_id=self.user.id) order = self.paid_pack() diff --git a/payment/tests_security_review.py b/payment/tests_security_review.py index 602d816..423bb82 100644 --- a/payment/tests_security_review.py +++ b/payment/tests_security_review.py @@ -30,7 +30,7 @@ def client_for(user): class _Base(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120009901', is_guest=False) + self.user = User.objects.create(phone='09120009901') self.app = client_for(self.user) self.pack = Plan.objects.get(kind='pack', question_credits=20) diff --git a/payment/views.py b/payment/views.py index 93c5741..a9865bf 100644 --- a/payment/views.py +++ b/payment/views.py @@ -48,24 +48,6 @@ def _redirect_to_configured_url(url: str) -> HttpResponseRedirect: return redirect_cls(url) -class GuestAwareBazaarThrottle(ScopedRateThrottle): - """همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ - حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify - بده، چون هر درخواستِ پذیرفته‌شده یه فراخوانیِ سرور-به-سرور به بازاره. - برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk.""" - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - class CreateOrderView(APIView): """ POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription) @@ -299,11 +281,9 @@ class BazaarVerifyPurchaseView(APIView): permission_classes = [IsAuthenticated] # این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase) # تریگر می‌کنه، برای همین throttle_scope مجزا و نرخِ سخت‌گیرانه‌تر (۱۰/hour) - # داره. کاربرهای مهمان با هر deviceId یک pk تازه می‌گیرن، پس - # GuestAwareBazaarThrottle برای اون‌ها بر اساسِ IP کلید می‌سازه تا چرخشِ - # حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ می‌شه. + # داره. throttle_scope = 'bazaar_verify' - throttle_classes = [GuestAwareBazaarThrottle] + throttle_classes = [ScopedRateThrottle] def get_permissions(self): if self.request.method == 'GET': diff --git a/progress/tests.py b/progress/tests.py index d003dda..c30cae5 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -23,7 +23,7 @@ class UserHeartsRegenTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='dev-hearts-regen', is_guest=True, name='') + self.user = User.objects.create(phone='09000000001', name='') self.token = issue_access_token(self.user.id) def _get_hearts(self): @@ -75,7 +75,7 @@ class CharacterGamificationTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-characters-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000002', name='') self.token = issue_access_token(self.user.id) self.owl = Character.objects.create(slug='owl', name='جغد', icon='owl', unlock_xp=0, order=1) @@ -275,7 +275,7 @@ class LeaderboardPeriodValidationTests(TestCase): from rest_framework.test import APIClient self.client = APIClient() - user = User.objects.create(device_id='lb-dev', is_guest=True) + user = User.objects.create(phone='09000000003') self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') def test_unknown_period_is_a_400(self): diff --git a/progress/tests_weekly_report.py b/progress/tests_weekly_report.py index 62be1f0..560ca91 100644 --- a/progress/tests_weekly_report.py +++ b/progress/tests_weekly_report.py @@ -46,8 +46,8 @@ class EstimateMinutesTests(SimpleTestCase): class WeeklyReportApiTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120009001', is_guest=False) - self.other = User.objects.create(phone='09120009002', is_guest=False) + self.user = User.objects.create(phone='09120009001') + self.other = User.objects.create(phone='09120009002') grade = Grade.objects.create(title='پایه نهم', icon='', color='') node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1), title='جزیره', order=1) @@ -139,12 +139,12 @@ class NodeProgressIsOwnOnlyTests(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120009003', is_guest=False) + self.user = User.objects.create(phone='09120009003') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') def test_someone_elses_id_returns_only_your_own_progress(self): - other = User.objects.create(phone='09120009004', is_guest=False) + other = User.objects.create(phone='09120009004') node = Node.objects.create( unit=Unit.objects.create(grade=Grade.objects.create(title='پایه', icon='', color=''), title='فصل', order=1), diff --git a/progress/views.py b/progress/views.py index 2a0ec36..a2cebd7 100644 --- a/progress/views.py +++ b/progress/views.py @@ -9,21 +9,6 @@ from rest_framework.views import APIView from .models import Character, StreakRecord, UserHearts, UserStats from .services import effective_current_streak - - -class GuestAwareReadThrottle(ScopedRateThrottle): - """همون الگوی GuestAwareContentThrottle: کاربرِ مهمان (هر deviceId یک pk - تازه) با IP کلید می‌خوره تا چرخشِ حساب سقفِ read رو دور نزنه.""" - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - return self.cache_format % {'scope': self.scope, 'ident': ident} from .serializers import ( CharacterListResponseSerializer, LeaderboardResponseSerializer, @@ -55,7 +40,7 @@ class UserSummaryView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): @@ -86,7 +71,7 @@ class StreakCalendarView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): @@ -112,7 +97,7 @@ class WeeklyReportView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=WeeklyReportResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): @@ -136,7 +121,7 @@ class LeaderboardView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False, @@ -215,7 +200,7 @@ class CharacterListView(APIView): permission_classes = [IsAuthenticated] throttle_scope = 'read' - throttle_classes = [GuestAwareReadThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema(responses=CharacterListResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): diff --git a/rag/tests.py b/rag/tests.py index 75873cd..107129f 100644 --- a/rag/tests.py +++ b/rag/tests.py @@ -72,7 +72,7 @@ class AskAIRagPathTests(TestCase): cache.clear() reset_clients_cache() self.client = APIClient() - self.user = User.objects.create(device_id='dev-rag-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000031', name='') self.token = issue_access_token(self.user.id) def _ask(self, body): @@ -277,7 +277,7 @@ class AskAIStreamTests(TestCase): cache.clear() reset_clients_cache() self.client = APIClient() - self.user = User.objects.create(device_id='dev-stream-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000032', name='') self.token = issue_access_token(self.user.id) def _ask_stream(self, body): @@ -330,7 +330,7 @@ class AskAIAnswerCacheTests(TestCase): cache.clear() reset_clients_cache() self.client = APIClient() - self.user = User.objects.create(device_id='dev-cache-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000033', name='') self.token = issue_access_token(self.user.id) def _ask(self, body): @@ -468,7 +468,7 @@ class AnswerAnnotationTests(TestCase): def setUp(self): cache.clear() reset_clients_cache() - self.user = User.objects.create(phone='09120000777', is_guest=False, grade_id=1) + self.user = User.objects.create(phone='09120000777', grade_id=1) self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -815,7 +815,7 @@ class AskDerivesContextFromLessonTests(TestCase): cache.clear() reset_clients_cache() - self.user = User.objects.create(device_id='dev-ctx-1', is_guest=True) + self.user = User.objects.create(phone='09000000034') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -886,7 +886,7 @@ class FollowUpStaysGroundedTests(TestCase): cache.clear() reset_clients_cache() - self.user = User.objects.create(device_id='dev-follow-1', is_guest=True) + self.user = User.objects.create(phone='09000000035') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') @@ -961,7 +961,7 @@ class ReviewFixRegressionTests(TestCase): def test_feedback_reason_survives_a_second_vote_without_one(self): from ai_tutor.models import Conversation, Message, MessageFeedback - user = User.objects.create(device_id='dev-reason', is_guest=True) + user = User.objects.create(phone='09000000036') client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') conversation = Conversation.objects.create(user_id=user.id) diff --git a/rag/tests_diagram.py b/rag/tests_diagram.py index 8c1e1ed..5a68a2d 100644 --- a/rag/tests_diagram.py +++ b/rag/tests_diagram.py @@ -111,7 +111,7 @@ class DiagramThroughTheApiTests(TestCase): cache.clear() reset_clients_cache() self.client = APIClient() - self.user = User.objects.create(device_id='dev-diagram', is_guest=True, name='') + self.user = User.objects.create(phone='09000000030', name='') self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') def test_stream_sends_text_without_json_and_diagram_in_done_and_history(self): diff --git a/rag/tests_guardrails.py b/rag/tests_guardrails.py index b59b20b..ed2053d 100644 --- a/rag/tests_guardrails.py +++ b/rag/tests_guardrails.py @@ -123,7 +123,7 @@ class GuardrailThroughTheApiTests(TestCase): def setUp(self): cache.clear() reset_clients_cache() - self.user = User.objects.create(device_id='dev-guardrail', is_guest=True, name='') + self.user = User.objects.create(phone='09000000037', name='') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index 0d04bcc..18e6595 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.py @@ -183,7 +183,7 @@ class ConversationMemoryTests(TestCase): cache.clear() def _user(self, phone): - user = User.objects.create(phone=phone, is_guest=False) + user = User.objects.create(phone=phone) client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') return client @@ -231,7 +231,7 @@ class ProviderStateTests(TestCase): def setUp(self): cache.clear() reset_clients_cache() - user = User.objects.create(phone='09120000904', is_guest=False) + user = User.objects.create(phone='09120000904') self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') @@ -276,7 +276,7 @@ class QuestionFocusTests(TestCase): type='multiple_choice', prompt='۲ به توان منفی ۳؟') self.right = Choice.objects.create(question=self.question, value='1/8', is_correct=True) self.wrong = Choice.objects.create(question=self.question, value='-8', is_correct=False) - user = User.objects.create(phone='09120000905', is_guest=False, grade_id=grade.id) + user = User.objects.create(phone='09120000905', grade_id=grade.id) self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') diff --git a/subscription/tests.py b/subscription/tests.py index eb73d72..b0b18fe 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -20,7 +20,7 @@ class PurchaseIdempotencyRetryTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000042', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -77,7 +77,7 @@ class PurchaseExternalCallNotUnderLockTests(TransactionTestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-lock-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000043', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -115,7 +115,7 @@ class PurchaseOrderCreationFailureCleanupTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-cleanup-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000044', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -171,7 +171,7 @@ class PurchaseFailureCleanupConcurrencyRegressionTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-race-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000045', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -257,7 +257,7 @@ class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-race-identical-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000046', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -314,7 +314,7 @@ class PurchaseThrottleTests(TestCase): from django.core.cache import cache cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-purchase-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000047', name='') self.token = issue_access_token(self.user.id) self.plan = Plan.objects.create( title='ماهانه', price=100000, currency='IRT', duration_days=30, @@ -343,7 +343,7 @@ class PurchaseThrottleTests(TestCase): } ) def test_requests_within_the_limit_still_succeed(self): - # ⚠️ مثلِ GuestSessionThrottleTests: override_settings به‌تنهایی روی + # override_settings به‌تنهایی روی # ScopedRateThrottle.THROTTLE_RATES (که یک‌بار موقعِ import گرفته # شده) اثر نداره؛ برای اینکه نرخِ واقعاً محدودشده اعمال بشه، مستقیماً # دیکشنریِ زنده‌ی THROTTLE_RATES رو patch می‌کنیم. @@ -362,10 +362,8 @@ class PurchaseThrottleTests(TestCase): self.assertEqual(blocked_response.status_code, 429) def test_changing_client_ip_does_not_bypass_the_user_limit(self): - # کاربرِ غیرمهمان لازمه، نه self.user، چون کلیدِ bucketِ مهمان‌ها - # عمداً بر اساسِ IP است (نگاه کنید به PurchaseGuestThrottleBypassTests) - # و این تست رفتارِ per-user برای ثبت‌نام‌شده‌ها را می‌سنجد. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501') + # کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد. + registered_user = User.objects.create(name='', phone='+989120000501') registered_token = issue_access_token(registered_user.id) def _purchase_as(idempotency_key, remote_addr): @@ -413,19 +411,8 @@ class PurchaseThrottleTests(TestCase): self.assertEqual(subscription.version, snapshot['version']) -class PurchaseGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='purchase' برای هر - کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView - با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم - می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle - تازه برای سقفِ ۱۰/ساعتِ purchase بگیره. این endpoint حساسِ مالیه: هر - درخواستِ پذیرفته‌شده Subscription می‌سازه/آپدیت می‌کنه و create_order() - رو صدا می‌زنه. حالا GuestAwarePurchaseThrottle برای کاربرهای مهمان - کلید رو بر اساسِ IP (همون get_ident که guest_session هم استفاده - می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ - throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. - """ +class PurchasePerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): from django.core.cache import cache @@ -444,79 +431,21 @@ class PurchaseGuestThrottleBypassTests(TestCase): HTTP_IDEMPOTENCY_KEY=idempotency_key, ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '2/hour'}): - guest1 = User.objects.create(device_id='device-purchase-guest-rot-1', is_guest=True, name='') - response1 = self._purchase('key-guest-rot-1', issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-purchase-guest-rot-2', is_guest=True, name='') - response2 = self._purchase('key-guest-rot-2', issue_access_token(guest2.id)) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی - # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های - # مهمانِ ساخته‌شده. - guest3 = User.objects.create(device_id='device-purchase-guest-rot-3', is_guest=True, name='') - response3 = self._purchase('key-guest-rot-3', issue_access_token(guest3.id)) - - self.assertEqual(response3.status_code, 429) - - def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self): - # حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر - # (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP - # مشترک متصل است. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}): - guest1 = User.objects.create(device_id='device-purchase-guest-rot-4', is_guest=True, name='') - response1 = self._purchase('key-guest-rot-4', issue_access_token(guest1.id)) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-purchase-guest-rot-5', is_guest=True, name='') - blocked = self._purchase('key-guest-rot-5', issue_access_token(guest2.id)) - self.assertEqual(blocked.status_code, 429) - - guest3 = User.objects.create(device_id='device-purchase-guest-rot-6', is_guest=True, name='') - still_blocked = self._purchase('key-guest-rot-6', issue_access_token(guest3.id)) - - self.assertEqual(still_blocked.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120000601') + user_a = User.objects.create(name='', phone='+989120000601') response_a = self._purchase('key-reg-a', issue_access_token(user_a.id)) self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120000602') + user_b = User.objects.create(name='', phone='+989120000602') response_b = self._purchase('key-reg-b', issue_access_token(user_b.id)) self.assertEqual(response_b.status_code, 200) - def test_blocked_guest_request_does_not_mutate_subscription_or_call_purchase_logic(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید Subscription/Order - # بسازه یا create_order() رو صدا بزنه — throttle باید *قبل* از - # رسیدن به منطقِ purchase رد کنه، حتی با Idempotency-Key معتبر. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}): - guest1 = User.objects.create(device_id='device-purchase-guest-rot-7', is_guest=True, name='') - first_response = self._purchase('key-guest-rot-7', issue_access_token(guest1.id)) - self.assertEqual(first_response.status_code, 200) - - # حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از - # قبل مصرف شده) و نباید Subscription/Order برای آن بسازد. - guest2 = User.objects.create(device_id='device-purchase-guest-rot-8', is_guest=True, name='') - with patch('subscription.views.create_order') as mock_create_order: - blocked_response = self._purchase('key-guest-rot-8', issue_access_token(guest2.id)) - self.assertEqual(blocked_response.status_code, 429) - mock_create_order.assert_not_called() - - self.assertFalse(Subscription.objects.filter(user_id=guest2.id).exists()) - self.assertEqual(Order.objects.filter(user_id=guest2.id).count(), 0) - class PlanCardContractTests(TestCase): """ diff --git a/subscription/tests_packs.py b/subscription/tests_packs.py index 68c930f..c6b0328 100644 --- a/subscription/tests_packs.py +++ b/subscription/tests_packs.py @@ -32,7 +32,7 @@ def _client(user): class _Base(TestCase): def setUp(self): cache.clear() - self.user = User.objects.create(phone='09120005001', is_guest=False) + self.user = User.objects.create(phone='09120005001') self.client = _client(self.user) self.pack60 = Plan.objects.get(kind='pack', question_credits=60) diff --git a/subscription/tests_security_review.py b/subscription/tests_security_review.py index 93cbd42..47096b9 100644 --- a/subscription/tests_security_review.py +++ b/subscription/tests_security_review.py @@ -20,7 +20,7 @@ class PaidPackWithDeletedPlanTests(TestCase): def test_order_snapshot_is_granted_even_if_plan_is_gone(self): cache.clear() - user = User.objects.create(phone='09120009902', is_guest=False) + user = User.objects.create(phone='09120009902') app = APIClient() app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') pack = Plan.objects.get(kind='pack', question_credits=20) diff --git a/subscription/views.py b/subscription/views.py index ca7d4b4..b3329f8 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -24,24 +24,6 @@ from .serializers import ( from .services import credit_balance, has_active_subscription, hearts_pass_until -class GuestAwarePurchaseThrottle(ScopedRateThrottle): - """همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ - حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه - (Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای - مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk.""" - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - # پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند. # با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py). PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بسته‌های سوال @@ -89,13 +71,9 @@ class PurchaseView(APIView): permission_classes = [IsAuthenticated] # این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون* - # کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر - # deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛ - # GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه - # (مثلِ GuestAwareAIThrottle/DiscountThrottle) و برای - # کاربرهای غیرمهمان رفتارِ per-user را حفظ می‌کنه. + # کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. throttle_scope = 'purchase' - throttle_classes = [GuestAwarePurchaseThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=PurchaseRequestSerializer, diff --git a/support/tests.py b/support/tests.py index 6008ebf..a9caa5e 100644 --- a/support/tests.py +++ b/support/tests.py @@ -24,7 +24,7 @@ class BugReportCreateThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-bugreport-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000060', name='') self.token = issue_access_token(self.user.id) def _create(self, i=0, remote_addr=None): @@ -56,7 +56,7 @@ class BugReportCreateThrottleTests(TestCase): def test_changing_client_ip_does_not_bypass_the_user_limit(self): # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901') + registered_user = User.objects.create(name='', phone='+989120000901') registered_token = issue_access_token(registered_user.id) def _create_as(i, remote_addr): @@ -97,7 +97,7 @@ class TicketCreateThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ticket-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000061', name='') self.token = issue_access_token(self.user.id) def _create(self, i=0, remote_addr=None): @@ -129,7 +129,7 @@ class TicketCreateThrottleTests(TestCase): def test_changing_client_ip_does_not_bypass_the_user_limit(self): # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902') + registered_user = User.objects.create(name='', phone='+989120000902') registered_token = issue_access_token(registered_user.id) def _create_as(i, remote_addr): @@ -170,7 +170,7 @@ class TicketMessageCreateThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ticketmsg-throttle-1', is_guest=True, name='') + self.user = User.objects.create(phone='09000000062', name='') self.token = issue_access_token(self.user.id) self.ticket = Ticket.objects.create(user_id=self.user.id, subject='موضوع', category='billing') @@ -203,7 +203,7 @@ class TicketMessageCreateThrottleTests(TestCase): def test_changing_client_ip_does_not_bypass_the_user_limit(self): # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه. - registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903') + registered_user = User.objects.create(name='', phone='+989120000903') registered_token = issue_access_token(registered_user.id) registered_ticket = Ticket.objects.create( user_id=registered_user.id, subject='موضوع', category='billing', @@ -238,21 +238,8 @@ class TicketMessageCreateThrottleTests(TestCase): self.assertEqual(TicketMessage.objects.filter(ticket=self.ticket).count(), count_before) -class SupportCreateGuestThrottleBypassTests(TestCase): - """ - 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='support_create' - (مشترک بینِ BugReportCreateView، TicketCreateView و - TicketMessageCreateView) برای هر کاربرِ احرازهویت‌شده (مهمان یا نه) - user.pk بود. چون GuestSessionView با هر deviceId جدید یک کاربرِ - مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ - حساب‌های مهمان، هر بار یک bucket throttle تازه برای این scopeِ - مشترک بگیره و سقفِ ۳/ساعتِ ساختِ رکوردهای پشتیبانی (BugReport/ - Ticket/TicketMessage) رو چند برابر کنه. حالا GuestAwareSupportThrottle - برای کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که - guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه - bucket تازه نمی‌ده — این هم روی bucket مشترکِ بینِ هر سه Endpoint و - هم روی رفتارِ per-user برای کاربرهای غیرمهمان تست می‌شه. - """ +class SupportCreatePerUserThrottleTests(TestCase): + """هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد.""" def setUp(self): cache.clear() @@ -277,84 +264,21 @@ class SupportCreateGuestThrottleBypassTests(TestCase): HTTP_AUTHORIZATION=f'Bearer {token}', ) - def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): - # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، - # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند، - # حتی وقتی از دو Endpointِ متفاوتِ همون scope استفاده می‌کنن. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '2/hour'}): - guest1 = User.objects.create(device_id='device-support-guest-rot-1', is_guest=True, name='') - response1 = self._create_bug_report(issue_access_token(guest1.id), 1) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-support-guest-rot-2', is_guest=True, name='') - response2 = self._create_ticket(issue_access_token(guest2.id), 2) - self.assertEqual(response2.status_code, 200) - - # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون - # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی - # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های - # مهمانِ ساخته‌شده یا این‌که کدوم Endpoint صدا زده شده. - guest3 = User.objects.create(device_id='device-support-guest-rot-3', is_guest=True, name='') - response3 = self._create_bug_report(issue_access_token(guest3.id), 3) - - self.assertEqual(response3.status_code, 429) - - def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self): - # حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر - # (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP - # مشترک متصل است. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): - guest1 = User.objects.create(device_id='device-support-guest-rot-4', is_guest=True, name='') - response1 = self._create_bug_report(issue_access_token(guest1.id), 4) - self.assertEqual(response1.status_code, 200) - - guest2 = User.objects.create(device_id='device-support-guest-rot-5', is_guest=True, name='') - blocked = self._create_bug_report(issue_access_token(guest2.id), 5) - self.assertEqual(blocked.status_code, 429) - - guest3 = User.objects.create(device_id='device-support-guest-rot-6', is_guest=True, name='') - still_blocked = self._create_bug_report(issue_access_token(guest3.id), 6) - - self.assertEqual(still_blocked.status_code, 429) def test_registered_users_keep_independent_per_user_buckets(self): # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle # برای اونا هنوز بر اساسِ user.pk است، نه IP. with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): - user_a = User.objects.create(is_guest=False, name='', phone='+989120001001') + user_a = User.objects.create(name='', phone='+989120001001') response_a = self._create_bug_report(issue_access_token(user_a.id), 'a') self.assertEqual(response_a.status_code, 200) - user_b = User.objects.create(is_guest=False, name='', phone='+989120001002') + user_b = User.objects.create(name='', phone='+989120001002') response_b = self._create_bug_report(issue_access_token(user_b.id), 'b') self.assertEqual(response_b.status_code, 200) - def test_blocked_guest_request_does_not_create_support_record(self): - # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید هیچ BugReport یا - # Ticket جدیدی بسازه — throttle باید *قبل* از رسیدن به منطقِ - # ساختِ رکورد رد کنه. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): - guest1 = User.objects.create(device_id='device-support-guest-rot-7', is_guest=True, name='') - first_response = self._create_bug_report(issue_access_token(guest1.id), 7) - self.assertEqual(first_response.status_code, 200) - - # حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از - # قبل مصرف شده) و نباید BugReport یا Ticket جدیدی بسازد. - guest2 = User.objects.create(device_id='device-support-guest-rot-8', is_guest=True, name='') - bug_count_before = BugReport.objects.count() - ticket_count_before = Ticket.objects.count() - - blocked_bug_response = self._create_bug_report(issue_access_token(guest2.id), 8) - self.assertEqual(blocked_bug_response.status_code, 429) - - blocked_ticket_response = self._create_ticket(issue_access_token(guest2.id), 8) - self.assertEqual(blocked_ticket_response.status_code, 429) - - self.assertEqual(BugReport.objects.count(), bug_count_before) - self.assertEqual(Ticket.objects.count(), ticket_count_before) - class TicketListTests(TestCase): """GET /api/support/tickets: فقط تیکت‌های خودِ کاربر، تازه‌ترین اول، با شمارِ پاسخِ پشتیبانی.""" @@ -362,8 +286,8 @@ class TicketListTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() - self.user = User.objects.create(device_id='device-ticket-list-1', is_guest=True, name='') - self.other = User.objects.create(device_id='device-ticket-list-2', is_guest=True, name='') + self.user = User.objects.create(phone='09000000063', name='') + self.other = User.objects.create(phone='09000000064', name='') self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'} def test_lists_only_own_tickets_with_reply_count(self): diff --git a/support/views.py b/support/views.py index a71114e..b13ca40 100644 --- a/support/views.py +++ b/support/views.py @@ -19,53 +19,14 @@ from .serializers import ( ) -class GuestAwareSupportThrottle(ScopedRateThrottle): - """ - همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و GuestAwareDiscountThrottle/ - GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) - اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ - احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده - می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ - تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های - مهمان، برای هر حساب یک bucket تازه از throttle_scope 'support_create' - بگیره. چون هر سه Endpointِ این اپ (BugReportCreateView، - TicketCreateView، TicketMessageCreateView) این scope رو به‌اشتراک - می‌ذارن و هرکدوم واقعاً یک رکورد در دیتابیس می‌سازن (BugReport، - Ticket، یا TicketMessage)، چرخشِ حساب مهمان می‌تونست محدودیتِ - ۳/ساعتِ در نظر گرفته‌شده رو چند برابر کنه و باعثِ spam بشه. - - این کلاسِ throttle یک‌بار اینجا تعریف شده و بینِ هر سه Endpoint - به‌اشتراک گذاشته می‌شه (به‌جایِ تکرارِ یک کلاسِ مشابه برای هرکدوم)، - دقیقاً چون هر سه از یک throttle_scope مشترک استفاده می‌کنن. فقط - برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP - کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی حساب‌های مهمانِ ساخته‌شده - از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمی‌ده. - رفتارِ throttle برای کاربرهای غیرمهمان (ثبت‌نام‌شده) دقیقاً مثلِ قبل - (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ ساختِ رکورد/ownership/serializer - در هر سه ویو دست‌نخورده‌ست. - """ - - def get_cache_key(self, request, view): - user = request.user - if user and user.is_authenticated and getattr(user, 'is_guest', False): - ident = f'guest-ip:{self.get_ident(request)}' - elif user and user.is_authenticated: - ident = user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % {'scope': self.scope, 'ident': ident} - - class BugReportCreateView(APIView): """POST /api/support/bug-reports — ثبت گزارش باگ""" permission_classes = [IsAuthenticated] # بدونِ idempotency/throttle، یک کاربر می‌تونست تعدادِ نامحدودی BugReport - # بسازه؛ GuestAwareSupportThrottle برای مهمان‌ها کلید رو بر اساسِ IP - # می‌سازه تا چرخشِ حساب bucket تازه نده. + # بسازه. throttle_scope = 'support_create' - throttle_classes = [GuestAwareSupportThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=BugReportCreateRequestSerializer, @@ -103,10 +64,8 @@ class TicketCreateView(APIView): # مثلِ BugReportCreateView: هیچ idempotency ای نیست، پس این endpoint هم # همون scope=support_create رو به‌اشتراک می‌ذاره تا محدودیتِ ساختِ # رکوردهای پشتیبانی، مستقل از این‌که کدوم نوع رکورده، رعایت بشه. - # ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده می‌شه - # تا چرخشِ حساب مهمان bucket تازه ندهد. throttle_scope = 'support_create' - throttle_classes = [GuestAwareSupportThrottle] + throttle_classes = [ScopedRateThrottle] def get_throttles(self): # GET سهمِ خواندن را می‌خورد، نه سقفِ ۳۰ در ساعتِ ساختِ رکورد. @@ -200,10 +159,8 @@ class TicketMessageCreateView(APIView): permission_classes = [IsAuthenticated] # مثلِ دو Endpoint بالا: بدونِ throttle، یک کاربر می‌تونست تعدادِ # نامحدودی پیام در یک تیکت بسازه. - # ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده می‌شه - # تا چرخشِ حساب مهمان bucket تازه ندهد. throttle_scope = 'support_create' - throttle_classes = [GuestAwareSupportThrottle] + throttle_classes = [ScopedRateThrottle] @extend_schema( request=TicketMessageCreateRequestSerializer, diff --git a/tools/loadtest/mkusers.py b/tools/loadtest/mkusers.py index 4b4135e..f59e7b1 100644 --- a/tools/loadtest/mkusers.py +++ b/tools/loadtest/mkusers.py @@ -1,5 +1,5 @@ # Run with: manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" env: LT_N, LT_PREFIX, LT_SEED -# Creates N non-guest test users (fake 0900… numbers, no signals, no SMS) and prints +# Creates N test users (fake 0900… numbers, no signals, no SMS) and prints # [{userId, token, grade, asks:[{lessonId, question}], nodeId, questionIds}] as JSON. import json import os @@ -18,7 +18,7 @@ out = [] for i in range(N): grade = GRADES[i % len(GRADES)] phone = f'{PREFIX}{i:04d}' - user, _ = User.objects.get_or_create(phone=phone, defaults={'is_guest': False, 'name': f'تست بار {i}', 'grade_id': grade}) + user, _ = User.objects.get_or_create(phone=phone, defaults={'name': f'تست بار {i}', 'grade_id': grade}) qs = list(Question.objects.filter(lesson__node__unit__grade_id=grade).select_related('lesson__node') .values_list('id', 'prompt', 'lesson_id', 'lesson__node_id')[:4000]) picks = rnd.sample(qs, 6)