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:
2026-10-08 13:05:19 +03:30
parent d6529956e1
commit 22c3196343
9 changed files with 74 additions and 53 deletions

View File

@@ -71,8 +71,7 @@ paid on the server on 2026-10-03); orders without the `question_credits`/`hearts
## Before any real user: read `docs/security-before-production.md` ## Before any real user: read `docs/security-before-production.md`
The server is deliberately left in demo mode for the internship presentation: seven The server is deliberately left in demo mode for the internship presentation: seven
fixed-code demo logins exist, demo users sit in the production DB, and `/api/health`, the API fixed-code demo logins exist, demo users sit in the production DB, and the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the
schema, the leaderboard and `/admin/` are all public. (The token signing key is no longer the
committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the
fix. **Do not treat this deployment as production-ready until every item there is closed**, fix. **Do not treat this deployment as production-ready until every item there is closed**,
and when one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only). and when one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).

View File

@@ -190,6 +190,9 @@ def _warnings() -> list[str]:
return out return out
from payment.permissions import HasInternalServiceKey # noqa: E402
class HealthView(APIView): class HealthView(APIView):
""" """
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده. GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
@@ -213,6 +216,13 @@ class HealthView(APIView):
} }
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache')) 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() warnings = _warnings()
payload = { payload = {
'status': 'ok' if critical_ok else 'error', 'status': 'ok' if critical_ok else 'error',
@@ -228,4 +238,4 @@ class HealthView(APIView):
# را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد. # را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد.
'clientIp': ScopedRateThrottle().get_ident(request), 'clientIp': ScopedRateThrottle().get_ident(request),
} }
return Response(payload, status=200 if critical_ok else 503) return Response(payload, status=status)

View File

@@ -636,6 +636,11 @@ SPECTACULAR_SETTINGS = {
{'name': 'System', 'description': 'سلامتِ سرویس'}, {'name': 'System', 'description': 'سلامتِ سرویس'},
], ],
'SERVE_INCLUDE_SCHEMA': False, '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, 'COMPONENT_SPLIT_REQUEST': True,
# دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲). # دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد. # بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد.

View File

@@ -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): def test_swagger_ui_carries_the_rtl_shell(self):
html = self.client.get('/api/docs/').content.decode() html = self.client.get('/api/docs/').content.decode()
self.assertIn('direction: rtl', html) self.assertIn('direction: rtl', html)
@@ -490,3 +499,20 @@ class InsecureSecretKeyWarningTests(TestCase):
self.assertIn('django-insecure', ' '.join(health._warnings())) self.assertIn('django-insecure', ' '.join(health._warnings()))
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'): with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
self.assertNotIn('django-insecure', ' '.join(health._warnings())) 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'})

View File

@@ -36,6 +36,7 @@ class _Quiet:
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0')) @override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
@override_settings(INTERNAL_SERVICE_KEY='k')
class RedisDownTests(_Quiet, TestCase): class RedisDownTests(_Quiet, TestCase):
"""پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود.""" """پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود."""
@@ -86,7 +87,7 @@ class RedisDownTests(_Quiet, TestCase):
self.assertNotEqual(response.status_code, 500) self.assertNotEqual(response.status_code, 500)
def test_health_says_so(self): 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.assertEqual(response.status_code, 503)
self.assertIs(response.json()['checks']['cache']['ok'], False) self.assertIs(response.json()['checks']['cache']['ok'], False)
@@ -107,6 +108,7 @@ def _free_port():
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست') @unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
@override_settings(INTERNAL_SERVICE_KEY='k')
class RealRedisTests(_Quiet, TestCase): class RealRedisTests(_Quiet, TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -155,7 +157,7 @@ class RealRedisTests(_Quiet, TestCase):
def test_throttle_is_shared_and_health_is_ok(self): def test_throttle_is_shared_and_health_is_ok(self):
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')): 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) self.assertIs(response.json()['checks']['cache']['ok'], True)
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self): 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'): with self.assertLogs('config.cache', 'ERROR'):
self.assertEqual(c.get('k', 'miss'), 'miss') self.assertEqual(c.get('k', 'miss'), 'miss')
self.assertEqual(APIClient().get('/api/grades').status_code, 200) 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() # برمی‌گردد، بدونِ ری‌استارتِ برنامه self.start() # برمی‌گردد، بدونِ ری‌استارتِ برنامه
c.set('k2', 'after', 60) c.set('k2', 'after', 60)
self.assertEqual(c.get('k2'), 'after') 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)

View File

@@ -6,7 +6,7 @@
""" """
import json import json
from django.test import TestCase from django.test import override_settings, TestCase
from content.models import Grade, Lesson, Node, Question, Unit from content.models import Grade, Lesson, Node, Question, Unit
from content.question_io import ImportError_, export_questions, import_questions, parse from content.question_io import ImportError_, export_questions, import_questions, parse
@@ -77,9 +77,10 @@ class QuestionImportTests(TestCase):
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵') self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthEndpointTests(TestCase): class HealthEndpointTests(TestCase):
def test_reports_ok_and_counts_content(self): def test_reports_ok_and_counts_content(self):
response = self.client.get('/api/health') response = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = response.json() body = response.json()
self.assertEqual(body['status'], 'ok') self.assertEqual(body['status'], 'ok')
@@ -88,12 +89,12 @@ class HealthEndpointTests(TestCase):
self.assertIn('questions', body['checks']['content']) self.assertIn('questions', body['checks']['content'])
def test_never_leaks_a_secret(self): def test_never_leaks_a_secret(self):
raw = self.client.get('/api/health').content.decode() raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'): for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
self.assertNotIn(needle, raw) self.assertNotIn(needle, raw)
def test_needs_no_authentication(self): def test_needs_no_authentication(self):
self.assertEqual(self.client.get('/api/health').status_code, 200) self.assertEqual(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)
class CurriculumImportTests(TestCase): class CurriculumImportTests(TestCase):
@@ -169,19 +170,21 @@ class CurriculumImportTests(TestCase):
self.assertEqual(Lesson.objects.get().title, 'ضرب توان‌ها') self.assertEqual(Lesson.objects.get().title, 'ضرب توان‌ها')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthWarningTests(TestCase): class HealthWarningTests(TestCase):
def test_debug_mode_is_reported_as_a_warning(self): def test_debug_mode_is_reported_as_a_warning(self):
with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'): with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'):
body = self.client.get('/api/health').json() body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
joined = ' '.join(body['warnings']) joined = ' '.join(body['warnings'])
self.assertIn('DEBUG', joined) self.assertIn('DEBUG', joined)
self.assertIn('0000', joined) self.assertIn('0000', joined)
def test_allowed_origins_include_the_deployed_frontend(self): def test_allowed_origins_include_the_deployed_frontend(self):
body = self.client.get('/api/health').json() body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins']) self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins'])
@override_settings(INTERNAL_SERVICE_KEY='k')
class MigrationCheckTests(TestCase): class MigrationCheckTests(TestCase):
""" """
این بررسی مهم‌ترین چیزی‌ست که /api/health می‌گوید، و تنها وقتی اهمیت دارد این بررسی مهم‌ترین چیزی‌ست که /api/health می‌گوید، و تنها وقتی اهمیت دارد
@@ -231,6 +234,7 @@ class ReplaceCountTests(TestCase):
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵') self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthTrafficTests(TestCase): class HealthTrafficTests(TestCase):
""" """
سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون می‌شود فهمید فرانت سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون می‌شود فهمید فرانت
@@ -243,7 +247,7 @@ class HealthTrafficTests(TestCase):
from accounts.models import User from accounts.models import User
User.objects.create(phone='09120000009', last_activity_at=timezone.now()) User.objects.create(phone='09120000009', last_activity_at=timezone.now())
traffic = self.client.get('/api/health').json()['traffic'] traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
self.assertEqual(traffic['users'], 1) self.assertEqual(traffic['users'], 1)
self.assertEqual(traffic['activeLast24h'], 1) self.assertEqual(traffic['activeLast24h'], 1)
@@ -258,7 +262,7 @@ class HealthTrafficTests(TestCase):
User.objects.create(phone='09120000010', User.objects.create(phone='09120000010',
last_activity_at=timezone.now() - timedelta(days=3)) last_activity_at=timezone.now() - timedelta(days=3))
traffic = self.client.get('/api/health').json()['traffic'] traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
self.assertEqual(traffic['users'], 1) self.assertEqual(traffic['users'], 1)
self.assertEqual(traffic['activeLast24h'], 0) self.assertEqual(traffic['activeLast24h'], 0)
@@ -266,7 +270,7 @@ class HealthTrafficTests(TestCase):
from accounts.models import User from accounts.models import User
User.objects.create(phone='09121234567', name='علی') User.objects.create(phone='09121234567', name='علی')
raw = self.client.get('/api/health').content.decode() raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
self.assertNotIn('09121234567', raw) self.assertNotIn('09121234567', raw)
self.assertNotIn('علی', raw) self.assertNotIn('علی', raw)

View File

@@ -47,9 +47,13 @@ poll کردنِ `/api/health`.
## بررسیِ سلامت ## بررسیِ سلامت
```bash ```bash
curl https://mathapi.tadrisino2.ir/api/health curl https://mathapi.tadrisino2.ir/api/health # عمومی: فقط {"status": "ok"|"error"}
curl -H "X-Internal-Key: <INTERNAL_SERVICE_KEY>" https://mathapi.tadrisino2.ir/api/health # جزئیات
``` ```
کلید در `.env` سرور است (`INTERNAL_SERVICE_KEY`)؛ جایی ننویسید و در چت نفرستید. مستنداتِ API (`/api/docs/`، `/api/redoc/`، `/api/schema/`) فقط برای ادمینِ واردشده به
`/admin/` باز است؛ برای فرانت: `python manage.py spectacular --file schema.yaml --format openapi`.
بدونِ احرازِ هویت و بدونِ SSH می‌گوید: دیتابیس سالم است یا نه، مهاجرتِ بدونِ احرازِ هویت و بدونِ SSH می‌گوید: دیتابیس سالم است یا نه، مهاجرتِ
اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، چند پایه/درس/سوال روی آن سرور اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، چند پایه/درس/سوال روی آن سرور
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتی‌ای هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتی‌ای
@@ -111,7 +115,7 @@ curl https://mathapi.tadrisino2.ir/api/health
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره می‌شود | | `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره می‌شود |
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌ی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیه‌ی تنظیم‌های دامنه از همین دو ساخته می‌شوند؛ بخشِ «عوض کردنِ دامنه» | | `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌ی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیه‌ی تنظیم‌های دامنه از همین دو ساخته می‌شوند؛ بخشِ «عوض کردنِ دامنه» |
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته می‌شوند؛ مقدارِ env فقط دامنه‌ی *اضافه* است | | `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته می‌شوند؛ مقدارِ env فقط دامنه‌ی *اضافه* است |
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد. **روی سرور خالی است عمداً**: Redisِ آن ماشین مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ بعد از ساختنِ Redisِ اختصاصیِ خودمان این را پر کنید. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (`config/cache.py`) | | `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد. روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`، پورت ۶۳۹۰) اشاره می‌کند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (`config/cache.py`) |
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی | | `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی |
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) | | `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد | | `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد |

View File

@@ -23,8 +23,6 @@
|---|---|---|---| |---|---|---|---|
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور | | ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
| ۳ | داده‌ی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور | | ۳ | داده‌ی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد |
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis | | ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
@@ -63,36 +61,6 @@
`purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند). `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند).
- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد. - **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد.
## ۴. `/api/health` عمومی و پرجزئیات
- **چیست:** `config/health.py`، `AllowAny`. بدونِ توکن برمی‌گرداند: `debug`، `warnings`
(مثلاً «شماره‌های تست فعال‌اند»، «محدودیتِ نرخ بینِ workerها مشترک نیست»)، `integrations`
(کدام سرویس‌ها پیکربندی‌اند، وضعیتِ اعتبارِ AI، نامِ مدل)، `traffic` (تعدادِ کاربر، فعالِ
۲۴ ساعتِ اخیر، زمانِ آخرین فعالیت)، `allowedOrigins`، `clientIp`، شمارِ محتوا و مهاجرت‌ها.
- **خطر:** نقشه‌ی حمله: مهاجم می‌فهمد محدودیت‌ها سه‌برابر دورزدنی‌اند، حسابِ کدِ ثابت
هست، اعتبارِ AI کی تمام شده، و ترافیکِ واقعی چقدر است.
- **چرا باز مانده:** صاحبِ پروژه و agentها بدونِ دسترسیِ SSH از همین صفحه سلامتِ سرور را
می‌بینند.
- **رفع:** پاسخِ عمومی فقط `{"status": "ok"|"degraded"}`؛ بقیه پشتِ هدرِ
`X-Internal-Key` (`INTERNAL_SERVICE_KEY`) یا فقط برای staff.
- **چطور بسنجید:** `curl https://mathapi.tadrisino2.ir/api/health` بدونِ هدر نباید
`warnings` و `traffic` داشته باشد.
## ۵. مستنداتِ API عمومی
- **چیست:** `/api/docs/` (Swagger)، `/api/redoc/` و `/api/schema/` بدونِ ورود باز‌اند
(تأیید: HTTP 200). schema همه‌ی endpointها، پارامترها، کدهای خطا و هدرهای داخلی
(`X-Internal-Key`) را فهرست می‌کند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند.
- **رفع:** مسیر را در nginx به IPهای تیم ببندید، یا در `SPECTACULAR_SETTINGS` هر دوِ
`SERVE_PERMISSIONS = ['rest_framework.permissions.IsAdminUser']` و
`SERVE_AUTHENTICATION = ['rest_framework.authentication.SessionAuthentication']` را بگذارید.
`IsAdminUser` به‌تنهایی کار نمی‌کند: احرازِ پیش‌فرضِ DRF فقط توکنِ Bearer است، پس ادمینِ واردشده
به پنل هم ۴۰۱ می‌گیرد (آزموده شد). اسکریپتِ Swagger/ReDoc نسخه‌ی ثابت دارد (نه `@latest`)، چون
روی همان دامنه‌ی `/admin/` اجرا می‌شود.
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی ## ۷. پنلِ ادمین روی دامنه‌ی عمومی
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)، - **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
@@ -259,6 +227,8 @@
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` | | پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) | | لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) |
| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) | | توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
| `/api/health` بی‌کلید فقط `ok`/`error` می‌دهد؛ جزئیات با هدرِ `X-Internal-Key` (کلید در `.env` سرور). بی‌کلیدِ تنظیم‌شده هیچ‌کس جزئیات نمی‌بیند | `config/tests.py` (`HealthIsMinimalForThePublicTests`) |
| Swagger، ReDoc و schema فقط برای ادمینِ واردشده به `/admin/` | `config/tests.py` (`ApiDocsRtlShellTests.test_docs_are_closed_to_anonymous_visitors`) |
| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) | | جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` | | شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` | | OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |

View File

@@ -225,6 +225,7 @@ class ConversationMemoryTests(TestCase):
@override_settings(**RAG_ON) @override_settings(**RAG_ON)
@override_settings(INTERNAL_SERVICE_KEY='k')
class ProviderStateTests(TestCase): class ProviderStateTests(TestCase):
"""اعتبارِ تمام‌شده باید در /api/health دیده شود، نه فقط در لاگ.""" """اعتبارِ تمام‌شده باید در /api/health دیده شود، نه فقط در لاگ."""
@@ -247,17 +248,17 @@ class ProviderStateTests(TestCase):
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001") quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503) self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
health = self.client.get('/api/health').json() health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted') self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
self.assertTrue(any('GapGPT' in w for w in health['warnings'])) self.assertTrue(any('GapGPT' in w for w in health['warnings']))
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200) self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
health = self.client.get('/api/health').json() health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok') self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self): def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503) self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
health = self.client.get('/api/health').json() health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok') self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')