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>
131 lines
6.6 KiB
Python
131 lines
6.6 KiB
Python
"""
|
||
نرمالسازیِ شماره و سقفهای دیتابیسیِ پیامکِ 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)
|