دو شکاف که وقتی آدمهای دیگر به پروژه وصل میشوند مهمتر میشوند. کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل میشدند. احرازِ هویتِ خودِ API کوکی نیست و اینها فقط برای ادمیناند — یعنی دقیقاً همانجایی که اعتبارنامهی واقعی وارد میشود. حالا خارج از DEBUG امناند، و SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام میشود و بدونش جنگو درخواست را HTTP میبیند و کوکیِ امن اصلاً ست نمیشود. SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم حلقهی ریدایرکت میسازد و کلِ سایت را میخواباند، دومی در مرورگرِ کاربر کش میشود و عملاً برگشتناپذیر است. هر دو تصمیمِ زیرساختیاند نه کد. /api/health حالا ترافیک را هم گزارش میدهد: تعدادِ کاربر، فعالهای ۲۴ ساعتِ اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت واقعاً به سرور رسیده یا نه — باید منتظر میماندیم کسی بگوید. فقط شمارش و زمان؛ تست جداگانه بررسی میکند هیچ شماره یا نامی بیرون نرود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
19 KiB
Python
406 lines
19 KiB
Python
import importlib
|
||
import json
|
||
import logging
|
||
import os
|
||
from unittest.mock import patch
|
||
|
||
from django.conf import settings
|
||
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'])
|
||
|
||
|
||
class ServerEnvDefaultsTests(SimpleTestCase):
|
||
"""
|
||
متغیرهایی که قبلاً مسئولِ سرور مجبور بود دستی به .env اضافه کند.
|
||
|
||
هر سهتا یک الگو داشتند: نبودشان یا سرویس را میخواباند یا یک مسیر را
|
||
بیسروصدا میشکست، و چون در .env.example هم نبودند، هر بار از نو کشف
|
||
میشدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
|
||
اینکه بدونِ هیچ متغیرِ محیطیای مقدارِ کارآمد دارند.
|
||
"""
|
||
|
||
HOST = 'api.math.tadrisino2.ir'
|
||
|
||
def test_the_api_domain_is_allowed_without_any_env_var(self):
|
||
self.assertIn(self.HOST, settings.DEFAULT_ALLOWED_HOSTS)
|
||
|
||
def test_an_incomplete_env_value_does_not_drop_our_domains(self):
|
||
"""
|
||
.env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد.
|
||
اگر مقدارِ موجود پیشفرض را کنار بزند، همان «Disallowed Host» قدیمی
|
||
برمیگردد و باز کسی باید دستی فایل را ویرایش کند.
|
||
"""
|
||
# settings فقط نامهای بزرگ را نشان میدهد؛ تابع از خودِ ماژول میآید.
|
||
from config.settings import _with_defaults
|
||
|
||
with patch.dict(os.environ, {'DJANGO_ALLOWED_HOSTS': 'localhost,127.0.0.1'}):
|
||
merged = _with_defaults('DJANGO_ALLOWED_HOSTS', settings.DEFAULT_ALLOWED_HOSTS)
|
||
self.assertIn(self.HOST, merged)
|
||
self.assertIn('localhost', merged, 'مقدارِ موجود نباید حذف شود')
|
||
|
||
def test_a_present_but_empty_value_still_falls_back(self):
|
||
"""کلید در .env هست ولی خالی — os.environ.get پیشفرض نمیدهد."""
|
||
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
|
||
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
|
||
or 'https://api.math.tadrisino2.ir')
|
||
self.assertTrue(value.startswith('https://'))
|
||
|
||
def test_admin_login_origin_is_trusted_without_any_env_var(self):
|
||
self.assertIn(f'https://{self.HOST}', settings.CSRF_TRUSTED_ORIGINS)
|
||
|
||
def test_the_deployed_frontend_may_call_the_api(self):
|
||
self.assertIn('https://math.tadrisino2.ir', settings.CORS_ALLOWED_ORIGINS)
|
||
|
||
def test_payment_urls_are_absolute_without_any_env_var(self):
|
||
"""خالی بودنِ PUBLIC_BASE_URL مسیرِ پرداخت را بیصدا میشکست."""
|
||
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
|
||
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
|
||
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL)
|
||
|
||
|
||
class SecureCookieTests(SimpleTestCase):
|
||
"""
|
||
کوکیِ نشستِ ادمین جاییست که اعتبارنامهی واقعی وارد میشود؛ روی HTTP
|
||
نباید رد و بدل شود.
|
||
|
||
مثلِ بقیهی تستهای این فایل ماژول را دوباره بار میکنیم: این مقدارها در
|
||
زمانِ import محاسبه میشوند و اجرای تست خودش DEBUG را خاموش میکند، پس
|
||
خواندنِ settings در لحظه چیزِ گمراهکنندهای میدهد.
|
||
"""
|
||
|
||
allow_database_queries = True
|
||
|
||
def _reload(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_cookies_are_secure_in_production(self):
|
||
module = self._reload(
|
||
{'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'x' * 60,
|
||
'CACHE_URL': 'redis://localhost:6379/0'},
|
||
)
|
||
self.assertFalse(module.DEBUG)
|
||
self.assertTrue(module.SESSION_COOKIE_SECURE)
|
||
self.assertTrue(module.CSRF_COOKIE_SECURE)
|
||
self.assertTrue(module.SESSION_COOKIE_HTTPONLY)
|
||
|
||
def test_cookies_stay_usable_over_plain_http_in_development(self):
|
||
"""با DEBUG روشن، کوکیِ امن یعنی ورود به ادمینِ محلی کار نمیکند."""
|
||
module = self._reload({'DJANGO_DEBUG': '1'})
|
||
self.assertTrue(module.DEBUG)
|
||
self.assertFalse(module.SESSION_COOKIE_SECURE)
|
||
|
||
def test_django_trusts_the_proxy_for_https(self):
|
||
"""بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمیشود و ورود میشکند."""
|
||
self.assertEqual(settings.SECURE_PROXY_SSL_HEADER,
|
||
('HTTP_X_FORWARDED_PROTO', 'https'))
|