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:
@@ -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`
|
||||
|
||||
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
|
||||
schema, the leaderboard and `/admin/` are all public. (The token signing key is no longer the
|
||||
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
|
||||
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**,
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
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.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, '۵')
|
||||
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class HealthEndpointTests(TestCase):
|
||||
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)
|
||||
body = response.json()
|
||||
self.assertEqual(body['status'], 'ok')
|
||||
@@ -88,12 +89,12 @@ class HealthEndpointTests(TestCase):
|
||||
self.assertIn('questions', body['checks']['content'])
|
||||
|
||||
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'):
|
||||
self.assertNotIn(needle, raw)
|
||||
|
||||
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):
|
||||
@@ -169,19 +170,21 @@ class CurriculumImportTests(TestCase):
|
||||
self.assertEqual(Lesson.objects.get().title, 'ضرب توانها')
|
||||
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class HealthWarningTests(TestCase):
|
||||
def test_debug_mode_is_reported_as_a_warning(self):
|
||||
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'])
|
||||
self.assertIn('DEBUG', joined)
|
||||
self.assertIn('0000', joined)
|
||||
|
||||
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'])
|
||||
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class MigrationCheckTests(TestCase):
|
||||
"""
|
||||
این بررسی مهمترین چیزیست که /api/health میگوید، و تنها وقتی اهمیت دارد
|
||||
@@ -231,6 +234,7 @@ class ReplaceCountTests(TestCase):
|
||||
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵')
|
||||
|
||||
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class HealthTrafficTests(TestCase):
|
||||
"""
|
||||
سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون میشود فهمید فرانت
|
||||
@@ -243,7 +247,7 @@ class HealthTrafficTests(TestCase):
|
||||
from accounts.models import User
|
||||
|
||||
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['activeLast24h'], 1)
|
||||
@@ -258,7 +262,7 @@ class HealthTrafficTests(TestCase):
|
||||
|
||||
User.objects.create(phone='09120000010',
|
||||
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['activeLast24h'], 0)
|
||||
|
||||
@@ -266,7 +270,7 @@ class HealthTrafficTests(TestCase):
|
||||
from accounts.models import User
|
||||
|
||||
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('علی', raw)
|
||||
|
||||
|
||||
@@ -47,9 +47,13 @@ poll کردنِ `/api/health`.
|
||||
## بررسیِ سلامت
|
||||
|
||||
```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 میگوید: دیتابیس سالم است یا نه، مهاجرتِ
|
||||
اعمالنشده مانده یا نه، کش کار میکند یا نه، چند پایه/درس/سوال روی آن سرور
|
||||
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتیای
|
||||
@@ -111,7 +115,7 @@ curl https://mathapi.tadrisino2.ir/api/health
|
||||
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
||||
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنهی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیهی تنظیمهای دامنه از همین دو ساخته میشوند؛ بخشِ «عوض کردنِ دامنه» |
|
||||
| `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` میدهد، نه موفقیتِ ساختگی |
|
||||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
|---|---|---|---|
|
||||
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
|
||||
| ۳ | دادهی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
|
||||
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان میدهد | متوسط | کد |
|
||||
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومیاند | متوسط | کد |
|
||||
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
||||
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب میدهد | متوسط | ادمینِ سرور |
|
||||
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
||||
@@ -63,36 +61,6 @@
|
||||
`purge_demo_data` اینها را **نمیگیرد** (از مسیرِ واقعیِ viewها ساخته شدهاند).
|
||||
- **چطور بسنجید:** `/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`)،
|
||||
@@ -259,6 +227,8 @@
|
||||
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمیزند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبتشده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
|
||||
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاهعمرِ همراهِ `paymentUrl` باز میشود | `payment/tests.py` (`PaymentRedirectViewTests`) |
|
||||
| توکنِ تمدید فقط بهصورتِ هشِ 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`) |
|
||||
| شمارهی موبایل نرمال میشود؛ cooldown، سقفِ روزانهی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده میشوند | `accounts/tests_otp_limits.py` |
|
||||
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکنهای قبلی را باطل میکند | `accounts/tests_audit_fixes.py` |
|
||||
|
||||
@@ -225,6 +225,7 @@ class ConversationMemoryTests(TestCase):
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||
class ProviderStateTests(TestCase):
|
||||
"""اعتبارِ تمامشده باید در /api/health دیده شود، نه فقط در لاگ."""
|
||||
|
||||
@@ -247,17 +248,17 @@ class ProviderStateTests(TestCase):
|
||||
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
|
||||
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.assertTrue(any('GapGPT' in w for w in health['warnings']))
|
||||
|
||||
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')
|
||||
|
||||
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)
|
||||
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')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user