diff --git a/CLAUDE.md b/CLAUDE.md index a8b786d..7ce0bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/config/health.py b/config/health.py index 8751773..0e95ba6 100644 --- a/config/health.py +++ b/config/health.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 1633380..aff9541 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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» می‌نامید و هشدار می‌داد. diff --git a/config/tests.py b/config/tests.py index 3d36712..48fc21d 100644 --- a/config/tests.py +++ b/config/tests.py @@ -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'}) diff --git a/config/tests_cache_resilience.py b/config/tests_cache_resilience.py index d8ea2b6..4cf9b71 100644 --- a/config/tests_cache_resilience.py +++ b/config/tests_cache_resilience.py @@ -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) diff --git a/content/tests_question_io.py b/content/tests_question_io.py index 0df9252..0efa93c 100644 --- a/content/tests_question_io.py +++ b/content/tests_question_io.py @@ -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) diff --git a/docs/operations.md b/docs/operations.md index 42da204..344e82b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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: " 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 را قفل می‌کرد | diff --git a/docs/security-before-production.md b/docs/security-before-production.md index c0014be..8c1f83e 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -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` | diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index 18e6595..af8c17b 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.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')