harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit: - phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP, family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated; non-Iranian and landline numbers are rejected before any SMS - cooldown and a daily per-phone cap are counted in the database, so the three gunicorn workers no longer triple them - a global daily OTP SMS cap (default 300) stops SMS pumping - per-IP rates widened for CGNAT / school Wi-Fi (env-overridable) - provider message id is logged; SMS text warns not to share the code Second audit round: an offer no longer shortens a longer paid plan, Zarinpal error bodies no longer 500, island XP reaches the parent report, a non-numeric questionId no longer 500s after the answer is generated. 596 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
شمارههای موجود را به همان شکلِ 09XXXXXXXXX میبرد که serializerها حالا میسازند
|
||||||
|
(accounts/phone.py)؛ وگرنه کسی که با +98… ثبتنام کرده بود دیگر به حسابِ خودش
|
||||||
|
نمیرسید و حسابِ تازه میگرفت.
|
||||||
|
|
||||||
|
ردیفی که شکلِ نرمالش از قبل مالِ کاربرِ دیگری است دست نمیخورد (unique است و
|
||||||
|
ادغامِ دو حساب تصمیمِ انسانی است)؛ ردیفی هم که موبایلِ ایران نیست همانطور میماند.
|
||||||
|
هر دو فقط شمرده و چاپ میشوند. برگشتناپذیر است چون شکلِ قبلی ذخیره نمیشود.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(apps, schema_editor):
|
||||||
|
from accounts.phone import normalize_phone
|
||||||
|
|
||||||
|
User = apps.get_model('accounts', 'User')
|
||||||
|
changed = skipped_taken = skipped_invalid = 0
|
||||||
|
for user in User.objects.exclude(phone__isnull=True).exclude(phone=''):
|
||||||
|
target = normalize_phone(user.phone)
|
||||||
|
if target is None:
|
||||||
|
skipped_invalid += 1
|
||||||
|
elif target != user.phone:
|
||||||
|
if User.objects.filter(phone=target).exists():
|
||||||
|
skipped_taken += 1
|
||||||
|
else:
|
||||||
|
User.objects.filter(pk=user.pk).update(phone=target)
|
||||||
|
changed += 1
|
||||||
|
if changed or skipped_taken or skipped_invalid:
|
||||||
|
print(f'\n normalize phones: changed={changed} taken={skipped_taken} invalid={skipped_invalid}')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0004_otpcode_failed_attempts_user_tokens_valid_after'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(normalize, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
31
accounts/phone.py
Normal file
31
accounts/phone.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
نرمالسازیِ شمارهی موبایلِ ایران به شکلِ یکتای 09XXXXXXXXX.
|
||||||
|
|
||||||
|
چرا: پیش از این `phone` رشتهی خام بود و 0912…، +98912…، 98912… و همان
|
||||||
|
شماره با ارقامِ فارسی چهار «شماره»ی جدا بودند — هر کدام cooldown و سقفِ پیامکِ
|
||||||
|
خودش را داشت و حسابِ جدا میساخت (security-before-production.md، بندِ ۱۷).
|
||||||
|
هرچه شکلِ 09\\d{9} نگیرد رد میشود، پس شمارهی خارجی یا خطِ ثابت هم به
|
||||||
|
کاوهنگار نمیرسد (پیامکِ بینالمللی گران است و هدفِ SMS pumping).
|
||||||
|
|
||||||
|
عمداً بیوابستگی به DRF است تا config/settings.py هم بتواند واردش کند
|
||||||
|
(کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||||
|
_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]')
|
||||||
|
_MOBILE = re.compile(r'09\d{9}')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(value) -> str | None:
|
||||||
|
"""شکلِ یکتای 09XXXXXXXXX، یا None اگر موبایلِ ایران نباشد."""
|
||||||
|
s = _SEPARATORS.sub('', str(value or '').translate(_DIGITS))
|
||||||
|
if s.startswith('+98'):
|
||||||
|
s = '0' + s[3:]
|
||||||
|
elif s.startswith('0098'):
|
||||||
|
s = '0' + s[4:]
|
||||||
|
elif s.startswith('98') and len(s) == 12:
|
||||||
|
s = '0' + s[2:]
|
||||||
|
elif s.startswith('9') and len(s) == 10:
|
||||||
|
s = '0' + s
|
||||||
|
return s if _MOBILE.fullmatch(s) else None
|
||||||
@@ -2,6 +2,8 @@ import re
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .phone import normalize_phone
|
||||||
|
|
||||||
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر (شمارهی تلفن با
|
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر (شمارهی تلفن با
|
||||||
# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم.
|
# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم.
|
||||||
_CONTACT = re.compile(
|
_CONTACT = re.compile(
|
||||||
@@ -15,6 +17,20 @@ def clean_display_name(value: str) -> str:
|
|||||||
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
|
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
|
||||||
|
|
||||||
|
|
||||||
|
class IranMobileField(serializers.CharField):
|
||||||
|
"""CharField که خروجیاش همیشه 09XXXXXXXXX است."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault('max_length', 20)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
phone = normalize_phone(super().to_internal_value(data))
|
||||||
|
if phone is None:
|
||||||
|
raise serializers.ValidationError('شمارهی موبایل معتبر نیست؛ مثلاً 09121234567.')
|
||||||
|
return phone
|
||||||
|
|
||||||
|
|
||||||
# ---- Request serializers ----
|
# ---- Request serializers ----
|
||||||
|
|
||||||
class GuestSessionRequestSerializer(serializers.Serializer):
|
class GuestSessionRequestSerializer(serializers.Serializer):
|
||||||
@@ -23,7 +39,7 @@ class GuestSessionRequestSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class LoginRequestSerializer(serializers.Serializer):
|
class LoginRequestSerializer(serializers.Serializer):
|
||||||
phone = serializers.CharField(max_length=15)
|
phone = IranMobileField()
|
||||||
otp = serializers.CharField(max_length=6)
|
otp = serializers.CharField(max_length=6)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +107,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class RequestOTPRequestSerializer(serializers.Serializer):
|
class RequestOTPRequestSerializer(serializers.Serializer):
|
||||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||||
phone = serializers.CharField(max_length=15)
|
phone = IranMobileField()
|
||||||
|
|
||||||
|
|
||||||
# ---- Response serializers ----
|
# ---- Response serializers ----
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ def send_sms(*, phone: str, message: str) -> None:
|
|||||||
)
|
)
|
||||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||||
|
|
||||||
|
# پذیرفتهشدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ میشود تا اگر کسی گفت «کد
|
||||||
|
# نیامد»، همین شناسه در پنلِ کاوهنگار (گزارشِ ارسال) دنبال شود — مثلاً
|
||||||
|
# شمارهای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمیشود.
|
||||||
|
entries = body.get('entries') if isinstance(body, dict) else None
|
||||||
|
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
|
||||||
|
logger.info(
|
||||||
|
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
|
||||||
|
_phone_suffix(phone), first.get('messageid'), first.get('status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_otp_sms(*, phone: str, code: str) -> None:
|
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ class SendOtpSmsTests(TestCase):
|
|||||||
@override_settings(DEBUG=True)
|
@override_settings(DEBUG=True)
|
||||||
def test_dev_mode_does_not_call_provider(self):
|
def test_dev_mode_does_not_call_provider(self):
|
||||||
with patch('accounts.services.requests.post') as mock_post:
|
with patch('accounts.services.requests.post') as mock_post:
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
mock_post.assert_not_called()
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||||
def test_production_without_api_key_raises_instead_of_silent_success(self):
|
def test_production_without_api_key_raises_instead_of_silent_success(self):
|
||||||
with patch('accounts.services.requests.post') as mock_post:
|
with patch('accounts.services.requests.post') as mock_post:
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
mock_post.assert_not_called()
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
|
||||||
@@ -49,11 +49,11 @@ class SendOtpSmsTests(TestCase):
|
|||||||
'entries': [{'messageid': 1, 'status': 1}],
|
'entries': [{'messageid': 1, 'status': 1}],
|
||||||
}
|
}
|
||||||
with patch('accounts.services.requests.post', return_value=mock_response) as mock_post:
|
with patch('accounts.services.requests.post', return_value=mock_response) as mock_post:
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
mock_post.assert_called_once()
|
mock_post.assert_called_once()
|
||||||
args, kwargs = mock_post.call_args
|
args, kwargs = mock_post.call_args
|
||||||
self.assertIn('test-key', args[0])
|
self.assertIn('test-key', args[0])
|
||||||
self.assertEqual(kwargs['data']['receptor'], '+989120000000')
|
self.assertEqual(kwargs['data']['receptor'], '09120000000')
|
||||||
self.assertIn('1234', kwargs['data']['message'])
|
self.assertIn('1234', kwargs['data']['message'])
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||||
@@ -61,7 +61,7 @@ class SendOtpSmsTests(TestCase):
|
|||||||
mock_response = Mock(status_code=400, text='bad request')
|
mock_response = Mock(status_code=400, text='bad request')
|
||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||||
def test_production_http_200_with_application_level_rejection_raises(self):
|
def test_production_http_200_with_application_level_rejection_raises(self):
|
||||||
@@ -77,7 +77,7 @@ class SendOtpSmsTests(TestCase):
|
|||||||
}
|
}
|
||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||||
def test_production_malformed_provider_body_raises(self):
|
def test_production_malformed_provider_body_raises(self):
|
||||||
@@ -87,20 +87,20 @@ class SendOtpSmsTests(TestCase):
|
|||||||
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
|
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
|
||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||||
def test_production_network_failure_raises(self):
|
def test_production_network_failure_raises(self):
|
||||||
import requests as requests_module
|
import requests as requests_module
|
||||||
with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')):
|
with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')):
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
|
|
||||||
@override_settings(DEBUG=True)
|
@override_settings(DEBUG=True)
|
||||||
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
|
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
|
||||||
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچوقت شمارهتلفنِ کامل رو
|
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچوقت شمارهتلفنِ کامل رو
|
||||||
# نشون بده؛ فقط ۴ رقمِ آخر (شناسهی امن) باید در پیامِ لاگ باشه.
|
# نشون بده؛ فقط ۴ رقمِ آخر (شناسهی امن) باید در پیامِ لاگ باشه.
|
||||||
phone = '+989120000000'
|
phone = '09120000000'
|
||||||
with self.assertLogs('accounts.services', level='INFO') as captured:
|
with self.assertLogs('accounts.services', level='INFO') as captured:
|
||||||
send_otp_sms(phone=phone, code='1234')
|
send_otp_sms(phone=phone, code='1234')
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ class SendOtpSmsTests(TestCase):
|
|||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
message = captured.records[0].getMessage()
|
message = captured.records[0].getMessage()
|
||||||
self.assertNotIn('bad request body secret-ish', message)
|
self.assertNotIn('bad request body secret-ish', message)
|
||||||
self.assertIn('phone_suffix=0000', message)
|
self.assertIn('phone_suffix=0000', message)
|
||||||
@@ -136,7 +136,7 @@ class SendOtpSmsTests(TestCase):
|
|||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
message = captured.records[0].getMessage()
|
message = captured.records[0].getMessage()
|
||||||
self.assertNotIn('not json body secret-ish', message)
|
self.assertNotIn('not json body secret-ish', message)
|
||||||
self.assertIn('phone_suffix=0000', message)
|
self.assertIn('phone_suffix=0000', message)
|
||||||
@@ -154,7 +154,7 @@ class SendOtpSmsTests(TestCase):
|
|||||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||||
with self.assertRaises(OTPDeliveryError):
|
with self.assertRaises(OTPDeliveryError):
|
||||||
send_otp_sms(phone='+989120000000', code='1234')
|
send_otp_sms(phone='09120000000', code='1234')
|
||||||
message = captured.records[0].getMessage()
|
message = captured.records[0].getMessage()
|
||||||
self.assertNotIn('secret-ish detail', message)
|
self.assertNotIn('secret-ish detail', message)
|
||||||
self.assertIn('phone_suffix=0000', message)
|
self.assertIn('phone_suffix=0000', message)
|
||||||
@@ -176,22 +176,23 @@ class RequestOTPViewTests(TestCase):
|
|||||||
def test_dev_mode_returns_otp_in_response_without_provider_call(self):
|
def test_dev_mode_returns_otp_in_response_without_provider_call(self):
|
||||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': '+989120000001'}, format='json',
|
'/api/auth/otp/request', {'phone': '09120000001'}, format='json',
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue(response.data['success'])
|
self.assertTrue(response.data['success'])
|
||||||
self.assertTrue(response.data['devOtp'])
|
self.assertTrue(response.data['devOtp'])
|
||||||
mock_send.assert_called_once()
|
mock_send.assert_called_once()
|
||||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000001').exists())
|
self.assertTrue(OTPCode.objects.filter(phone='09120000001').exists())
|
||||||
|
|
||||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||||
def test_production_delivery_failure_does_not_report_success(self):
|
def test_production_delivery_failure_does_not_report_success(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': '+989120000002'}, format='json',
|
'/api/auth/otp/request', {'phone': '09120000002'}, format='json',
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 503)
|
self.assertEqual(response.status_code, 503)
|
||||||
# OTP row is created before delivery is attempted; only the SMS leg fails.
|
# ردیفِ کدی که هرگز نرسید حذف میشود تا در cooldown و سقفِ روزانهی
|
||||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists())
|
# دیتابیسی شمرده نشود (۲۰۲۶-۰۹-۲۵).
|
||||||
|
self.assertFalse(OTPCode.objects.filter(phone='09120000002').exists())
|
||||||
|
|
||||||
@override_settings(DEBUG=True)
|
@override_settings(DEBUG=True)
|
||||||
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
|
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
|
||||||
@@ -199,11 +200,11 @@ class RequestOTPViewTests(TestCase):
|
|||||||
with patch('accounts.views.secrets.randbelow', return_value=7):
|
with patch('accounts.views.secrets.randbelow', return_value=7):
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': '+989120000003'}, format='json',
|
'/api/auth/otp/request', {'phone': '09120000003'}, format='json',
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data['devOtp'], '000007')
|
self.assertEqual(response.data['devOtp'], '000007')
|
||||||
otp = OTPCode.objects.get(phone='+989120000003')
|
otp = OTPCode.objects.get(phone='09120000003')
|
||||||
self.assertEqual(otp.code, '000007')
|
self.assertEqual(otp.code, '000007')
|
||||||
self.assertEqual(len(otp.code), 6)
|
self.assertEqual(len(otp.code), 6)
|
||||||
self.assertTrue(otp.code.isdigit())
|
self.assertTrue(otp.code.isdigit())
|
||||||
@@ -242,7 +243,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
|||||||
# (RequestOTPPhoneCooldownTests).
|
# (RequestOTPPhoneCooldownTests).
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_second_request_invalidates_first_otp(self):
|
def test_second_request_invalidates_first_otp(self):
|
||||||
phone = '+989121110001'
|
phone = '09121110001'
|
||||||
first_code = self._request_otp(phone, '111111')
|
first_code = self._request_otp(phone, '111111')
|
||||||
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
|
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
|
||||||
self.assertFalse(first_otp.is_used)
|
self.assertFalse(first_otp.is_used)
|
||||||
@@ -254,7 +255,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_newest_otp_still_works_for_login(self):
|
def test_newest_otp_still_works_for_login(self):
|
||||||
phone = '+989121110002'
|
phone = '09121110002'
|
||||||
self._request_otp(phone, '111111')
|
self._request_otp(phone, '111111')
|
||||||
second_code = self._request_otp(phone, '222222')
|
second_code = self._request_otp(phone, '222222')
|
||||||
|
|
||||||
@@ -265,7 +266,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_older_otp_is_rejected_after_new_request(self):
|
def test_older_otp_is_rejected_after_new_request(self):
|
||||||
phone = '+989121110003'
|
phone = '09121110003'
|
||||||
first_code = self._request_otp(phone, '111111')
|
first_code = self._request_otp(phone, '111111')
|
||||||
self._request_otp(phone, '222222')
|
self._request_otp(phone, '222222')
|
||||||
|
|
||||||
@@ -342,7 +343,7 @@ class LoginPhoneRateLimitTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||||
def test_repeated_attempts_for_same_phone_are_blocked(self):
|
def test_repeated_attempts_for_same_phone_are_blocked(self):
|
||||||
phone = '+989123334444'
|
phone = '09123334444'
|
||||||
|
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -374,7 +375,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
|||||||
# (RequestOTPPhoneCooldownTests).
|
# (RequestOTPPhoneCooldownTests).
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
|
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
|
||||||
phone = '+989127778888'
|
phone = '09127778888'
|
||||||
|
|
||||||
# otp_request (IP-محور) پیشفرض 5/hour است؛ برای اینکه فقط
|
# otp_request (IP-محور) پیشفرض 5/hour است؛ برای اینکه فقط
|
||||||
# محدودیتِ شمارهتلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
|
# محدودیتِ شمارهتلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
|
||||||
@@ -394,7 +395,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
|
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
|
||||||
phone = '+989127778889'
|
phone = '09127778889'
|
||||||
|
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
@@ -416,7 +417,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
|
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
|
||||||
phone = '+989127778890'
|
phone = '09127778890'
|
||||||
|
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||||
@@ -458,7 +459,7 @@ class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
|
|||||||
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
|
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
phone = '+989127778891'
|
phone = '09127778891'
|
||||||
barrier = threading.Barrier(2)
|
barrier = threading.Barrier(2)
|
||||||
results = []
|
results = []
|
||||||
results_lock = threading.Lock()
|
results_lock = threading.Lock()
|
||||||
@@ -502,7 +503,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||||
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
|
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
|
||||||
phone = '+989127779001'
|
phone = '09127779001'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
@@ -521,7 +522,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
|
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
|
||||||
# درخواستهایِ بلاکشده توسطِ cooldown هیچوقت به شمارندهی ساعتی
|
# درخواستهایِ بلاکشده توسطِ cooldown هیچوقت به شمارندهی ساعتی
|
||||||
# نمیرسن، سهمیهی ساعتی مصرف نمیشه.
|
# نمیرسن، سهمیهی ساعتی مصرف نمیشه.
|
||||||
phone = '+989127779002'
|
phone = '09127779002'
|
||||||
|
|
||||||
# throttle IP-محورِ otp_request (پیشفرض 5/hour) رو موقتاً باز
|
# throttle IP-محورِ otp_request (پیشفرض 5/hour) رو موقتاً باز
|
||||||
# میکنیم تا اینجا فقط cooldown/سهمیهی ساعتیِ per-phone سنجیده بشه،
|
# میکنیم تا اینجا فقط cooldown/سهمیهی ساعتیِ per-phone سنجیده بشه،
|
||||||
@@ -541,7 +542,11 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
|
|
||||||
# سهمیهی ساعتی هنوز دستنخورده است: بعد از پایانِ cooldown،
|
# سهمیهی ساعتی هنوز دستنخورده است: بعد از پایانِ cooldown،
|
||||||
# همچنان میشه تا سقفِ مجاز درخواست داد.
|
# همچنان میشه تا سقفِ مجاز درخواست داد.
|
||||||
|
# گذشتِ زمان: هم کلیدِ کش و هم cooldownِ دیتابیسی (از expires_at).
|
||||||
cache.delete(f'otp_request_phone_cooldown:{phone}')
|
cache.delete(f'otp_request_phone_cooldown:{phone}')
|
||||||
|
OTPCode.objects.filter(phone=phone).update(
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(seconds=settings.OTP_TTL_SECONDS - 61),
|
||||||
|
)
|
||||||
with patch('accounts.views.send_otp_sms') as mock_send_again:
|
with patch('accounts.views.send_otp_sms') as mock_send_again:
|
||||||
allowed_again = self.client.post(
|
allowed_again = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||||
@@ -551,7 +556,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||||
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
|
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
|
||||||
phone = '+989127779003'
|
phone = '09127779003'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||||
@@ -569,7 +574,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
def test_cooldown_expires_after_configured_window(self):
|
def test_cooldown_expires_after_configured_window(self):
|
||||||
import time
|
import time
|
||||||
|
|
||||||
phone = '+989127779004'
|
phone = '09127779004'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
@@ -588,10 +593,10 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
def test_cooldown_is_per_phone_not_global(self):
|
def test_cooldown_is_per_phone_not_global(self):
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
|
'/api/auth/otp/request', {'phone': '09127779005'}, format='json',
|
||||||
)
|
)
|
||||||
second_response = self.client.post(
|
second_response = self.client.post(
|
||||||
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
|
'/api/auth/otp/request', {'phone': '09127779006'}, format='json',
|
||||||
)
|
)
|
||||||
self.assertEqual(first_response.status_code, 200)
|
self.assertEqual(first_response.status_code, 200)
|
||||||
self.assertEqual(second_response.status_code, 200)
|
self.assertEqual(second_response.status_code, 200)
|
||||||
@@ -604,7 +609,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||||
def test_cooldown_response_reports_retry_after_header_and_body(self):
|
def test_cooldown_response_reports_retry_after_header_and_body(self):
|
||||||
phone = '+989127779008'
|
phone = '09127779008'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
@@ -633,7 +638,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
|||||||
def test_retry_after_counts_down_as_time_passes(self):
|
def test_retry_after_counts_down_as_time_passes(self):
|
||||||
import time
|
import time
|
||||||
|
|
||||||
phone = '+989127779009'
|
phone = '09127779009'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||||
@@ -673,7 +678,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
|||||||
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
|
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
phone = '+989127779007'
|
phone = '09127779007'
|
||||||
barrier = threading.Barrier(2)
|
barrier = threading.Barrier(2)
|
||||||
results = []
|
results = []
|
||||||
results_lock = threading.Lock()
|
results_lock = threading.Lock()
|
||||||
@@ -706,7 +711,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
|||||||
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک میموند.
|
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک میموند.
|
||||||
# این تست تضمین میکنه که با شکستِ ارسال، cooldown پاک میشه و
|
# این تست تضمین میکنه که با شکستِ ارسال، cooldown پاک میشه و
|
||||||
# کاربر میتونه فوراً دوباره تلاش کنه.
|
# کاربر میتونه فوراً دوباره تلاش کنه.
|
||||||
phone = '+989127779010'
|
phone = '09127779010'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
|
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
@@ -727,7 +732,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
|||||||
def test_cooldown_still_active_after_successful_delivery(self):
|
def test_cooldown_still_active_after_successful_delivery(self):
|
||||||
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
|
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
|
||||||
# رفتارِ قبلی فعال میمونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
|
# رفتارِ قبلی فعال میمونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
|
||||||
phone = '+989127779011'
|
phone = '09127779011'
|
||||||
|
|
||||||
with patch('accounts.views.send_otp_sms'):
|
with patch('accounts.views.send_otp_sms'):
|
||||||
first_response = self.client.post(
|
first_response = self.client.post(
|
||||||
@@ -895,22 +900,22 @@ class UserProfileViewTests(TestCase):
|
|||||||
|
|
||||||
def test_returns_existing_profile_fields_for_registered_user(self):
|
def test_returns_existing_profile_fields_for_registered_user(self):
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||||
)
|
)
|
||||||
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
|
Profile.objects.create(user=user, birth_year=2005, parent_phone='09120000002', bio='سلام')
|
||||||
token = issue_access_token(user.id)
|
token = issue_access_token(user.id)
|
||||||
|
|
||||||
response = self._get_profile(token)
|
response = self._get_profile(token)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertEqual(data['phone'], '+989120000001')
|
self.assertEqual(data['phone'], '09120000001')
|
||||||
self.assertEqual(data['name'], 'علی')
|
self.assertEqual(data['name'], 'علی')
|
||||||
self.assertEqual(data['avatar'], 'avatar.png')
|
self.assertEqual(data['avatar'], 'avatar.png')
|
||||||
self.assertEqual(data['isGuest'], False)
|
self.assertEqual(data['isGuest'], False)
|
||||||
self.assertEqual(data['gradeId'], 2)
|
self.assertEqual(data['gradeId'], 2)
|
||||||
self.assertEqual(data['birthYear'], 2005)
|
self.assertEqual(data['birthYear'], 2005)
|
||||||
self.assertEqual(data['parentPhone'], '+989120000002')
|
self.assertEqual(data['parentPhone'], '09120000002')
|
||||||
self.assertEqual(data['bio'], 'سلام')
|
self.assertEqual(data['bio'], 'سلام')
|
||||||
|
|
||||||
def test_does_not_leak_other_users_profile(self):
|
def test_does_not_leak_other_users_profile(self):
|
||||||
@@ -1653,7 +1658,7 @@ class TestLoginPhoneParsingTests(TestCase):
|
|||||||
def test_valid_pairs_are_parsed(self):
|
def test_valid_pairs_are_parsed(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._parse(' 09120000001:834512 , +989120000002:110947 '),
|
self._parse(' 09120000001:834512 , +989120000002:110947 '),
|
||||||
{'09120000001': '834512', '+989120000002': '110947'},
|
{'09120000001': '834512', '09120000002': '110947'},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_value_yields_no_test_phones(self):
|
def test_empty_value_yields_no_test_phones(self):
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class OtpTests(TestCase):
|
|||||||
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||||
|
|
||||||
def test_a_code_dies_after_five_wrong_guesses(self):
|
def test_a_code_dies_after_five_wrong_guesses(self):
|
||||||
phone = '+989121234001'
|
phone = '09121234001'
|
||||||
self._request(phone)
|
self._request(phone)
|
||||||
for guess in ('000001', '000002', '000003', '000004', '000005'):
|
for guess in ('000001', '000002', '000003', '000004', '000005'):
|
||||||
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
|
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
|
||||||
@@ -68,7 +68,7 @@ class OtpTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
|
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
|
||||||
TEST_LOGIN_PHONES={'+989120000101': '4321'})
|
TEST_LOGIN_PHONES={'09120000101': '4321'})
|
||||||
class GuestSignupKeepsProgressTests(TestCase):
|
class GuestSignupKeepsProgressTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
cache.clear()
|
cache.clear()
|
||||||
@@ -85,25 +85,25 @@ class GuestSignupKeepsProgressTests(TestCase):
|
|||||||
def test_the_guest_becomes_the_registered_user(self):
|
def test_the_guest_becomes_the_registered_user(self):
|
||||||
with patch('accounts.views.secrets.randbelow', return_value=654321), \
|
with patch('accounts.views.secrets.randbelow', return_value=654321), \
|
||||||
patch('accounts.views.send_otp_sms'):
|
patch('accounts.views.send_otp_sms'):
|
||||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234002'}, format='json')
|
self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json')
|
||||||
body = self._login('+989121234002', '654321').json()
|
body = self._login('09121234002', '654321').json()
|
||||||
|
|
||||||
self.assertEqual(int(body['userId']), self.guest_id)
|
self.assertEqual(int(body['userId']), self.guest_id)
|
||||||
user = User.objects.get(pk=self.guest_id)
|
user = User.objects.get(pk=self.guest_id)
|
||||||
self.assertEqual((user.phone, user.is_guest), ('+989121234002', False))
|
self.assertEqual((user.phone, user.is_guest), ('09121234002', False))
|
||||||
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
|
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
|
||||||
|
|
||||||
def test_an_existing_account_is_not_merged(self):
|
def test_an_existing_account_is_not_merged(self):
|
||||||
existing = User.objects.create(phone='+989121234003', is_guest=False)
|
existing = User.objects.create(phone='09121234003', is_guest=False)
|
||||||
with patch('accounts.views.secrets.randbelow', return_value=111222), \
|
with patch('accounts.views.secrets.randbelow', return_value=111222), \
|
||||||
patch('accounts.views.send_otp_sms'):
|
patch('accounts.views.send_otp_sms'):
|
||||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234003'}, format='json')
|
self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json')
|
||||||
body = self._login('+989121234003', '111222').json()
|
body = self._login('09121234003', '111222').json()
|
||||||
self.assertEqual(int(body['userId']), existing.id)
|
self.assertEqual(int(body['userId']), existing.id)
|
||||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||||
|
|
||||||
def test_demo_test_phones_never_absorb_a_guest(self):
|
def test_demo_test_phones_never_absorb_a_guest(self):
|
||||||
body = self._login('+989120000101', '4321').json()
|
body = self._login('09120000101', '4321').json()
|
||||||
self.assertNotEqual(int(body['userId']), self.guest_id)
|
self.assertNotEqual(int(body['userId']), self.guest_id)
|
||||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||||
|
|
||||||
|
|||||||
130
accounts/tests_otp_limits.py
Normal file
130
accounts/tests_otp_limits.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
نرمالسازیِ شماره و سقفهای دیتابیسیِ پیامکِ OTP (۲۰۲۶-۰۹-۲۵، همان روزی که
|
||||||
|
کلیدِ کاوهنگار وصل شد). security-before-production.md بندِ ۱۷.
|
||||||
|
|
||||||
|
کش در هر تست پاک میشود تا رفتارِ «workerِ دیگر» (کشِ خالی، دیتابیسِ مشترک)
|
||||||
|
شبیهسازی شود. همزمانیِ واقعیِ دو پروسهی gunicorn تست نشده است.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from accounts.models import OTPCode, User
|
||||||
|
from accounts.services import send_sms
|
||||||
|
|
||||||
|
|
||||||
|
def _request(phone):
|
||||||
|
return APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={})
|
||||||
|
class PhoneNormalizationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_other_spellings_of_the_same_number_share_one_cooldown(self):
|
||||||
|
# همان سنجهای که security-before-production.md برای بستنِ بندِ ۱۷ نوشته.
|
||||||
|
with patch('accounts.views.send_otp_sms') as send:
|
||||||
|
self.assertEqual(_request('09121112233').status_code, 200)
|
||||||
|
for spelling in ('+989121112233', '989121112233', '۰۹۱۲۱۱۱۲۲۳۳', '0912 111 2233'):
|
||||||
|
self.assertEqual(_request(spelling).status_code, 429, spelling)
|
||||||
|
send.assert_called_once()
|
||||||
|
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)
|
||||||
|
with patch('accounts.views.secrets.randbelow', return_value=246810), \
|
||||||
|
patch('accounts.views.send_otp_sms'):
|
||||||
|
_request('+989121112244')
|
||||||
|
body = APIClient().post('/api/auth/login', {'phone': '۰۹۱۲۱۱۱۲۲۴۴', 'otp': '246810'},
|
||||||
|
format='json').json()
|
||||||
|
self.assertEqual(User.objects.filter(phone__endswith='9121112244').count(), 1)
|
||||||
|
self.assertEqual(body['userId'], str(User.objects.get(phone='09121112244').id))
|
||||||
|
|
||||||
|
def test_non_mobile_numbers_are_rejected_before_any_sms(self):
|
||||||
|
with patch('accounts.views.send_otp_sms') as send:
|
||||||
|
for bad in ('+14155552671', '02188888888', '0912123', 'abc'):
|
||||||
|
self.assertEqual(_request(bad).status_code, 400, bad)
|
||||||
|
send.assert_not_called()
|
||||||
|
self.assertFalse(OTPCode.objects.exists())
|
||||||
|
|
||||||
|
@override_settings(TEST_LOGIN_PHONES={'09120000101': '4321'})
|
||||||
|
def test_demo_phone_matches_in_any_spelling(self):
|
||||||
|
response = APIClient().post('/api/auth/login', {'phone': '+989120000101', 'otp': '4321'},
|
||||||
|
format='json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={}, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||||
|
class DatabaseBackedLimitTests(TestCase):
|
||||||
|
"""کشِ LocMem مالِ هر worker است؛ این سقفها باید با کشِ خالی هم بمانند."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_cooldown_holds_on_a_worker_with_an_empty_cache(self):
|
||||||
|
with patch('accounts.views.send_otp_sms') as send:
|
||||||
|
self.assertEqual(_request('09121112255').status_code, 200)
|
||||||
|
cache.clear() # درخواستِ بعدی به workerِ دیگری میرسد
|
||||||
|
response = _request('09121112255')
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
self.assertTrue(1 <= response.json()['retryAfter'] <= 120)
|
||||||
|
send.assert_called_once()
|
||||||
|
|
||||||
|
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_REQUEST_PHONE_DAILY_MAX=3)
|
||||||
|
def test_daily_cap_per_phone(self):
|
||||||
|
with patch('accounts.views.send_otp_sms') as send:
|
||||||
|
statuses = []
|
||||||
|
for _ in range(5):
|
||||||
|
cache.clear() # سقفِ ساعتیِ کش را کنار میگذارد؛ فقط سقفِ روزانه بماند
|
||||||
|
statuses.append(_request('09121112266').status_code)
|
||||||
|
self.assertEqual(statuses, [200, 200, 200, 429, 429])
|
||||||
|
self.assertEqual(send.call_count, 3)
|
||||||
|
|
||||||
|
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||||
|
def test_codes_older_than_a_day_do_not_count(self):
|
||||||
|
OTPCode.objects.bulk_create([
|
||||||
|
OTPCode(phone='09121112277', code='000000',
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=2))
|
||||||
|
for _ in range(20)
|
||||||
|
])
|
||||||
|
with patch('accounts.views.send_otp_sms'):
|
||||||
|
self.assertEqual(_request('09121112277').status_code, 200)
|
||||||
|
|
||||||
|
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_SMS_GLOBAL_DAILY_MAX=2)
|
||||||
|
def test_global_daily_cap_stops_sms_pumping(self):
|
||||||
|
with patch('accounts.views.send_otp_sms') as send:
|
||||||
|
self.assertEqual(_request('09121110001').status_code, 200)
|
||||||
|
self.assertEqual(_request('09121110002').status_code, 200)
|
||||||
|
with self.assertLogs('accounts.views', level='ERROR'):
|
||||||
|
response = _request('09121110003')
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertEqual(send.call_count, 2)
|
||||||
|
self.assertFalse(OTPCode.objects.filter(phone='09121110003').exists())
|
||||||
|
|
||||||
|
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k')
|
||||||
|
def test_failed_delivery_frees_the_cooldown_and_quota(self):
|
||||||
|
with patch('accounts.services.requests.post', side_effect=requests.ConnectionError('down')):
|
||||||
|
self.assertEqual(_request('09121112288').status_code, 503)
|
||||||
|
cache.clear()
|
||||||
|
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
|
||||||
|
'return': {'status': 200}, 'entries': [{'messageid': 1, 'status': 1}]}})()
|
||||||
|
with patch('accounts.services.requests.post', return_value=ok):
|
||||||
|
self.assertEqual(_request('09121112288').status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderLoggingTests(TestCase):
|
||||||
|
@override_settings(KAVENEGAR_API_KEY='k')
|
||||||
|
def test_accepted_message_logs_its_id_not_its_text(self):
|
||||||
|
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
|
||||||
|
'return': {'status': 200}, 'entries': [{'messageid': 8792343, 'status': 1}]}})()
|
||||||
|
with patch('accounts.services.requests.post', return_value=ok), \
|
||||||
|
self.assertLogs('accounts.services', level='INFO') as logs:
|
||||||
|
send_sms(phone='09121112299', message='کد ورود شما: 123456')
|
||||||
|
joined = '\n'.join(logs.output)
|
||||||
|
self.assertIn('messageid=8792343', joined)
|
||||||
|
self.assertNotIn('123456', joined)
|
||||||
@@ -186,6 +186,8 @@ class RequestOTPView(APIView):
|
|||||||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||||
|
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
|
||||||
|
# املای دیگرِ همان شماره سهمِ جدا نمیگیرد.
|
||||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||||
attempt_count = 1
|
attempt_count = 1
|
||||||
@@ -207,25 +209,62 @@ class RequestOTPView(APIView):
|
|||||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# سقفهای دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
|
||||||
|
# workerاند (روی سرور ×۳). اینها از ردیفهای OTPCode شمرده میشوند و بینِ
|
||||||
|
# workerها مشترکاند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
|
||||||
|
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنشاند؛ با
|
||||||
|
# transaction_mode=IMMEDIATE دو درخواستِ همزمان پشتِ هم اجرا میشوند
|
||||||
|
# (همزمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
|
||||||
|
# فراخوانیِ کاوهنگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
|
||||||
|
now = timezone.now()
|
||||||
|
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
|
||||||
|
day_window_expiry = now - timezone.timedelta(days=1) + ttl
|
||||||
|
with transaction.atomic():
|
||||||
|
phone_codes = OTPCode.objects.filter(phone=phone)
|
||||||
|
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
|
||||||
|
if cooldown_seconds and last_expiry is not None:
|
||||||
|
elapsed = (now - (last_expiry - ttl)).total_seconds()
|
||||||
|
if elapsed < cooldown_seconds:
|
||||||
|
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||||
|
'retryAfter': retry_after,
|
||||||
|
},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
headers={'Retry-After': str(retry_after)},
|
||||||
|
)
|
||||||
|
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
|
||||||
|
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
|
||||||
|
return Response(
|
||||||
|
{'detail': 'سقفِ روزانهی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
|
||||||
|
# یا حملهی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
|
||||||
|
logger.error(
|
||||||
|
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
|
||||||
|
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
# فقط آخرین کد صادرشده معتبر است.
|
# فقط آخرین کد صادرشده معتبر است.
|
||||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||||
|
|
||||||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||||
OTPCode.objects.create(
|
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||||||
phone=phone,
|
|
||||||
code=code,
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_otp_sms(phone=phone, code=code)
|
send_otp_sms(phone=phone, code=code)
|
||||||
except OTPDeliveryError:
|
except OTPDeliveryError:
|
||||||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
|
||||||
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
# حذف میکنیم تا در cooldown و سقفِ روزانهی دیتابیسی شمرده نشه.
|
||||||
|
# سقفِ ساعتیِ کش و throttleِ IP دستنخورده میمونن (محدودیتِ حجم).
|
||||||
cache.delete(phone_cooldown_key)
|
cache.delete(phone_cooldown_key)
|
||||||
|
otp_row.delete()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||||
@@ -380,9 +419,13 @@ class LogoutView(APIView):
|
|||||||
"""
|
"""
|
||||||
POST /api/auth/logout — خروج از حساب.
|
POST /api/auth/logout — خروج از حساب.
|
||||||
|
|
||||||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و همهی access
|
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و access tokenهای
|
||||||
tokenهای صادرشده تا این لحظه رد میشوند. دستگاههای دیگر با refreshِ
|
صادرشده *پیش از ثانیهی خروج* رد میشوند. دستگاههای دیگر با refreshِ
|
||||||
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||||||
|
|
||||||
|
⚠️ مقایسه ثانیهای است: توکنی که در همان ثانیهی خروج صادر شده معتبر
|
||||||
|
میماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
|
||||||
|
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
|
|
||||||
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمیبرد و پورسانتی
|
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمیبرد و پورسانتی
|
||||||
حساب نمیشد؛ برنامهی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
|
حساب نمیشد؛ برنامهی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
|
||||||
پرداختشدهی کاربرِ معرفیشده Conversion را `paid` میکند و
|
پرداختشدهی کاربرِ معرفیشده *بعد از ثبتِ کد* Conversion را `paid` میکند و
|
||||||
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت میکند.
|
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت میکند.
|
||||||
|
|
||||||
|
⚠️ «کاربرِ تازه» بررسی نمیشود: مشتریِ قدیمی هم هر وقت بخواهد کد ثبت میکند
|
||||||
|
و تمدیدِ بعدیاش پورسانت میسازد (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). جلوی
|
||||||
|
معرفیِ خود با شمارهی دوم هم چیزی نیست.
|
||||||
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
|
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
|
||||||
|
|
||||||
ثبتنام یا ورودِ مهمان هیچ پورسانتی نمیسازد؛ فقط پول جابهجا شدنِ واقعی.
|
ثبتنام یا ورودِ مهمان هیچ پورسانتی نمیسازد؛ فقط پول جابهجا شدنِ واقعی.
|
||||||
|
|||||||
@@ -98,12 +98,21 @@ def _is_db_ref_id(value) -> bool:
|
|||||||
این تابع فقط تشخیص میده که آیا مقدار برای ذخیرهسازی در همچین
|
این تابع فقط تشخیص میده که آیا مقدار برای ذخیرهسازی در همچین
|
||||||
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
|
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
|
||||||
(اونجا همیشه مقدارِ خامِ اصلی پاس داده میشه).
|
(اونجا همیشه مقدارِ خامِ اصلی پاس داده میشه).
|
||||||
|
|
||||||
|
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد میشود؛ پیش از
|
||||||
|
۲۰۲۶-۰۹-۲۵ نمیشد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ میداد و
|
||||||
|
سهمِ روزانه مصرفشده میماند.
|
||||||
"""
|
"""
|
||||||
if value in (None, ''):
|
if value in (None, ''):
|
||||||
return False
|
return False
|
||||||
return str(value).strip().lstrip('-').isdigit()
|
return str(value).strip().lstrip('-').isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_ref_or_none(value):
|
||||||
|
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
|
||||||
|
return value if _is_db_ref_id(value) else None
|
||||||
|
|
||||||
|
|
||||||
def _conversation_history(conversation, before_message) -> list[dict]:
|
def _conversation_history(conversation, before_message) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
||||||
@@ -419,7 +428,7 @@ class AskAIView(APIView):
|
|||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
text=answer_text,
|
text=answer_text,
|
||||||
question_id=v.get('questionId') or None,
|
question_id=_db_ref_or_none(v.get('questionId')),
|
||||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -558,7 +567,7 @@ class AskAIView(APIView):
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
assistant_message = Message.objects.create(
|
assistant_message = Message.objects.create(
|
||||||
conversation=conversation, role='assistant', text=answer_text,
|
conversation=conversation, role='assistant', text=answer_text,
|
||||||
question_id=v.get('questionId') or None,
|
question_id=_db_ref_or_none(v.get('questionId')),
|
||||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
)
|
)
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
|
|||||||
@@ -422,9 +422,13 @@ REST_FRAMEWORK = {
|
|||||||
# `/api/health` در `clientIp` نشان میدهد سرور چه IPای میبیند.
|
# `/api/health` در `clientIp` نشان میدهد سرور چه IPای میبیند.
|
||||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
|
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
|
||||||
'DEFAULT_THROTTLE_RATES': {
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
'otp_request': '5/hour',
|
# سقفهای per-IP عمداً گشادهاند: پشتِ CGNATِ اپراتورهای موبایل، وایفایِ
|
||||||
'otp_login': '10/hour',
|
# مدرسه یا شبکهی یک دفتر، دهها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر
|
||||||
'guest_session': '20/hour',
|
# میتوانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایینتر و
|
||||||
|
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
||||||
|
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
|
||||||
|
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
||||||
|
'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'),
|
||||||
'bazaar_verify': '10/hour',
|
'bazaar_verify': '10/hour',
|
||||||
'ai_ask': '20/hour',
|
'ai_ask': '20/hour',
|
||||||
'rag_ingest': '30/hour',
|
'rag_ingest': '30/hour',
|
||||||
@@ -600,6 +604,20 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
|||||||
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
||||||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||||
|
|
||||||
|
# عمرِ هر کدِ ورود. سقفهای دیتابیسیِ پایین زمانِ صدور را از expires_at منهای
|
||||||
|
# همین مقدار میخوانند، پس عوض کردنش آن سقفها را هم (برای کدهای قدیمی) جابهجا میکند.
|
||||||
|
OTP_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
# سقفهایی که در دیتابیس شمرده میشوند، نه در کش. کشِ پیشفرض LocMem و مالِ
|
||||||
|
# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه
|
||||||
|
# برابرند؛ این دو بینِ همهی workerها مشترکاند و بدونِ Redis هم درست کار میکنند.
|
||||||
|
# - روزانهی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز.
|
||||||
|
# - روزانهی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوهنگار اگر کسی با شمارههای تصادفی
|
||||||
|
# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد؛
|
||||||
|
# با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
||||||
|
OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10'))
|
||||||
|
OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300'))
|
||||||
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل میشود (شمارش در دیتابیس).
|
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل میشود (شمارش در دیتابیس).
|
||||||
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
|
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
|
||||||
OTP_MAX_FAILED_ATTEMPTS = 5
|
OTP_MAX_FAILED_ATTEMPTS = 5
|
||||||
@@ -617,6 +635,8 @@ DEV_MASTER_OTP = '0000' if DEBUG else None
|
|||||||
# را normalize نمیکند (accounts/serializers.py فقط CharField است)، پس
|
# را normalize نمیکند (accounts/serializers.py فقط CharField است)، پس
|
||||||
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
|
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
|
||||||
def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||||||
|
from accounts.phone import normalize_phone
|
||||||
|
|
||||||
pairs = {}
|
pairs = {}
|
||||||
for item in raw.split(','):
|
for item in raw.split(','):
|
||||||
item = item.strip()
|
item = item.strip()
|
||||||
@@ -633,7 +653,14 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
|||||||
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
|
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
|
||||||
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
|
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
|
||||||
)
|
)
|
||||||
pairs[phone] = code
|
# همان نرمالسازیِ ورودیِ API (accounts/phone.py)، وگرنه شمارهای که
|
||||||
|
# اینجا +98… نوشته شده با ورودیِ نرمالشدهی 09… هرگز جور نمیشد.
|
||||||
|
normalized = normalize_phone(phone)
|
||||||
|
if normalized is None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f'TEST_LOGIN_PHONES شمارهی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512'
|
||||||
|
)
|
||||||
|
pairs[normalized] = code
|
||||||
return pairs
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
@@ -646,7 +673,11 @@ TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES',
|
|||||||
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
|
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
|
||||||
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
|
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
|
||||||
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
|
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
|
||||||
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
|
# جملهی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامکهای بانکی)؛ کلِ متن زیرِ
|
||||||
|
# ۷۰ نویسه میماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه.
|
||||||
|
OTP_SMS_TEMPLATE = os.environ.get(
|
||||||
|
'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچکس ندهید.',
|
||||||
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Content progress (NodeProgress status)
|
# Content progress (NodeProgress status)
|
||||||
|
|||||||
140
config/tests_audit_round2.py
Normal file
140
config/tests_audit_round2.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵: ایرادهایی که بازتولید و بسته شدند.
|
||||||
|
|
||||||
|
هر تست همان سناریویی را اجرا میکند که پیشتر خراب بود. ایرادهایی که عمداً
|
||||||
|
باز ماندند (تصمیمِ محصولی یا خطرِ شکستنِ فرانت) در docs/security-before-production.md
|
||||||
|
و کامنتِ همان کدها ثبتاند.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
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 User
|
||||||
|
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||||
|
from discount.models import Offer
|
||||||
|
from family.models import FamilyLink
|
||||||
|
from payment.models import Order
|
||||||
|
from payment.services import apply_order_payment_result
|
||||||
|
from subscription.models import Plan, Subscription
|
||||||
|
|
||||||
|
|
||||||
|
def _client(user):
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create(phone='09120008001', is_guest=False)
|
||||||
|
self.client = _client(self.user)
|
||||||
|
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def buy(self, client=None):
|
||||||
|
response = (client or self.client).post(
|
||||||
|
'/api/subscription/purchase', {'planId': str(self.year.id)}, format='json')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
return Order.objects.get(pk=response.json()['orderId'])
|
||||||
|
|
||||||
|
def pay(self, order, ref):
|
||||||
|
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
|
||||||
|
new_status='paid', event='test')
|
||||||
|
|
||||||
|
|
||||||
|
class OfferNeverShortensSubscription(_Base):
|
||||||
|
def test_trial_code_after_a_paid_year_keeps_the_year(self):
|
||||||
|
self.pay(self.buy(), ref='R1')
|
||||||
|
before = Subscription.objects.get(user_id=self.user.id).expires_at
|
||||||
|
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
|
||||||
|
response = self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
|
||||||
|
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')
|
||||||
|
self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
|
||||||
|
sub = Subscription.objects.get(user_id=self.user.id)
|
||||||
|
self.assertEqual(sub.status, 'active')
|
||||||
|
self.assertIn((sub.expires_at - timezone.now()).days, (6, 7))
|
||||||
|
|
||||||
|
def test_free_full_still_upgrades_a_paid_year_to_unlimited(self):
|
||||||
|
self.pay(self.buy(), ref='R3')
|
||||||
|
Offer.objects.create(name='رایگانِ کامل', kind='free_full', code='FULL')
|
||||||
|
self.client.post('/api/discount/offers/redeem', {'code': 'FULL'}, format='json')
|
||||||
|
self.assertIsNone(Subscription.objects.get(user_id=self.user.id).expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ZARINPAL_MERCHANT_ID='x' * 36, ZARINPAL_REDIRECT_URL='https://app.example/')
|
||||||
|
class ZarinpalErrorBodyReturnsToApp(_Base):
|
||||||
|
def test_documented_error_body_redirects_instead_of_500(self):
|
||||||
|
order = self.buy()
|
||||||
|
Order.objects.filter(pk=order.pk).update(gateway_authority='A' * 36)
|
||||||
|
fake = MagicMock(status_code=200)
|
||||||
|
fake.json.return_value = {'data': [], 'errors': {'code': -51, 'message': 'failed'}}
|
||||||
|
with patch('payment.zarinpal.requests.post', return_value=fake):
|
||||||
|
response = APIClient().get('/api/payment/zarinpal/callback',
|
||||||
|
{'Authority': 'A' * 36, 'Status': 'OK'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn('status=failed', response['Location'])
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyReportCountsIslands(TestCase):
|
||||||
|
def test_island_quiz_xp_reaches_the_parent(self):
|
||||||
|
cache.clear()
|
||||||
|
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||||
|
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
|
||||||
|
title='جزیره', order=1)
|
||||||
|
child = User.objects.create(phone='09120008002', grade_id=grade.id)
|
||||||
|
child_client = _client(child)
|
||||||
|
for i in range(4):
|
||||||
|
question = Question.objects.create(
|
||||||
|
lesson=Lesson.objects.create(node=node, title=f'درس {i}'),
|
||||||
|
type='multiple_choice', prompt=f'سوال {i}')
|
||||||
|
right = Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||||
|
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||||||
|
child_client.post(f'/api/questions/{question.id}/answer', {'selectedOptionId': right.id},
|
||||||
|
format='json')
|
||||||
|
child_client.post(f'/api/nodes/{node.id}/quiz/complete',
|
||||||
|
{'correctCount': 4, 'totalQuestions': 4}, format='json')
|
||||||
|
|
||||||
|
parent = User.objects.create(phone='09120008003')
|
||||||
|
FamilyLink.objects.create(parent_user_id=parent.id, child_user_id=child.id,
|
||||||
|
child_phone=child.phone, invite_code='654321',
|
||||||
|
status='accepted', accepted_at=timezone.now())
|
||||||
|
report = _client(parent).get('/api/family/weekly-report').json()
|
||||||
|
self.assertEqual(report['xpEarned'], 40)
|
||||||
|
self.assertEqual(report['islandsCompleted'], 1)
|
||||||
|
self.assertEqual(report['lessonsCompleted'], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class AskWithNonNumericQuestionId(TestCase):
|
||||||
|
def test_answer_is_saved_not_500(self):
|
||||||
|
cache.clear()
|
||||||
|
user = User.objects.create(phone='09120008004')
|
||||||
|
response = _client(user).post('/api/ai/ask',
|
||||||
|
{'userQuestion': 'کسر چیست؟', 'questionId': 'q-12'},
|
||||||
|
format='json')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
from ai_tutor.models import Message
|
||||||
|
self.assertIsNone(Message.objects.get(pk=response.json()['messageId']).question_id)
|
||||||
@@ -76,7 +76,13 @@ def node_playable_lesson_ids(node) -> list[int]:
|
|||||||
|
|
||||||
|
|
||||||
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
|
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
|
||||||
"""تعدادِ سوالهای متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده."""
|
"""
|
||||||
|
تعدادِ سوالهای متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده.
|
||||||
|
|
||||||
|
«درست» یعنی *هر* جوابِ درست، حتی بعد از جوابِ غلط روی همان سوال. چون
|
||||||
|
جوابِ غلط `correctOptionId` را برمیگرداند و تکرارِ جواب آزاد است، این عدد
|
||||||
|
جلوی «جواب نداده امتیاز گرفتن» را میگیرد، نه «غلط زدن و بعد درست زدن».
|
||||||
|
"""
|
||||||
from .models import UserAnswer
|
from .models import UserAnswer
|
||||||
|
|
||||||
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
|
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
|
||||||
|
|||||||
@@ -514,8 +514,9 @@ class LessonCompleteView(APIView):
|
|||||||
# میتونست بهجای هر کاربرِ دیگری xp/coins ثبت کنه.
|
# میتونست بهجای هر کاربرِ دیگری xp/coins ثبت کنه.
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره؛ برای همینه که
|
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره؛ برای همینه که
|
||||||
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
|
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ در
|
||||||
# (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار میسازه.
|
# settings (`lesson_complete`، الان ۱۲۰/hour) کمتر از question_answer است
|
||||||
|
# چون این endpoint رکوردِ ماندگار میسازه.
|
||||||
throttle_scope = 'lesson_complete'
|
throttle_scope = 'lesson_complete'
|
||||||
throttle_classes = [GuestAwareContentThrottle]
|
throttle_classes = [GuestAwareContentThrottle]
|
||||||
|
|
||||||
@@ -554,6 +555,9 @@ class LessonCompleteView(APIView):
|
|||||||
# امتیاز فقط برای جوابهایی که واقعاً ثبت شدهاند. پیشتر عددِ کلاینت
|
# امتیاز فقط برای جوابهایی که واقعاً ثبت شدهاند. پیشتر عددِ کلاینت
|
||||||
# (فقط با سقفِ تعدادِ سوالها) پذیرفته میشد، پس صدا زدنِ مستقیمِ همین
|
# (فقط با سقفِ تعدادِ سوالها) پذیرفته میشد، پس صدا زدنِ مستقیمِ همین
|
||||||
# endpoint برای هر درس، بیجواب دادن، امتیاز و جدولِ رتبهبندی را پر میکرد.
|
# endpoint برای هر درس، بیجواب دادن، امتیاز و جدولِ رتبهبندی را پر میکرد.
|
||||||
|
# ⚠️ جلوی «غلط بزن، کلید را ببین، درست بزن» را نمیگیرد
|
||||||
|
# (verified_correct_count را ببینید)، و همین جوابها در پایانِ آزمونِ
|
||||||
|
# جزیره دوباره امتیاز میگیرند. هر دو در بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید شد.
|
||||||
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
|
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
|
||||||
|
|
||||||
# ratio همیشه بر اساس actual_question_count محاسبه میشود، نه totalQuestions ارسالی کلاینت.
|
# ratio همیشه بر اساس actual_question_count محاسبه میشود، نه totalQuestions ارسالی کلاینت.
|
||||||
@@ -883,9 +887,12 @@ class NodeQuizCompleteView(APIView):
|
|||||||
correct_count = serializer.validated_data['correctCount']
|
correct_count = serializer.validated_data['correctCount']
|
||||||
total_questions = serializer.validated_data['totalQuestions']
|
total_questions = serializer.validated_data['totalQuestions']
|
||||||
|
|
||||||
# سقفها از روی خودِ مبحث، نه از روی حرفِ کلاینت، تا امتیازِ متورم
|
# سقفها از روی خودِ مبحث، نه از روی حرفِ کلاینت: آزمون بیشتر از
|
||||||
# ثبت نشود: آزمون بیشتر از استخرِ سوالهای مبحث (و سقفِ NodeQuizView)
|
# استخرِ سوالهای مبحث (و سقفِ NodeQuizView) سوال ندارد، و پاسخِ درست
|
||||||
# سوال ندارد، و پاسخِ درست بیشتر از سوالها نمیشود.
|
# بیشتر از سوالها نمیشود.
|
||||||
|
# ⚠️ سقف «کلِ استخر» است (تا ۲۰)، نه آزمونِ چهارسوالیای که واقعاً داده
|
||||||
|
# شد؛ جوابهای درستِ مسیرِ درسها هم اینجا دوباره شمرده میشوند. قفل بودنِ
|
||||||
|
# جزیره هم بررسی نمیشود: هر جزیرهای با correctCount=0 «تمام» میشود.
|
||||||
pool = min(
|
pool = min(
|
||||||
Question.objects.filter(lesson__node_id=node_id).count(),
|
Question.objects.filter(lesson__node_id=node_id).count(),
|
||||||
NodeQuizView.MAX_SIZE,
|
NodeQuizView.MAX_SIZE,
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
|||||||
|
|
||||||
|
|
||||||
def _is_usable(discount: DiscountCode) -> bool:
|
def _is_usable(discount: DiscountCode) -> bool:
|
||||||
|
# ⚠️ هر Redemption شمرده میشود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس
|
||||||
|
# سبدِ رهاشده سهمِ max_uses را برای همیشه میسوزاند و خودِ همان کاربر هم
|
||||||
|
# روی سفارشِ تازهاش دیگر نمیتواند کد بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
|
||||||
return bool(
|
return bool(
|
||||||
discount.redemptions.count() < discount.max_uses
|
discount.redemptions.count() < discount.max_uses
|
||||||
and (discount.expires_at is None or discount.expires_at >= timezone.now())
|
and (discount.expires_at is None or discount.expires_at >= timezone.now())
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ PaymentRequest را میزند، `Order.gateway_authority` را ذخیره م
|
|||||||
|
|
||||||
کاربرِ واردشده با شماره کدِ معرف میگیرد (`GET /api/affiliate/me`) و کاربرِ
|
کاربرِ واردشده با شماره کدِ معرف میگیرد (`GET /api/affiliate/me`) و کاربرِ
|
||||||
تازه کدِ دیگری را ثبت میکند (`POST /api/affiliate/track`). **پورسانت فقط روی
|
تازه کدِ دیگری را ثبت میکند (`POST /api/affiliate/track`). **پورسانت فقط روی
|
||||||
اولین پرداختِ واقعیِ کاربرِ معرفیشده** حساب میشود
|
اولین پرداختِ واقعیِ کاربرِ معرفیشده پس از ثبتِ کد** حساب میشود. تازه بودنِ
|
||||||
|
کاربر سنجیده نمیشود: مشتریِ قدیمی هم میتواند بعداً کد ثبت کند و تمدیدش پورسانت
|
||||||
|
بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵)
|
||||||
(`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت):
|
(`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت):
|
||||||
`commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` میشود.
|
`commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` میشود.
|
||||||
مهمان نه کد میگیرد نه معرفیشده حساب میشود (۴۰۳). تسویهی پورسانت با معرف هنوز
|
مهمان نه کد میگیرد نه معرفیشده حساب میشود (۴۰۳). تسویهی پورسانت با معرف هنوز
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
|
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
|
||||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
||||||
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
||||||
|
| ۱۷ | ~~شمارهی موبایل نرمال نمیشود؛ سقفهای per-phone با املای دیگر دور زده میشوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد |
|
||||||
|
| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,6 +208,43 @@
|
|||||||
- **خطر:** هر XSS در سایت هر دو را میخواند.
|
- **خطر:** هر XSS در سایت هر دو را میخواند.
|
||||||
- **رفع:** CSPِ سختگیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
|
- **رفع:** CSPِ سختگیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
|
||||||
|
|
||||||
|
## ۱۷. شمارهی موبایل نرمال نمیشود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
|
||||||
|
|
||||||
|
- **چیست:** `phone` در ورود، درخواستِ OTP و دعوتِ خانواده رشتهی خام است
|
||||||
|
(`accounts/serializers.py`، `max_length=15` بدونِ اعتبارسنجی). `09121234567`،
|
||||||
|
`+989121234567`، `989121234567` و همان شماره با ارقامِ فارسی چهار «شماره»ی جدااند.
|
||||||
|
- **خطر:** cooldownِ ۲ دقیقهای و سقفِ ۵ در ساعتِ هر شماره بهازای هر املا جداست، پس با
|
||||||
|
چند IP میشود یک گوشی را با پیامک بمباران کرد (وقتی کاوهنگار وصل شود، به هزینهی ما)؛
|
||||||
|
یک آدم با دو املا دو حسابِ جدا میسازد؛ دعوتِ والد با املای دیگر ۴۰۳ میگیرد.
|
||||||
|
(بازتولید شد با تستِ موقت؛ پیامکِ واقعی فرستاده نشد.)
|
||||||
|
- **رفع:** یک تابعِ `normalize_phone` (ارقامِ فارسی/عربی → لاتین، `+98`/`0098`/`98` → `0`،
|
||||||
|
فقط `09\d{9}`) در هر سه serializer، و migrationِ داده برای ردیفهای موجود.
|
||||||
|
- **چطور بسنجید:** دو درخواستِ OTP پشتِسرِهم با `0912…` و `+98912…` باید دومی ۴۲۹ بگیرد.
|
||||||
|
- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوهنگار روی سرور رفت؛ کامیت: _پس از کامیت
|
||||||
|
اینجا بنویسید_). `accounts/phone.py::normalize_phone` در ورود، درخواستِ OTP، دعوتِ
|
||||||
|
خانواده و کلیدهای `TEST_LOGIN_PHONES`؛ migrationِ `accounts.0005` شمارههای موجود را
|
||||||
|
نرمال میکند (ردیفی که شکلِ نرمالش مالِ کاربرِ دیگری است دست نمیخورد و فقط شمرده میشود).
|
||||||
|
`parentPhone` در پروفایل هنوز نرمال **نمیشود**. همراهش، چون از این تاریخ پیامکِ واقعی
|
||||||
|
خرجِ اعتبار است:
|
||||||
|
- cooldownِ ۲ دقیقهای و سقفِ روزانهی هر شماره (`OTP_REQUEST_PHONE_DAILY_MAX=10`)
|
||||||
|
حالا در **دیتابیس** هم شمرده میشوند، پس روی ۳ workerِ سرور سهبرابر نمیشوند (بندِ ۹
|
||||||
|
برای این دو دیگر صدق نمیکند؛ برای throttleهای IP هنوز میکند).
|
||||||
|
- سقفِ روزانهی کلِ پیامکِ OTP (`OTP_SMS_GLOBAL_DAILY_MAX=300`) جلوی SMS pumping؛
|
||||||
|
رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد. با رشدِ کاربرِ واقعی از `.env` بالا ببرید.
|
||||||
|
- سقفهای per-IP گشادهتر شدند (کد ۲۰، ورود ۳۰، مهمان ۶۰ در ساعت، همه از `.env` قابلِ
|
||||||
|
تغییر) چون پشتِ CGNAT و وایفایِ مدرسه دهها نفر یک IP دارند.
|
||||||
|
- تستها: `accounts/tests_otp_limits.py`. همزمانیِ واقعیِ دو پروسهی gunicorn تست نشده.
|
||||||
|
|
||||||
|
## ۱۸. صفحهی پرداخت مالکیت را نمیسنجد (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
|
||||||
|
|
||||||
|
- **چیست:** `PaymentRedirectView` (`AllowAny`) برای هر شناسهی سفارشِ ترتیبی از زرینپال
|
||||||
|
authority میگیرد.
|
||||||
|
- **خطر:** غریبه با پیمایشِ `/orders/1/pay`، `/orders/2/pay`… روی سفارشهای باز authority
|
||||||
|
مینشاند؛ از آن به بعد صاحبِ سفارش «کدِ تخفیف را پیش از درگاه وارد کنید» میگیرد
|
||||||
|
(بازتولید شد، با زرینپالِ mock). وضعیتِ paid/failedِ سفارشِ دیگران هم از ریدایرکت پیداست.
|
||||||
|
- **رفع:** در `paymentUrl` یک توکنِ امضاشدهی کوتاهعمر (`signing`) با `order_id` بگذارید و
|
||||||
|
همانجا بسنجید؛ یا شناسهی غیرترتیبی.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## چیزهایی که بسته شدهاند (برای اینکه دوباره باز نشوند)
|
## چیزهایی که بسته شدهاند (برای اینکه دوباره باز نشوند)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from accounts.serializers import IranMobileField
|
||||||
|
|
||||||
|
|
||||||
class FamilyLinkInviteRequestSerializer(serializers.Serializer):
|
class FamilyLinkInviteRequestSerializer(serializers.Serializer):
|
||||||
childPhone = serializers.CharField(max_length=15)
|
childPhone = IranMobileField()
|
||||||
|
|
||||||
|
|
||||||
class FamilyLinkInviteResponseSerializer(serializers.Serializer):
|
class FamilyLinkInviteResponseSerializer(serializers.Serializer):
|
||||||
@@ -47,5 +49,8 @@ class FamilyWeeklyReportResponseSerializer(serializers.Serializer):
|
|||||||
weekStart = serializers.DateField()
|
weekStart = serializers.DateField()
|
||||||
xpEarned = serializers.IntegerField()
|
xpEarned = serializers.IntegerField()
|
||||||
lessonsCompleted = serializers.IntegerField()
|
lessonsCompleted = serializers.IntegerField()
|
||||||
|
# افزوده ۲۰۲۶-۰۹-۲۵: جزیرههایی که آزمونشان در این هفته تمام شده. xpEarned
|
||||||
|
# از همین تاریخ امتیازِ آزمونِ جزیره را هم دارد.
|
||||||
|
islandsCompleted = serializers.IntegerField()
|
||||||
currentStreak = serializers.IntegerField()
|
currentStreak = serializers.IntegerField()
|
||||||
openedAt = serializers.DateTimeField(allow_null=True)
|
openedAt = serializers.DateTimeField(allow_null=True)
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ class FamilyLinkAcceptThrottleTests(TestCase):
|
|||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='')
|
self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='')
|
||||||
self.child = User.objects.create(
|
self.child = User.objects.create(
|
||||||
device_id='device-family-child-1', is_guest=True, name='', phone='+989120000001',
|
device_id='device-family-child-1', is_guest=True, name='', phone='09120000001',
|
||||||
)
|
)
|
||||||
self.child_token = issue_access_token(self.child.id)
|
self.child_token = issue_access_token(self.child.id)
|
||||||
self.registered_user = User.objects.create(is_guest=False, name='', phone='+989120000098')
|
self.registered_user = User.objects.create(is_guest=False, name='', phone='09120000098')
|
||||||
self.registered_token = issue_access_token(self.registered_user.id)
|
self.registered_token = issue_access_token(self.registered_user.id)
|
||||||
self.link = FamilyLink.objects.create(
|
self.link = FamilyLink.objects.create(
|
||||||
parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456',
|
parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456',
|
||||||
@@ -60,7 +60,7 @@ class FamilyLinkAcceptThrottleTests(TestCase):
|
|||||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||||
other_user = User.objects.create(
|
other_user = User.objects.create(
|
||||||
is_guest=False, name='', phone='+989120000099',
|
is_guest=False, name='', phone='09120000099',
|
||||||
)
|
)
|
||||||
other_token = issue_access_token(other_user.id)
|
other_token = issue_access_token(other_user.id)
|
||||||
|
|
||||||
@@ -153,11 +153,11 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase):
|
|||||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000201')
|
user_a = User.objects.create(is_guest=False, name='', phone='09120000201')
|
||||||
response_a = self._accept('000001', issue_access_token(user_a.id))
|
response_a = self._accept('000001', issue_access_token(user_a.id))
|
||||||
self.assertEqual(response_a.status_code, 404)
|
self.assertEqual(response_a.status_code, 404)
|
||||||
|
|
||||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000202')
|
user_b = User.objects.create(is_guest=False, name='', phone='09120000202')
|
||||||
response_b = self._accept('000002', issue_access_token(user_b.id))
|
response_b = self._accept('000002', issue_access_token(user_b.id))
|
||||||
|
|
||||||
self.assertEqual(response_b.status_code, 404)
|
self.assertEqual(response_b.status_code, 404)
|
||||||
@@ -168,7 +168,7 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase):
|
|||||||
# برسه — throttle باید *قبل* از منطقِ accept رد کنه.
|
# برسه — throttle باید *قبل* از منطقِ accept رد کنه.
|
||||||
parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='')
|
parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='')
|
||||||
child = User.objects.create(
|
child = User.objects.create(
|
||||||
device_id='device-family-guest-rot-child', is_guest=True, name='', phone='+989120000203',
|
device_id='device-family-guest-rot-child', is_guest=True, name='', phone='09120000203',
|
||||||
)
|
)
|
||||||
link = FamilyLink.objects.create(
|
link = FamilyLink.objects.create(
|
||||||
parent_user_id=parent.id, child_phone=child.phone, invite_code='654321',
|
parent_user_id=parent.id, child_phone=child.phone, invite_code='654321',
|
||||||
@@ -217,55 +217,55 @@ class FamilyLinkInviteThrottleTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_unauthenticated_request_is_rejected(self):
|
def test_unauthenticated_request_is_rejected(self):
|
||||||
response = self._invite('+989120001001')
|
response = self._invite('09120001001')
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
def test_requests_within_the_limit_still_succeed(self):
|
def test_requests_within_the_limit_still_succeed(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
response = self._invite(f'+98912000110{i}', token=self.token)
|
response = self._invite(f'0912000110{i}', token=self.token)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn('inviteCode', response.data)
|
self.assertIn('inviteCode', response.data)
|
||||||
|
|
||||||
def test_excessive_invite_requests_from_same_user_are_blocked(self):
|
def test_excessive_invite_requests_from_same_user_are_blocked(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
response = self._invite(f'+98912000120{i}', token=self.token)
|
response = self._invite(f'0912000120{i}', token=self.token)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
blocked_response = self._invite('+989120001299', token=self.token)
|
blocked_response = self._invite('09120001299', token=self.token)
|
||||||
self.assertEqual(blocked_response.status_code, 429)
|
self.assertEqual(blocked_response.status_code, 429)
|
||||||
|
|
||||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||||
# کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
|
# کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
|
||||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||||
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
|
registered_parent = User.objects.create(is_guest=False, name='', phone='09120001300')
|
||||||
registered_token = issue_access_token(registered_parent.id)
|
registered_token = issue_access_token(registered_parent.id)
|
||||||
|
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
response = self._invite(
|
response = self._invite(
|
||||||
f'+98912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}',
|
f'0912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}',
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
blocked_response = self._invite('+989120001399', token=registered_token, remote_addr='10.5.0.99')
|
blocked_response = self._invite('09120001399', token=registered_token, remote_addr='10.5.0.99')
|
||||||
self.assertEqual(blocked_response.status_code, 429)
|
self.assertEqual(blocked_response.status_code, 429)
|
||||||
|
|
||||||
def test_throttled_request_does_not_create_family_link(self):
|
def test_throttled_request_does_not_create_family_link(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||||
first_response = self._invite('+989120001401', token=self.token)
|
first_response = self._invite('09120001401', token=self.token)
|
||||||
self.assertEqual(first_response.status_code, 200)
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
|
||||||
link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count()
|
link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count()
|
||||||
blocked_response = self._invite('+989120001402', token=self.token)
|
blocked_response = self._invite('09120001402', token=self.token)
|
||||||
|
|
||||||
self.assertEqual(blocked_response.status_code, 429)
|
self.assertEqual(blocked_response.status_code, 429)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before,
|
FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before,
|
||||||
)
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='+989120001402').exists(),
|
FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='09120001402').exists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -295,39 +295,39 @@ class FamilyLinkInviteGuestThrottleBypassTests(TestCase):
|
|||||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}):
|
||||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='')
|
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='')
|
||||||
response1 = self._invite('+989120001501', issue_access_token(guest1.id))
|
response1 = self._invite('09120001501', issue_access_token(guest1.id))
|
||||||
self.assertEqual(response1.status_code, 200)
|
self.assertEqual(response1.status_code, 200)
|
||||||
|
|
||||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='')
|
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='')
|
||||||
response2 = self._invite('+989120001502', issue_access_token(guest2.id))
|
response2 = self._invite('09120001502', issue_access_token(guest2.id))
|
||||||
self.assertEqual(response2.status_code, 200)
|
self.assertEqual(response2.status_code, 200)
|
||||||
|
|
||||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||||
guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='')
|
guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='')
|
||||||
response3 = self._invite('+989120001503', issue_access_token(guest3.id))
|
response3 = self._invite('09120001503', issue_access_token(guest3.id))
|
||||||
|
|
||||||
self.assertEqual(response3.status_code, 429)
|
self.assertEqual(response3.status_code, 429)
|
||||||
|
|
||||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120001601')
|
user_a = User.objects.create(is_guest=False, name='', phone='09120001601')
|
||||||
response_a = self._invite('+989120001701', issue_access_token(user_a.id))
|
response_a = self._invite('09120001701', issue_access_token(user_a.id))
|
||||||
self.assertEqual(response_a.status_code, 200)
|
self.assertEqual(response_a.status_code, 200)
|
||||||
|
|
||||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120001602')
|
user_b = User.objects.create(is_guest=False, name='', phone='09120001602')
|
||||||
response_b = self._invite('+989120001702', issue_access_token(user_b.id))
|
response_b = self._invite('09120001702', issue_access_token(user_b.id))
|
||||||
|
|
||||||
self.assertEqual(response_b.status_code, 200)
|
self.assertEqual(response_b.status_code, 200)
|
||||||
|
|
||||||
def test_blocked_guest_request_does_not_create_family_link(self):
|
def test_blocked_guest_request_does_not_create_family_link(self):
|
||||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='')
|
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='')
|
||||||
first_response = self._invite('+989120001801', issue_access_token(guest1.id))
|
first_response = self._invite('09120001801', issue_access_token(guest1.id))
|
||||||
self.assertEqual(first_response.status_code, 200)
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
|
||||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='')
|
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='')
|
||||||
blocked_response = self._invite('+989120001802', issue_access_token(guest2.id))
|
blocked_response = self._invite('09120001802', issue_access_token(guest2.id))
|
||||||
|
|
||||||
self.assertEqual(blocked_response.status_code, 429)
|
self.assertEqual(blocked_response.status_code, 429)
|
||||||
self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists())
|
self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists())
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ class FamilyLinkAcceptView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# اگر دعوت برای شمارهی مشخصی صادر شده باشد، فقط همان شماره میتواند accept کند.
|
# اگر دعوت برای شمارهی مشخصی صادر شده باشد، فقط همان شماره میتواند accept کند.
|
||||||
|
# ⚠️ مقایسهی رشتهی خام است: دعوت با +98912… را صاحبِ 0912… نمیتواند
|
||||||
|
# بپذیرد (۴۰۳، بازتولید شد). مهمان (phone=None) هم هیچ دعوتی را نمیپذیرد.
|
||||||
if link.child_phone and link.child_phone != request.user.phone:
|
if link.child_phone and link.child_phone != request.user.phone:
|
||||||
raise PermissionDenied('این دعوت متعلق به شمارهی دیگری است.')
|
raise PermissionDenied('این دعوت متعلق به شمارهی دیگری است.')
|
||||||
|
|
||||||
@@ -284,6 +286,11 @@ class FamilyWeeklyReportView(APIView):
|
|||||||
جنگوییه، همون «خوندن» با import مستقیم مدلهای آن دو اپ پیادهسازی
|
جنگوییه، همون «خوندن» با import مستقیم مدلهای آن دو اپ پیادهسازی
|
||||||
شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای
|
شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای
|
||||||
خوندن نام کاربر از accounts.User استفاده میکنه.
|
خوندن نام کاربر از accounts.User استفاده میکنه.
|
||||||
|
|
||||||
|
امتیاز هم از درسها و هم از آزمونِ جزیره (UserNodeCompletion) جمع میشود.
|
||||||
|
پیش از ۲۰۲۶-۰۹-۲۵ فقط درسها شمرده میشدند و فرزندی که با آزمونِ جزیره (مسیرِ
|
||||||
|
اصلیِ بازیِ فرانت) پیش میرفت برای والد «۰ امتیاز» دیده میشد.
|
||||||
|
⚠️ ParentalSettings فقط ذخیره میشود؛ هیچجای کد اعمالش نمیکند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -308,14 +315,21 @@ class FamilyWeeklyReportView(APIView):
|
|||||||
related_id=log.id,
|
related_id=log.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
from content.models import UserLessonCompletion
|
from content.models import UserLessonCompletion, UserNodeCompletion
|
||||||
from progress.models import StreakRecord
|
from progress.models import StreakRecord
|
||||||
|
|
||||||
completions = UserLessonCompletion.objects.filter(
|
completions = UserLessonCompletion.objects.filter(
|
||||||
user_id=link.child_user_id, created_at__date__gte=week_start
|
user_id=link.child_user_id, created_at__date__gte=week_start
|
||||||
)
|
)
|
||||||
xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0
|
islands = UserNodeCompletion.objects.filter(
|
||||||
|
user_id=link.child_user_id, created_at__date__gte=week_start
|
||||||
|
)
|
||||||
|
xp_earned = (
|
||||||
|
(completions.aggregate(total=Sum('xp_earned'))['total'] or 0)
|
||||||
|
+ (islands.aggregate(total=Sum('xp_earned'))['total'] or 0)
|
||||||
|
)
|
||||||
lessons_completed = completions.count()
|
lessons_completed = completions.count()
|
||||||
|
islands_completed = islands.values('node_id').distinct().count()
|
||||||
|
|
||||||
streak = StreakRecord.objects.filter(user_id=link.child_user_id).first()
|
streak = StreakRecord.objects.filter(user_id=link.child_user_id).first()
|
||||||
current_streak = streak.current_streak if streak else 0
|
current_streak = streak.current_streak if streak else 0
|
||||||
@@ -328,6 +342,7 @@ class FamilyWeeklyReportView(APIView):
|
|||||||
'weekStart': week_start,
|
'weekStart': week_start,
|
||||||
'xpEarned': xp_earned,
|
'xpEarned': xp_earned,
|
||||||
'lessonsCompleted': lessons_completed,
|
'lessonsCompleted': lessons_completed,
|
||||||
|
'islandsCompleted': islands_completed,
|
||||||
'currentStreak': current_streak,
|
'currentStreak': current_streak,
|
||||||
'openedAt': log.opened_at,
|
'openedAt': log.opened_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ class PaymentRedirectView(APIView):
|
|||||||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||||||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||||||
میکند، نه یک PaymentRequest تازه.
|
میکند، نه یک PaymentRequest تازه.
|
||||||
|
|
||||||
|
⚠️ AllowAny است و مالکیتِ سفارش را نمیسنجد (مرورگر هدرِ Bearer نمیفرستد)؛
|
||||||
|
شناسهی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders/<n>/pay` برای سفارشِ
|
||||||
|
دیگران authority میسازد و از آن به بعد صاحبِ سفارش دیگر نمیتواند کدِ
|
||||||
|
تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|||||||
@@ -120,12 +120,23 @@ def verify_payment(*, authority: str, amount: int, currency: str) -> ZarinpalVer
|
|||||||
raise ZarinpalAPIError()
|
raise ZarinpalAPIError()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result_data = resp.json()['data']
|
body = resp.json()
|
||||||
code = result_data.get('code')
|
result_data = body['data']
|
||||||
except (ValueError, KeyError, TypeError):
|
except (ValueError, KeyError, TypeError):
|
||||||
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
|
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
|
||||||
raise ZarinpalAPIError()
|
raise ZarinpalAPIError()
|
||||||
|
|
||||||
|
# زرینپال روی خطا (مثلاً -51 پرداختِ ناموفق) `"data": []` و کدِ خطا را در
|
||||||
|
# `errors` برمیگرداند. این یک «رد»ِ صریح است، نه خرابیِ شبکه، پس مثلِ هر کدِ
|
||||||
|
# غیرِ ۱۰۰/۱۰۱ نامعتبر حساب میشود. پیش از ۲۰۲۶-۰۹-۲۵ اینجا `[].get` کرش
|
||||||
|
# میکرد و کاربر بهجای برگشت به اپ صفحهی ۵۰۰ میدید. با بدنهی مستندِ
|
||||||
|
# زرینپال تست شده، نه با درگاهِ واقعی.
|
||||||
|
if not isinstance(result_data, dict):
|
||||||
|
errors = body.get('errors') if isinstance(body, dict) else None
|
||||||
|
logger.warning('Zarinpal payment-verify rejected: errors=%s', errors)
|
||||||
|
return ZarinpalVerifyResult(is_valid=False, raw=body if isinstance(body, dict) else None)
|
||||||
|
code = result_data.get('code')
|
||||||
|
|
||||||
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرینپال) — هر دو معتبرن.
|
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرینپال) — هر دو معتبرن.
|
||||||
if code in (100, 101):
|
if code in (100, 101):
|
||||||
return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data)
|
return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data)
|
||||||
|
|||||||
@@ -65,16 +65,29 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e
|
|||||||
|
|
||||||
مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با
|
مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با
|
||||||
select_for_update قفل میشود تا فراخوانیهای همزمان lost update نسازند.
|
select_for_update قفل میشود تا فراخوانیهای همزمان lost update نسازند.
|
||||||
|
|
||||||
|
اشتراکِ فعالی که دیرتر تمام میشود (یا نامحدود است) کوتاه نمیشود؛ آفر فقط
|
||||||
|
وقتی اثر دارد که بیشتر از چیزی که کاربر دارد بدهد. پیش از ۲۰۲۶-۰۹-۲۵ کاربری
|
||||||
|
که سالانه خریده بود و بعد کدِ آفرِ ۷ روزه میزد (یا مهمانی که خریده بود و بعد
|
||||||
|
ثبتنام میکرد و آفرِ خودکارِ ثبتنام میگرفت) با ۷ روز میماند.
|
||||||
"""
|
"""
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
Subscription.objects.get_or_create(user_id=user_id)
|
Subscription.objects.get_or_create(user_id=user_id)
|
||||||
subscription = Subscription.objects.select_for_update().get(user_id=user_id)
|
subscription = Subscription.objects.select_for_update().get(user_id=user_id)
|
||||||
old_status = subscription.status
|
old_status = subscription.status
|
||||||
|
|
||||||
subscription.status = 'active'
|
offered_until = (
|
||||||
subscription.expires_at = (
|
|
||||||
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
||||||
)
|
)
|
||||||
|
still_active = old_status == 'active' and (
|
||||||
|
subscription.expires_at is None or subscription.expires_at > timezone.now()
|
||||||
|
)
|
||||||
|
keeps_longer = still_active and offered_until is not None and (
|
||||||
|
subscription.expires_at is None or subscription.expires_at >= offered_until
|
||||||
|
)
|
||||||
|
subscription.status = 'active'
|
||||||
|
if not keeps_longer:
|
||||||
|
subscription.expires_at = offered_until
|
||||||
subscription.save(update_fields=['status', 'expires_at'])
|
subscription.save(update_fields=['status', 'expires_at'])
|
||||||
|
|
||||||
status_changed.send(
|
status_changed.send(
|
||||||
|
|||||||
@@ -112,8 +112,9 @@ class PurchaseView(APIView):
|
|||||||
plan = get_object_or_404(Plan, pk=v['planId'])
|
plan = get_object_or_404(Plan, pk=v['planId'])
|
||||||
idempotency_key = request.headers.get('Idempotency-Key')
|
idempotency_key = request.headers.get('Idempotency-Key')
|
||||||
|
|
||||||
# Three-phase flow so create_order (which may call an external payment
|
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
||||||
# gateway) never runs inside a DB transaction/lock: phase 1 creates/updates
|
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
||||||
|
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
|
||||||
# the pending Subscription, phase 2 calls create_order and restores the
|
# the pending Subscription, phase 2 calls create_order and restores the
|
||||||
# prior state on failure, phase 3 attaches order_id atomically.
|
# prior state on failure, phase 3 attaches order_id atomically.
|
||||||
existing_order = None
|
existing_order = None
|
||||||
|
|||||||
Reference in New Issue
Block a user