- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py) stops deleted/renamed/re-worded content from being recreated and keeps admin-edited lesson text; new rows in the seed files still arrive (migration content.0010) - plan list cache is invalidated on Plan save (receiver used a stale 'v1' key) - token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE) - a failed gateway result only changes a pending order (forged NOK no longer overwrites refunded/canceled) - tutor gets the correct answer only after the student has answered - support reply notification moved to a TicketMessage post_save signal - global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000) - Bazaar verify uses only the order's SKU; bazaar orders require a SKU - /api/health warns on the committed signing key; deploy.sh saves local commits; 400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion - earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
490 lines
23 KiB
Python
490 lines
23 KiB
Python
import importlib
|
||
import json
|
||
import logging
|
||
import os
|
||
from urllib.parse import urlsplit
|
||
from unittest.mock import patch
|
||
|
||
from django.conf import settings
|
||
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
|
||
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 هم نبودند، هر بار از نو کشف
|
||
میشدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
|
||
اینکه بدونِ هیچ متغیرِ محیطیای مقدارِ کارآمد دارند.
|
||
"""
|
||
|
||
# دامنهی API از PUBLIC_BASE_URL میآید، نه از یک رشتهی ثابت در تست.
|
||
HOST = urlsplit(settings.PUBLIC_BASE_URL).hostname
|
||
|
||
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://mathapi.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)
|
||
|
||
|
||
class ApiDocsRtlShellTests(TestCase):
|
||
"""
|
||
صفحههای مستنداتِ API باید پوستهی راستبهچپِ خودمان را داشته باشند.
|
||
|
||
بدونِ آن، Swagger متنِ فارسی را در یک بلوکِ چپبهراست میچیند و نتیجهاش
|
||
پریدنِ نقطهی جمله به ابتدای خط و برعکس شدنِ ستونهای جدول است. این تست
|
||
هست چون خرابیاش فقط با چشم دیده میشود: هیچ خطایی نمیدهد و هیچ تستِ
|
||
دیگری نمیگیردش.
|
||
"""
|
||
|
||
def test_swagger_ui_carries_the_rtl_shell(self):
|
||
html = self.client.get('/api/docs/').content.decode()
|
||
self.assertIn('direction: rtl', html)
|
||
# کد باید صریحاً چپبهراست بماند، وگرنه «Authorization: Bearer ...»
|
||
# در بلوکِ RTL جابهجا میشود.
|
||
self.assertIn('direction: ltr', html)
|
||
# قالبِ اصلیِ drf-spectacular باید دستنخورده زیرش باشد.
|
||
self.assertIn('swagger-ui-bundle', html)
|
||
|
||
def test_redoc_carries_the_rtl_shell(self):
|
||
html = self.client.get('/api/redoc/').content.decode()
|
||
self.assertIn('direction: rtl', html)
|
||
|
||
|
||
class InsecureSecretKeyWarningTests(TestCase):
|
||
"""کلیدِ کامیتشدهی django-insecure در پروداکشن باید در /api/health دیده شود (۲۰۲۶-۱۰-۰۳)."""
|
||
|
||
def test_committed_key_is_reported(self):
|
||
from config import health
|
||
with override_settings(DEBUG=False, SECRET_KEY='django-insecure-xyz'):
|
||
self.assertIn('django-insecure', ' '.join(health._warnings()))
|
||
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
|
||
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
|