throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
173
config/tests.py
173
config/tests.py
@@ -1,9 +1,16 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import SimpleTestCase
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from config.middleware import APIRequestLoggingMiddleware
|
||||
|
||||
|
||||
class SecretKeyFailClosedTests(SimpleTestCase):
|
||||
@@ -116,3 +123,167 @@ class CacheUrlFailClosedTests(SimpleTestCase):
|
||||
settings_module.CACHES['default']['BACKEND'],
|
||||
'django.core.cache.backends.locmem.LocMemCache',
|
||||
)
|
||||
|
||||
|
||||
class APIRequestLoggingMiddlewareTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات میانافزارِ جدیدِ لاگِ درخواستِ API (config.middleware.
|
||||
APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه
|
||||
(متد/مسیر/وضعیت/مدت/شناسهی کاربر) ثبت کنه، بدونِ اینکه هیچوقت بدنه/
|
||||
هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً
|
||||
لاگ نکنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
|
||||
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/user/grade',
|
||||
{'gradeId': 10},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertIn('method=POST', message)
|
||||
self.assertIn('path=/api/user/grade', message)
|
||||
self.assertIn('status=200', message)
|
||||
self.assertIn(f'user_id={user.id}', message)
|
||||
|
||||
# هیچوقت نباید هیچ سکرتی در پیامِ لاگ باشه.
|
||||
self.assertNotIn(token, message)
|
||||
self.assertNotIn('Bearer', message)
|
||||
self.assertNotIn('gradeId', message)
|
||||
|
||||
def test_anonymous_api_request_is_logged_with_null_user_id(self):
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertIn('method=POST', message)
|
||||
self.assertIn('path=/api/auth/session', message)
|
||||
self.assertIn('status=200', message)
|
||||
self.assertIn('user_id=None', message)
|
||||
|
||||
def test_non_api_request_is_not_logged(self):
|
||||
# /admin/ زیرِ /api/ نیست؛ میانافزار عمداً اینجور مسیرها رو لاگ
|
||||
# نمیکنه تا لاگِ اضافه/بیفایده تولید نشه.
|
||||
logger = logging.getLogger('config.middleware')
|
||||
handler = logging.Handler()
|
||||
records = []
|
||||
handler.emit = records.append
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
self.client.get('/admin/login/')
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
self.assertEqual(records, [])
|
||||
|
||||
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
|
||||
# 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک
|
||||
# exceptionِ دستنخورده raise کنه، میانافزار باید همچنان یک خطِ
|
||||
# لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ
|
||||
# حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise
|
||||
# کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگهای رو
|
||||
# تغییر بده. مستقیماً خودِ میانافزار (نه کلِ چرخهی
|
||||
# exception-handlingِ Django) تست میشه، چون همون لایهایست که این
|
||||
# فیکس روش اعمال شده.
|
||||
#
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط
|
||||
# record.getMessage() رو چک میکرد. getMessage() خودش داخلی
|
||||
# msg %% args رو انجام میده، پس اگه شمارِ آرگومانها با
|
||||
# placeholderهای رشته جور نباشه، همونجا با یک exception (نه یک
|
||||
# پیامِ نادرست) fail میشد و اصلاً به assertion نمیرسید. برای اینکه
|
||||
# این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یکجوری
|
||||
# fail نشه)، حالا با logging.Formatter().format(record) دقیقاً
|
||||
# همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی میکنه رو صدا
|
||||
# میزنیم و روی متنِ نهاییِ فرمتشده assert میکنیم.
|
||||
request = RequestFactory().get('/api/some/failing/path')
|
||||
|
||||
def failing_get_response(inner_request):
|
||||
raise ValueError('boom')
|
||||
|
||||
middleware = APIRequestLoggingMiddleware(failing_get_response)
|
||||
|
||||
with self.assertLogs('config.middleware', level='ERROR') as captured:
|
||||
with self.assertRaises(ValueError):
|
||||
middleware(request)
|
||||
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
record = captured.records[0]
|
||||
self.assertEqual(record.levelname, 'ERROR')
|
||||
|
||||
formatted = logging.Formatter().format(record)
|
||||
|
||||
self.assertIn('method=GET', formatted)
|
||||
self.assertIn('path=/api/some/failing/path', formatted)
|
||||
self.assertIn('status=500', formatted)
|
||||
self.assertIn('user_id=None', formatted)
|
||||
|
||||
|
||||
class JSONLogFormatterTests(SimpleTestCase):
|
||||
"""
|
||||
🔒 اثبات فرمتکنندهی جدیدِ JSON (config.log_formatters.JSONLogFormatter)
|
||||
که پروژه رو برای جمعآوریِ متمرکزِ لاگ (Loki/ELK-style) آماده میکنه:
|
||||
خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message
|
||||
باشه، و در صورتِ وجودِ exception، traceback هم بهعنوانِ فیلدِ
|
||||
"exception" اضافه بشه.
|
||||
"""
|
||||
|
||||
def _make_record(self, level=logging.INFO, message='hello world', exc_info=None):
|
||||
return logging.LogRecord(
|
||||
name='some.logger',
|
||||
level=level,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg=message,
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self):
|
||||
from config.log_formatters import JSONLogFormatter
|
||||
|
||||
record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200')
|
||||
formatted = JSONLogFormatter().format(record)
|
||||
|
||||
payload = json.loads(formatted)
|
||||
self.assertEqual(payload['level'], 'INFO')
|
||||
self.assertEqual(payload['logger'], 'some.logger')
|
||||
self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200')
|
||||
self.assertIn('timestamp', payload)
|
||||
self.assertNotIn('exception', payload)
|
||||
|
||||
def test_record_with_exception_includes_traceback_field(self):
|
||||
from config.log_formatters import JSONLogFormatter
|
||||
|
||||
try:
|
||||
raise ValueError('boom')
|
||||
except ValueError:
|
||||
import sys
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info)
|
||||
formatted = JSONLogFormatter().format(record)
|
||||
|
||||
payload = json.loads(formatted)
|
||||
self.assertEqual(payload['level'], 'ERROR')
|
||||
self.assertEqual(payload['message'], 'something failed')
|
||||
self.assertIn('exception', payload)
|
||||
self.assertIn('ValueError: boom', payload['exception'])
|
||||
|
||||
Reference in New Issue
Block a user