Files
MathApp/config/tests.py

271 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import importlib
import json
import logging
import os
from unittest.mock import patch
from django.core.exceptions import ImproperlyConfigured
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):
"""
تست‌های متمرکز روی این فیکس: SECRET_KEY دیگه در پروداکشن (DEBUG=False)
دیفالتِ هاردکد/قابل‌حدس‌زدن نداره. اگه DJANGO_SECRET_KEY ست نشده باشه،
settings.py باید fail-closed بشه (خطای صریح موقعِ import)، نه اینکه با
یک fallback شناخته‌شده بالا بیاد. برای DEBUG=True، fallbackِ محلیِ قبلی
باید دست‌نخورده بمونه.
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
فقط یک‌بار موقعِ import محاسبه می‌شه (نه چیزی که override_settings
بتونه toggle کنه)، ماژولِ config.settings رو با متغیرهای محیطیِ
کنترل‌شده reload می‌کنیم.
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_secret_key_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''},
env_removed={'DJANGO_SECRET_KEY'},
)
def test_production_configured_secret_key_used(self):
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
# ImproperlyConfiguredِ CACHE_URL رخ می‌ده، نه رسیدن به assertionِ
# SECRET_KEY که این تست واقعاً می‌سنجه.
settings_module = self._reload_settings_with_env(
env_overrides={
'DJANGO_DEBUG': '',
'DJANGO_SECRET_KEY': 'prod-secret-xyz',
'CACHE_URL': 'redis://localhost:6379/0',
},
)
self.assertFalse(settings_module.DEBUG)
self.assertEqual(settings_module.SECRET_KEY, 'prod-secret-xyz')
def test_debug_mode_missing_secret_key_uses_local_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'DJANGO_SECRET_KEY'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.SECRET_KEY,
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
)
class CacheUrlFailClosedTests(SimpleTestCase):
"""
🔒 تستِ متمرکز روی فیکسِ CACHE_URL: در پروداکشن (DEBUG=False)، اگه
CACHE_URL ست نشده باشه، settings.py باید fail-closed بشه (خطای صریح
موقعِ import)، نه fallback خاموش به LocMemCache (که بینِ worker/instance
ها مشترک نیست و throttle/rate-limit ها رو بی‌اثر می‌کنه).
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_cache_url_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
env_removed={'CACHE_URL'},
)
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'CACHE_URL'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
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):
# میان‌افزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
# handlerِ واقعی طی می‌کنه رو صدا می‌زنیم (نه فقط getMessage()، که
# یه mismatchِ %%-args رو با exceptionِ خودش می‌پوشونه، نه fail واقعی).
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'])