throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -1 +1,366 @@
# Create your tests here.
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import BugReport, Ticket, TicketMessage
class BugReportCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی BugReportCreateView
(throttle_scope='support_create'): هیچ idempotency ای روی این
endpoint نیست، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست
تعدادِ نامحدودی BugReport با درخواست‌های پرتکرار بسازه.
ScopedRateThrottle با pk کاربر کلید می‌خوره، نه IP.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-bugreport-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه
# که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
registered_token = issue_access_token(registered_user.id)
def _create_as(i, remote_addr):
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.4.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.4.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_bug_report(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = BugReport.objects.filter(user_id=self.user.id).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(BugReport.objects.filter(user_id=self.user.id).count(), count_before)
class TicketCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketCreateView
(throttle_scope='support_create'، مشترک با BugReport/TicketMessage).
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ticket-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
registered_token = issue_access_token(registered_user.id)
def _create_as(i, remote_addr):
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.5.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.5.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_ticket(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = Ticket.objects.filter(user_id=self.user.id).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(Ticket.objects.filter(user_id=self.user.id).count(), count_before)
class TicketMessageCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketMessageCreateView
(throttle_scope='support_create'، مشترک با BugReport/Ticket).
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ticketmsg-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.ticket = Ticket.objects.create(user_id=self.user.id, subject='موضوع', category='billing')
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/support/tickets/{self.ticket.id}/messages',
{'text': f'پیام {i}'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
registered_token = issue_access_token(registered_user.id)
registered_ticket = Ticket.objects.create(
user_id=registered_user.id, subject='موضوع', category='billing',
)
def _create_as(i, remote_addr):
return self.client.post(
f'/api/support/tickets/{registered_ticket.id}/messages',
{'text': f'پیام {i}'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.6.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.6.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_message(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = TicketMessage.objects.filter(ticket=self.ticket).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(TicketMessage.objects.filter(ticket=self.ticket).count(), count_before)
class SupportCreateGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='support_create'
(مشترک بینِ BugReportCreateView، TicketCreateView و
TicketMessageCreateView) برای هر کاربرِ احرازهویت‌شده (مهمان یا نه)
user.pk بود. چون GuestSessionView با هر deviceId جدید یک کاربرِ
مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ
حساب‌های مهمان، هر بار یک bucket throttle تازه برای این scopeِ
مشترک بگیره و سقفِ ۳/ساعتِ ساختِ رکوردهای پشتیبانی (BugReport/
Ticket/TicketMessage) رو چند برابر کنه. حالا GuestAwareSupportThrottle
برای کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که
guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه
bucket تازه نمی‌ده — این هم روی bucket مشترکِ بینِ هر سه Endpoint و
هم روی رفتارِ per-user برای کاربرهای غیرمهمان تست می‌شه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
def _create_bug_report(self, token, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
**kwargs,
)
def _create_ticket(self, token, i=0):
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند،
# حتی وقتی از دو Endpointِ متفاوتِ همون scope استفاده می‌کنن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '2/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-1', is_guest=True, name='')
response1 = self._create_bug_report(issue_access_token(guest1.id), 1)
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-support-guest-rot-2', is_guest=True, name='')
response2 = self._create_ticket(issue_access_token(guest2.id), 2)
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی
# سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های
# مهمانِ ساخته‌شده یا این‌که کدوم Endpoint صدا زده شده.
guest3 = User.objects.create(device_id='device-support-guest-rot-3', is_guest=True, name='')
response3 = self._create_bug_report(issue_access_token(guest3.id), 3)
self.assertEqual(response3.status_code, 429)
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
# (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP
# مشترک متصل است.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-4', is_guest=True, name='')
response1 = self._create_bug_report(issue_access_token(guest1.id), 4)
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-support-guest-rot-5', is_guest=True, name='')
blocked = self._create_bug_report(issue_access_token(guest2.id), 5)
self.assertEqual(blocked.status_code, 429)
guest3 = User.objects.create(device_id='device-support-guest-rot-6', is_guest=True, name='')
still_blocked = self._create_bug_report(issue_access_token(guest3.id), 6)
self.assertEqual(still_blocked.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120001001')
response_a = self._create_bug_report(issue_access_token(user_a.id), 'a')
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120001002')
response_b = self._create_bug_report(issue_access_token(user_b.id), 'b')
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_does_not_create_support_record(self):
# درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید هیچ BugReport یا
# Ticket جدیدی بسازه — throttle باید *قبل* از رسیدن به منطقِ
# ساختِ رکورد رد کنه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-7', is_guest=True, name='')
first_response = self._create_bug_report(issue_access_token(guest1.id), 7)
self.assertEqual(first_response.status_code, 200)
# حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از
# قبل مصرف شده) و نباید BugReport یا Ticket جدیدی بسازد.
guest2 = User.objects.create(device_id='device-support-guest-rot-8', is_guest=True, name='')
bug_count_before = BugReport.objects.count()
ticket_count_before = Ticket.objects.count()
blocked_bug_response = self._create_bug_report(issue_access_token(guest2.id), 8)
self.assertEqual(blocked_bug_response.status_code, 429)
blocked_ticket_response = self._create_ticket(issue_access_token(guest2.id), 8)
self.assertEqual(blocked_ticket_response.status_code, 429)
self.assertEqual(BugReport.objects.count(), bug_count_before)
self.assertEqual(Ticket.objects.count(), ticket_count_before)