سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا میآورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفتوبرگشتِ یکروزه بود. ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنههای شناختهشدهی خودمان را پیشفرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS «CSRF verification failed» میدهد و علتش از پیام معلوم نیست. SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در .django_secret_key نگه داشته میشود (۰۶۰۰، خارج از گیت). همانقدر غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک میماند، ریاستارت نشستهای کاربران را باطل نمیکند. CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک ضعفِ واقعیست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا میآید و /api/health صریحاً هشدار میدهد. تستِ مربوطه هم از «باید بالا نیاید» به «باید بالا بیاید ولی بیصدا نماند» تغییر کرد. متغیرِ محیطی همچنان بر همهی این پیشفرضها اولویت دارد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
14 KiB
Python
309 lines
14 KiB
Python
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 در پروداکشن هرگز دیفالتِ قابلحدسزدن ندارد — این هنوز برقرار است.
|
||
|
||
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمیآمد، و
|
||
نتیجهاش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا میآورد
|
||
باید حدس میزد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
|
||
میسازد و در فایلی کنارِ پروژه نگه میدارد — همانقدر غیرقابلِ حدس، بدونِ
|
||
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
|
||
تصادفی باشد، نه رشتهی هاردکدِ حالتِ توسعه.
|
||
|
||
مثل 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_without_the_env_var_generates_a_real_random_key(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''},
|
||
env_removed={'DJANGO_SECRET_KEY'},
|
||
)
|
||
key = settings_module.SECRET_KEY
|
||
self.addCleanup(
|
||
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||
)
|
||
self.assertFalse(settings_module.DEBUG)
|
||
self.assertNotEqual(
|
||
key,
|
||
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
|
||
)
|
||
self.assertGreaterEqual(len(key), 50)
|
||
|
||
def test_the_generated_key_survives_a_restart(self):
|
||
"""اگر هر بار کلیدِ تازه ساخته شود، همهی نشستها با هر ریاستارت باطل میشوند."""
|
||
first = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||
).SECRET_KEY
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||
)
|
||
self.addCleanup(
|
||
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||
)
|
||
self.assertEqual(first, settings_module.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 در پروداکشن یک ضعفِ واقعیست: LocMemCache بینِ workerها
|
||
مشترک نیست و throttleها را کماثر میکند.
|
||
|
||
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همینجا استثنا
|
||
پرتاب میشد و نتیجهاش از بیرون فقط 502 بود. حالا سرویس بالا میآید و
|
||
/api/health صریحاً هشدار میدهد. چیزی که باید تضمین شود این است که این
|
||
ضعف **بیصدا** نماند.
|
||
"""
|
||
|
||
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_without_cache_url_boots_but_is_not_silent(self):
|
||
settings_module = self._reload_settings_with_env(
|
||
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
|
||
env_removed={'CACHE_URL'},
|
||
)
|
||
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
|
||
|
||
# همان ضعف باید در /api/health دیده شود، وگرنه بیصدا میماند.
|
||
from config import health
|
||
|
||
with self.settings(DEBUG=False,
|
||
CACHES=settings_module.CACHES,
|
||
DEV_MASTER_OTP=None):
|
||
warnings = ' '.join(health._warnings())
|
||
self.assertIn('کش', warnings)
|
||
|
||
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'])
|