stop an existing .env from cancelling the defaults meant to replace it
پیشفرضهای دیروز روی یک سرورِ تازه کار میکردند ولی روی سرورِ واقعی نه، و تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثیشان میکرد. مقدارِ موجود ولی ناقص: .env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً جایگزینِ پیشفرض میشد و دامنهی api.math.tadrisino2.ir باز هم غایب میماند — یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا دامنههای خودمان به مقدارِ محیطی اضافه میشوند نه جایگزینش؛ همین برای CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد. مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد. نتیجهاش paymentUrl نسبی و callbackِ خالیِ زرینپال بود — خرابیای که فقط موقعِ اولین پرداختِ واقعی معلوم میشود. حالا مقدارِ خالی هم به پیشفرض میافتد. این سهتا همانهاییاند که تا امروز مسئولِ سرور باید دستی به .env اضافه میکرد و چون در .env.example هم نبودند، هر بار از نو کشف میشدند. حالا در همان فایل مستند شدهاند و تست نگهبانشان است. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,22 +67,42 @@ if not SECRET_KEY:
|
||||
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
|
||||
) from exc
|
||||
|
||||
# دامنههای شناختهشدهی خودمان پیشفرضاند تا یک متغیرِ فراموششده باعثِ
|
||||
# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان میکند.
|
||||
def _env_list(name: str) -> list[str]:
|
||||
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
|
||||
|
||||
|
||||
def _with_defaults(name: str, defaults: list[str]) -> list[str]:
|
||||
"""
|
||||
مقدارِ محیطی + پیشفرضهای خودمان، بدونِ تکرار و با حفظِ ترتیب.
|
||||
|
||||
چرا اضافه و نه جایگزین: فایلِ .env روی سرور از قبل وجود دارد و مقدارهای
|
||||
ناقص دارد (مثلاً DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1). اگر مقدارِ
|
||||
موجود پیشفرض را کنار میزد، همان خطای قدیمی برمیگشت و باز کسی باید
|
||||
دستی فایل را ویرایش میکرد. دامنههای خودمان همیشه باید مجاز باشند.
|
||||
"""
|
||||
seen, out = set(), []
|
||||
for value in _env_list(name) + defaults:
|
||||
if value not in seen:
|
||||
seen.add(value)
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
# دامنههای شناختهشدهی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ
|
||||
# DisallowedHost نشود؛ هر چه در محیط باشد به اینها اضافه میشود.
|
||||
DEFAULT_ALLOWED_HOSTS = [
|
||||
'api.math.tadrisino2.ir',
|
||||
'math.tadrisino2.ir',
|
||||
'localhost', '127.0.0.1',
|
||||
]
|
||||
ALLOWED_HOSTS = [
|
||||
h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h
|
||||
] or DEFAULT_ALLOWED_HOSTS
|
||||
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||||
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o
|
||||
] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir']
|
||||
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
||||
'CSRF_TRUSTED_ORIGINS',
|
||||
['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'],
|
||||
)
|
||||
|
||||
# --- CORS -------------------------------------------------------------------
|
||||
# فرانتاند (Vite/React) از یک origin دیگر صدا میزند، پس بدونِ این هدرها مرورگر
|
||||
@@ -101,9 +121,7 @@ CORS_DEFAULT_ORIGINS = [
|
||||
'http://localhost:3000', 'http://127.0.0.1:3000',
|
||||
'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی
|
||||
]
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o
|
||||
] or CORS_DEFAULT_ORIGINS
|
||||
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
||||
CORS_ALLOW_CREDENTIALS = False
|
||||
# فقط مسیرهای API؛ /admin/ و فایلهای استاتیک لازم نیست از origin دیگر خوانده شوند.
|
||||
CORS_URLS_REGEX = r'^/api/.*$'
|
||||
@@ -490,8 +508,18 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
|
||||
# -----------------------------------------------------------------------
|
||||
# آدرسِ عمومیِ خودِ بکاند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمیگردد
|
||||
# (PaymentRedirectView) و هم برای callback_url که به زرینپال میفرستیم لازم
|
||||
# است. بدونش این دو URL شکسته میمانند — سیگنالِ واضحی که هنوز ست نشده.
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/')
|
||||
# است.
|
||||
#
|
||||
# چرا پیشفرض دارد: خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
||||
# بیسروصدا میشکند — paymentUrl نسبی میشود و زرینپال callback خالی
|
||||
# میگیرد. این دقیقاً همان دسته خطاست که فقط موقعِ اولین پرداختِ واقعی معلوم
|
||||
# میشود. دامنهی خودمان از قبل معلوم است، پس حدس زدنی نیست.
|
||||
# .strip() اول لازم است: کلید در .env سرور وجود دارد ولی خالی است، و
|
||||
# os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد — مقدارِ خالی آن را
|
||||
# خنثی میکرد و مسیرِ پرداخت باز هم بیصدا میشکست.
|
||||
PUBLIC_BASE_URL = (
|
||||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
||||
).rstrip('/')
|
||||
|
||||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||||
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -306,3 +307,52 @@ class JSONLogFormatterTests(SimpleTestCase):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user