""" حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (هویت و حساب). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود. """ import logging import socket from unittest import mock from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.cache import cache from django.core.management import call_command from django.test import TestCase, override_settings from django.utils import timezone from rest_framework.test import APIClient from accounts.authentication import issue_access_token from accounts.models import OTPCode, User from config.log_formatters import JSONLogFormatter def _bearer(token): c = APIClient() c.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') return c def _otp_row(phone, code='482913'): OTPCode.objects.create(phone=phone, code=code, expires_at=timezone.now() + timezone.timedelta(minutes=5)) return code class AccountDeletionKeepsLimitsTests(TestCase): def setUp(self): cache.clear() from discount.models import DiscountCode from subscription.models import Plan self.plan = Plan.objects.create(title='ماهانه‌ی آزمایشی', price=45000, duration_days=30) DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1) 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._user_redeems('09121230101') self.assertEqual(first.status_code, 200) 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) 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)) with mock.patch('accounts.views.send_otp_sms'): OTPCode.objects.create(phone=phone, code='777777', expires_at=timezone.now() + timezone.timedelta(minutes=5)) d = _bearer(issue_access_token(user.id)).post('/api/user/delete', {'otp': '777777'}, format='json') self.assertEqual(d.status_code, 200) cache.clear() r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') self.assertEqual(r.status_code, 429) class DisplayNameContactFilterTests(TestCase): def test_contacts_are_removed_and_names_kept(self): from accounts.serializers import clean_display_name as clean for sample in ('0912/123/4567', '۰۹۱۲/۱۲۳/۴۵۶۷', '0912 _123_ 4567', 'ig: ali_kid_2014', 'آیدی: kid2014'): self.assertEqual(clean(sample), '', sample) for name in ('علی رضایی', 'آیدین', 'Sara 2014', 'ایتالیا', 'تلما'): self.assertEqual(clean(name), name) class _Capture(logging.Handler): def __init__(self): super().__init__() self.lines = [] self.setFormatter(JSONLogFormatter()) def emit(self, record): self.lines.append(self.format(record)) FAKE_KEY = '4E6F74415265616C4B6176656E656761724B6579414243444546' @override_settings(KAVENEGAR_API_KEY=FAKE_KEY, KAVENEGAR_OTP_TEMPLATE='SMSVerify') class KavenegarKeyNotLoggedTests(TestCase): """کلیدِ کاوه‌نگار بخشی از آدرس است و متنِ خطای requests آدرس را دارد.""" def test_connection_error_log_has_no_key(self): from accounts import services cap = _Capture() log = logging.getLogger('accounts.services') log.addHandler(cap) def no_dns(*args, **kwargs): raise socket.gaierror(socket.EAI_NONAME, 'network disabled in tests') try: with mock.patch('socket.getaddrinfo', no_dns), self.assertRaises(services.SMSDeliveryError): services.send_verify_lookup(phone='09120000000', template='SMSVerify', token='123456') finally: log.removeHandler(cap) out = '\n'.join(cap.lines) self.assertIn('SMS request to provider failed', out) self.assertNotIn(FAKE_KEY, out) 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) for app_label, codename in perms: staff.user_permissions.add(Permission.objects.get(content_type__app_label=app_label, codename=codename)) return staff class StaffCannotSwapLoginPhoneTests(TestCase): """کارمندی با change_user شماره‌ی کاربر را عوض نمی‌کند؛ وگرنه با شماره‌ی خودش کد می‌گیرد و واردِ آن حساب می‌شود.""" def setUp(self): cache.clear() 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_is_read_only(self): r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', { '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') login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')}, format='json') self.assertNotEqual(login.json().get('userId'), str(self.victim.pk)) class StaffAccountsAdminSuperuserOnlyTests(TestCase): """auth.User/Group پنل: با change_group یا change_user (auth) کارمند خودش را ابرکاربر یا همه‌کاره نمی‌کند.""" def test_change_group_cannot_grant_all_permissions(self): from django.contrib.auth.models import Permission call_command('setup_supervisor_group', verbosity=0) staff = _staff_with(('auth', 'change_group'), ('auth', 'view_group')) group = Group.objects.get(name='Supervisor') staff.groups.add(group) self.client.force_login(staff) r = self.client.post(f'/admin/auth/group/{group.pk}/change/', {'name': 'Supervisor', 'permissions': list(Permission.objects.values_list('pk', flat=True))}) self.assertEqual(r.status_code, 403) staff = get_user_model().objects.get(pk=staff.pk) self.assertFalse(staff.has_perm('accounts.add_otpcode')) def test_change_user_cannot_make_self_superuser(self): staff = _staff_with(('auth', 'change_user'), ('auth', 'view_user')) self.client.force_login(staff) form = self.client.get(f'/admin/auth/user/{staff.pk}/change/') self.assertEqual(form.status_code, 200) self.assertNotContains(form, 'name="is_superuser"') r = self.client.post(f'/admin/auth/user/{staff.pk}/change/', { 'username': staff.username, 'is_active': 'on', 'is_staff': 'on', 'is_superuser': 'on', 'last_login_0': '', 'last_login_1': '', 'date_joined_0': '2026-01-01', 'date_joined_1': '00:00:00'}) self.assertEqual(r.status_code, 403) self.assertFalse(get_user_model().objects.get(pk=staff.pk).is_superuser) class UnicodeInputsAreNormalisedOrRejectedTests(TestCase): """رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCIIو پایه‌ی ناموجود: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا.""" def setUp(self): cache.clear() self.addCleanup(cache.clear) def test_fullwidth_and_math_digits_normalise_to_same_phone(self): from accounts.phone import normalize_phone for variant in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '۰۹۱۲۳۴۵۶۷۸۹', '+98 912 345 6789'): self.assertEqual(normalize_phone(variant), '09123456789', variant) self.assertIsNone(normalize_phone('0912345678²')) def test_non_ascii_otp_is_400_not_500(self): phone = '09120000099' with override_settings(TEST_LOGIN_PHONES={phone: '123456'}): for code in ('²²²²', '١٢٣٤٥٦٧'[:6] + 'x', 'abcd'): r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json') self.assertEqual(r.status_code, 400, code) def test_unknown_or_huge_grade_is_400(self): 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') r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json') self.assertEqual(r.status_code, 400) def test_display_name_nfkc_contacts_removed(self): from accounts.serializers import clean_display_name as clean for sample in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '0912*345*6789', '@kid2014'): self.assertEqual(clean(sample), '', sample) self.assertEqual(clean('علی رضایی'), 'علی رضایی') class StaffCannotMintLoginsTests(TestCase): """کارمندِ غیرابرکاربر با اجازه‌ی add نمی‌تواند OTPCode یا Session دستی بسازد (= ورود به هر شماره).""" def setUp(self): from django.contrib.auth.models import Permission, User as AuthUser self.staff = AuthUser.objects.create_user('staff1', 's@x.c', 'pw12345!', is_staff=True) self.staff.user_permissions.set(Permission.objects.filter( codename__in=['add_otpcode', 'change_otpcode', 'view_otpcode', 'add_session', 'change_session', 'view_session'])) self.root = AuthUser.objects.create_superuser('root1', 'r@x.c', 'pw12345!') def test_staff_gets_403_on_add_pages(self): self.client.force_login(self.staff) for model in ('otpcode', 'session'): self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 403, model) def test_superuser_can_still_add(self): self.client.force_login(self.root) for model in ('otpcode', 'session'): self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 200, model)