Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in

accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:20:10 +03:30
parent 8835e01595
commit 663252a260
61 changed files with 357 additions and 1518 deletions

View File

@@ -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',)

View File

@@ -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):

View File

@@ -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',
),
]

View File

@@ -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)

View File

@@ -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):

View File

@@ -1,5 +1,5 @@
"""
سیگنال ثبت‌نامِ واقعی کاربر (نه guest session). سایر اپ‌ها (مثلاً discount
سیگنال ثبت‌نامِ واقعی کاربر. سایر اپ‌ها (مثلاً discount
برای اختصاص خودکار آفر) به‌جای import کردن مستقیم از accounts، فقط این
سیگنال رو در apps.py خودشون connect می‌کنن؛ این یعنی accounts هیچ‌وقت لازم
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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):

View File

@@ -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']

View File

@@ -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):

View File

@@ -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')

View File

@@ -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'])

View File

@@ -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')

View File

@@ -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)

View File

@@ -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 خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند.