Close /api/health details and API docs to the public
Health: public gets only ok/error; details need X-Internal-Key. Docs/schema: admin session only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -190,6 +190,9 @@ def _warnings() -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
from payment.permissions import HasInternalServiceKey # noqa: E402
|
||||
|
||||
|
||||
class HealthView(APIView):
|
||||
"""
|
||||
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||||
@@ -213,6 +216,13 @@ class HealthView(APIView):
|
||||
}
|
||||
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||||
|
||||
status = 200 if critical_ok else 503
|
||||
# عموم فقط ok/error میبینند؛ جزئیات (هشدارها، ترافیک، سرویسهای وصلشده، IP) پشتِ هدرِ
|
||||
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی میماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
|
||||
# در .env نباشد، هیچکس جزئیات را نمیبیند (نه اینکه همه ببینند).
|
||||
if not HasInternalServiceKey().has_permission(request, self):
|
||||
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||
|
||||
warnings = _warnings()
|
||||
payload = {
|
||||
'status': 'ok' if critical_ok else 'error',
|
||||
@@ -228,4 +238,4 @@ class HealthView(APIView):
|
||||
# را به خودش برمیگرداند، پس چیزی لو نمیدهد.
|
||||
'clientIp': ScopedRateThrottle().get_ident(request),
|
||||
}
|
||||
return Response(payload, status=200 if critical_ok else 503)
|
||||
return Response(payload, status=status)
|
||||
|
||||
@@ -636,6 +636,11 @@ SPECTACULAR_SETTINGS = {
|
||||
{'name': 'System', 'description': 'سلامتِ سرویس'},
|
||||
],
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser بهتنهایی کافی نیست: احرازِ پیشفرضِ DRF
|
||||
# فقط Bearer است و ادمینِ پنل ۴۰۱ میگرفت؛ SessionAuthentication کوکیِ پنل را میشناسد.
|
||||
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بینیاز از HTTP).
|
||||
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
|
||||
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
# دو فیلدِ «kind» با دو مجموعهی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
|
||||
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» مینامید و هشدار میداد.
|
||||
|
||||
@@ -467,6 +467,15 @@ class ApiDocsRtlShellTests(TestCase):
|
||||
دیگری نمیگیردش.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth.models import User as AuthUser
|
||||
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
|
||||
|
||||
def test_docs_are_closed_to_anonymous_visitors(self):
|
||||
from rest_framework.test import APIClient
|
||||
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
|
||||
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
|
||||
|
||||
def test_swagger_ui_carries_the_rtl_shell(self):
|
||||
html = self.client.get('/api/docs/').content.decode()
|
||||
self.assertIn('direction: rtl', html)
|
||||
@@ -490,3 +499,20 @@ class InsecureSecretKeyWarningTests(TestCase):
|
||||
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()))
|
||||
|
||||
|
||||
class HealthIsMinimalForThePublicTests(TestCase):
|
||||
"""/api/health بیکلید فقط ok/error میدهد؛ با کلیدِ درست جزئیات."""
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
def test_public_sees_only_status(self):
|
||||
body = self.client.get('/api/health').json()
|
||||
self.assertEqual(set(body), {'status'})
|
||||
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
|
||||
self.assertEqual(set(wrong), {'status'})
|
||||
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||
self.assertIn('traffic', full)
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='')
|
||||
def test_without_a_configured_key_nobody_gets_details(self):
|
||||
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})
|
||||
|
||||
@@ -36,6 +36,7 @@ class _Quiet:
|
||||
|
||||
|
||||
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class RedisDownTests(_Quiet, TestCase):
|
||||
"""پورتِ ۱ بسته است: هر اتصال فوراً رد میشود."""
|
||||
|
||||
@@ -86,7 +87,7 @@ class RedisDownTests(_Quiet, TestCase):
|
||||
self.assertNotEqual(response.status_code, 500)
|
||||
|
||||
def test_health_says_so(self):
|
||||
response = APIClient().get('/api/health')
|
||||
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertIs(response.json()['checks']['cache']['ok'], False)
|
||||
|
||||
@@ -107,6 +108,7 @@ def _free_port():
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class RealRedisTests(_Quiet, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -155,7 +157,7 @@ class RealRedisTests(_Quiet, TestCase):
|
||||
|
||||
def test_throttle_is_shared_and_health_is_ok(self):
|
||||
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
|
||||
response = APIClient().get('/api/health')
|
||||
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
|
||||
self.assertIs(response.json()['checks']['cache']['ok'], True)
|
||||
|
||||
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
|
||||
@@ -168,9 +170,9 @@ class RealRedisTests(_Quiet, TestCase):
|
||||
with self.assertLogs('config.cache', 'ERROR'):
|
||||
self.assertEqual(c.get('k', 'miss'), 'miss')
|
||||
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
|
||||
self.assertEqual(APIClient().get('/api/health').status_code, 503)
|
||||
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 503)
|
||||
|
||||
self.start() # برمیگردد، بدونِ ریاستارتِ برنامه
|
||||
c.set('k2', 'after', 60)
|
||||
self.assertEqual(c.get('k2'), 'after')
|
||||
self.assertEqual(APIClient().get('/api/health').status_code, 200)
|
||||
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)
|
||||
|
||||
Reference in New Issue
Block a user