import importlib import json import logging import os from unittest.mock import patch from django.conf import settings 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')) class CorsDevPortTests(TestCase): """ پورتِ سرورِ توسعه نباید در فهرستِ CORS شمرده شود. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و بی‌سروصدا رفت روی ۵۱۷۴. فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی نبود، و چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. هزینه‌اش یک ویرایشِ .env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت. """ def _preflight(self, origin): return self.client.options( '/api/grades', HTTP_ORIGIN=origin, HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET', ) def test_any_localhost_port_is_allowed_so_a_new_dev_port_never_breaks_again(self): for origin in ('http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000', 'http://127.0.0.1:8080'): with self.subTest(origin=origin): resp = self._preflight(origin) self.assertEqual( resp.headers.get('access-control-allow-origin'), origin) def test_the_deployed_frontend_origin_still_works(self): resp = self._preflight('https://math.tadrisino2.ir') self.assertEqual(resp.headers.get('access-control-allow-origin'), 'https://math.tadrisino2.ir') def test_a_stranger_origin_is_still_refused(self): # الگو فقط localhost است، نه هر دامنه‌ای؛ و «localhost» به‌عنوانِ # زیررشته در دامنه‌ی دیگری نباید رد شود. for origin in ('https://evil.example.com', 'http://localhost.evil.com', 'https://math.tadrisino2.ir.evil.com'): with self.subTest(origin=origin): resp = self._preflight(origin) self.assertIsNone(resp.headers.get('access-control-allow-origin')) def test_credentials_stay_off(self): # توکنِ ما در هدر است نه کوکی؛ روشن کردنِ این فقط سطحِ حمله اضافه می‌کند # و پذیرشِ هر پورتِ localhost را واقعاً خطرناک می‌کرد. from django.conf import settings self.assertFalse(settings.CORS_ALLOW_CREDENTIALS)