Files
MathApp/config/tests.py
Zareian ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- 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>
2026-10-03 16:05:27 +03:30

490 lines
23 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 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()))