Spaces:
Running
Running
| """ | |
| test_bot_admin.py — Админка и webhook: гейты секретов, healthcheck, /export_state, /logs, /stats. | |
| Выделено из test_bot.py (P2 аудита); общие фейки — в bot_test_helpers.py. | |
| """ | |
| import asyncio | |
| import bot | |
| import os | |
| from tests.bot_test_helpers import ( | |
| _FakeAdminRequest, | |
| _FakeWebhookRequest, | |
| _run_webhook_handler, | |
| ) | |
| def test_check_bot_token_auth_accepts_correct_bearer_header(): | |
| original = bot.BOT_TOKEN | |
| bot.BOT_TOKEN = "real-secret-token" | |
| try: | |
| req = _FakeAdminRequest(headers={"Authorization": "Bearer real-secret-token"}) | |
| assert bot._check_bot_token_auth(req) is True | |
| finally: | |
| bot.BOT_TOKEN = original | |
| def test_check_bot_token_auth_rejects_query_param_regression(): | |
| # РЕГРЕССИЯ (код-ревью): раньше BOT_TOKEN читался из ?bot_token=... в URL — GET- | |
| # запрос с секретом в query-строке попадает в access-логи прокси/историю браузера | |
| # (CWE-598). Теперь query-параметр должен полностью ИГНОРИРОВАТЬСЯ — единственный | |
| # легитимный путь — заголовок Authorization: Bearer. | |
| original = bot.BOT_TOKEN | |
| bot.BOT_TOKEN = "real-secret-token" | |
| try: | |
| req = _FakeAdminRequest(headers={}, query_params={"bot_token": "real-secret-token"}) | |
| assert bot._check_bot_token_auth(req) is False | |
| finally: | |
| bot.BOT_TOKEN = original | |
| def test_check_bot_token_auth_rejects_wrong_or_missing_header(): | |
| original = bot.BOT_TOKEN | |
| bot.BOT_TOKEN = "real-secret-token" | |
| try: | |
| assert bot._check_bot_token_auth(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"})) is False | |
| assert bot._check_bot_token_auth(_FakeAdminRequest(headers={})) is False | |
| # Без префикса "Bearer " — тоже отказ, даже если сам токен совпадает. | |
| assert bot._check_bot_token_auth(_FakeAdminRequest(headers={"Authorization": "real-secret-token"})) is False | |
| finally: | |
| bot.BOT_TOKEN = original | |
| def test_check_admin_key_accepts_correct_bearer_header(): | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| req = _FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"}) | |
| assert bot._check_admin_key(req) is True | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_check_admin_key_rejects_query_param_regression(): | |
| # РЕГРЕССИЯ (аудит техдолга): раньше ADMIN_PANEL_KEY читался из ?key=... в URL — | |
| # та же уязвимость (CWE-598), что уже была исправлена для BOT_TOKEN в /admin_keys | |
| # (см. test_check_bot_token_auth_rejects_query_param_regression), но не была | |
| # применена к /diag/webhook_url/export_state. Query-параметр больше не должен | |
| # приниматься вообще, даже если значение верное. | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| req = _FakeAdminRequest(headers={}, query_params={"key": "real-admin-key"}) | |
| assert bot._check_admin_key(req) is False | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_check_admin_key_rejects_wrong_or_missing_key(): | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| assert bot._check_admin_key(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"})) is False | |
| assert bot._check_admin_key(_FakeAdminRequest(headers={})) is False | |
| assert bot._check_admin_key(_FakeAdminRequest(headers={"Authorization": "real-admin-key"})) is False | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_webhook_handler_tracks_dispatched_task_for_shutdown(): | |
| original_secret = bot.WEBHOOK_SECRET | |
| original_bot_obj = bot.bot | |
| original_process = bot._process_raw_update | |
| bot.WEBHOOK_SECRET = "real-webhook-secret" | |
| bot.bot = object() | |
| bot._inflight_tasks.clear() | |
| async def fake_process(raw_update): | |
| await asyncio.sleep(0) | |
| bot._process_raw_update = fake_process | |
| try: | |
| req = _FakeWebhookRequest( | |
| headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"}, | |
| body={"update_id": 1}, | |
| ) | |
| asyncio.run(_run_webhook_handler(req)) | |
| # _run_webhook_handler уже дожидается одного тика планировщика — к этому | |
| # моменту короткая fake_process должна была завершиться и самоудалиться | |
| # из набора (см. test_track_inflight_task_registers_and_self_removes_on_completion). | |
| assert not bot._inflight_tasks | |
| finally: | |
| bot.WEBHOOK_SECRET = original_secret | |
| bot.bot = original_bot_obj | |
| bot._process_raw_update = original_process | |
| def test_healthcheck_reports_not_ready_when_bot_or_client_uninitialized(): | |
| original_bot, original_client = bot.bot, bot.client | |
| bot.bot = None | |
| bot.client = None | |
| try: | |
| result = asyncio.run(bot.healthcheck()) | |
| assert result == {"status": "starting", "ready": False} | |
| finally: | |
| bot.bot, bot.client = original_bot, original_client | |
| def test_healthcheck_reports_ready_when_initialized(): | |
| original_bot, original_client = bot.bot, bot.client | |
| bot.bot = object() | |
| bot.client = object() | |
| try: | |
| result = asyncio.run(bot.healthcheck()) | |
| assert result == {"status": "ok", "ready": True} | |
| finally: | |
| bot.bot, bot.client = original_bot, original_client | |
| def test_export_state_rejects_missing_or_wrong_key(): | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"}))) | |
| # Отказ — честный 401, а не 200 с телом {"error": ...}: иначе брутфорс | |
| # ключа отличался от успеха только телом ответа (аудит 26.09.2026). | |
| assert result.status_code == 401 | |
| assert b"error" in result.body | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_export_state_logs_denied_attempt(caplog): | |
| import logging | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| with caplog.at_level(logging.WARNING, logger="bot"): | |
| asyncio.run(bot.export_state(_FakeAdminRequest(headers={}))) | |
| assert any("[admin] Denied GET /export_state" in r.getMessage() for r in caplog.records) | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_fastapi_schema_endpoints_are_disabled(): | |
| # Публичная схема на HF Space описывала все эндпоинты бесплатно. | |
| assert bot.app.docs_url is None | |
| assert bot.app.redoc_url is None | |
| assert bot.app.openapi_url is None | |
| def test_export_state_rejects_query_param_regression(): | |
| # См. test_check_admin_key_rejects_query_param_regression — /export_state — самый | |
| # чувствительный из трёх эндпоинтов (отдаёт ПОЛНЫЕ истории всех чатов), поэтому | |
| # регрессия здесь проверяется отдельно, а не только на уровне _check_admin_key. | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| try: | |
| result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={}, query_params={"key": "real-admin-key"}))) | |
| assert result.status_code == 401 | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| def test_export_state_returns_chats_and_quota_with_valid_key(): | |
| original = bot.ADMIN_PANEL_KEY | |
| bot.ADMIN_PANEL_KEY = "real-admin-key" | |
| chat_id = 999411 | |
| bot.chat_state[chat_id] = { | |
| "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}], | |
| "quota": {}, "recent_media_ids": {}, "last_activity": 0.0, | |
| } | |
| try: | |
| result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"}))) | |
| assert str(chat_id) in result["chats"] | |
| assert result["chats"][str(chat_id)]["history"] == [{"role": "user", "content": "hi"}] | |
| assert "global_quota" in result | |
| assert "exported_at" in result | |
| finally: | |
| bot.ADMIN_PANEL_KEY = original | |
| bot.chat_state.pop(chat_id, None) | |
| def test_webhook_handler_accepts_valid_secret_and_dispatches_update(): | |
| original_secret = bot.WEBHOOK_SECRET | |
| original_bot_obj = bot.bot | |
| original_process = bot._process_raw_update | |
| bot.WEBHOOK_SECRET = "real-webhook-secret" | |
| bot.bot = object() # не-None достаточно, чтобы пройти проверку "бот уже инициализирован" | |
| calls = [] | |
| async def fake_process(raw_update): | |
| calls.append(raw_update) | |
| bot._process_raw_update = fake_process | |
| try: | |
| req = _FakeWebhookRequest( | |
| headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"}, | |
| body={"update_id": 1}, | |
| ) | |
| result = asyncio.run(_run_webhook_handler(req)) | |
| assert result == {"ok": True} | |
| assert calls == [{"update_id": 1}] | |
| finally: | |
| bot.WEBHOOK_SECRET = original_secret | |
| bot.bot = original_bot_obj | |
| bot._process_raw_update = original_process | |
| def test_webhook_handler_rejects_invalid_or_missing_secret_and_does_not_dispatch(): | |
| original_secret = bot.WEBHOOK_SECRET | |
| original_process = bot._process_raw_update | |
| bot.WEBHOOK_SECRET = "real-webhook-secret" | |
| calls = [] | |
| async def fake_process(raw_update): | |
| calls.append(raw_update) | |
| bot._process_raw_update = fake_process | |
| try: | |
| for bad_headers in ( | |
| {"X-Telegram-Bot-Api-Secret-Token": "wrong-secret"}, | |
| {}, | |
| ): | |
| req = _FakeWebhookRequest(headers=bad_headers, body={"update_id": 1}) | |
| result = asyncio.run(_run_webhook_handler(req)) | |
| assert result == {"ok": False} | |
| assert calls == [] | |
| finally: | |
| bot.WEBHOOK_SECRET = original_secret | |
| bot._process_raw_update = original_process | |
| def test_webhook_handler_drops_update_when_bot_not_yet_initialized(): | |
| # Апдейт может прийти раньше, чем main() успеет создать глобальный bot (Bot/ | |
| # genai.Client создаются уже после старта uvicorn) — отвечаем 503, чтобы | |
| # Telegram повторил апдейт, а не считаем дроп успехом (AUD-E-003). | |
| original_secret = bot.WEBHOOK_SECRET | |
| original_bot_obj = bot.bot | |
| original_process = bot._process_raw_update | |
| bot.WEBHOOK_SECRET = "real-webhook-secret" | |
| bot.bot = None | |
| calls = [] | |
| async def fake_process(raw_update): | |
| calls.append(raw_update) | |
| bot._process_raw_update = fake_process | |
| try: | |
| req = _FakeWebhookRequest( | |
| headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"}, | |
| body={"update_id": 1}, | |
| ) | |
| result = asyncio.run(_run_webhook_handler(req)) | |
| assert result.status_code == 503 | |
| assert calls == [] | |
| finally: | |
| bot.WEBHOOK_SECRET = original_secret | |
| bot.bot = original_bot_obj | |
| bot._process_raw_update = original_process | |
| def test_allowed_updates_contains_only_real_telegram_types(): | |
| # guest_message — валидное поле Update (guest mode, Bot API; проверено по | |
| # core.telegram.org/bots/api 2026-09-21). Удаление отсюда было ошибкой | |
| # аудита AUD-J-001 и ломало гостевой режим — этот тест её ловит. | |
| assert "guest_message" in bot.ALLOWED_UPDATES | |
| assert "message" in bot.ALLOWED_UPDATES | |
| def test_admin_secrets_are_independent_of_bot_token_when_seed_set(): | |
| # РЕГРЕССИЯ (аудит техдолга): раньше WEBHOOK_SECRET/ADMIN_PANEL_KEY выводились | |
| # ИСКЛЮЧИТЕЛЬНО из BOT_TOKEN — компрометация токена компрометировала оба сразу, | |
| # и ни один нельзя было ротировать независимо. Теперь можно задать отдельную соль. | |
| # | |
| # Прежняя версия теста считала sha256 дважды и сравнивала соль саму с собой, | |
| # то есть НЕ вызывала код бота и проходила при любой поломке вывода ключей | |
| # (враждебное ревью 27.09.2026). Теперь проверяем настоящий инвариант модуля. | |
| import hashlib | |
| seed = bot._ADMIN_SECRET_SEED | |
| assert seed, "соль обязана быть непустой, иначе ключи предсказуемы" | |
| # Ключи — реальные производные текущей соли, а не литералы в тесте. | |
| assert bot.WEBHOOK_SECRET == hashlib.sha256(seed.encode()).hexdigest()[:32] | |
| assert bot.ADMIN_PANEL_KEY == hashlib.sha256(seed.encode() + b"admin_panel").hexdigest()[:24] | |
| # Два ключа не совпадают, и ни один не равен самому токену. | |
| assert bot.WEBHOOK_SECRET != bot.ADMIN_PANEL_KEY | |
| assert bot.WEBHOOK_SECRET != bot.BOT_TOKEN | |
| assert bot.ADMIN_PANEL_KEY != bot.BOT_TOKEN | |
| # Соль из ADMIN_SECRET_SEED, когда она задана, — ключи не выводятся из BOT_TOKEN. | |
| configured_seed = os.environ.get("ADMIN_SECRET_SEED", "").strip() | |
| if configured_seed: | |
| assert seed == configured_seed | |
| def test_admin_secret_seed_falls_back_to_bot_token_when_unset(): | |
| # Без ADMIN_SECRET_SEED поведение идентично прежнему (соль = BOT_TOKEN) — не | |
| # ломает существующие деплои, которые эту переменную не настраивали. | |
| # Прежняя версия ждала `or "default"`, а код делает `or secrets.token_hex(32)` | |
| # (bot.py) — ветка «всё пусто» не проверялась и ожидание было неверным. | |
| import re | |
| seed_env = os.environ.get("ADMIN_SECRET_SEED", "").strip() | |
| if seed_env: | |
| assert bot._ADMIN_SECRET_SEED == seed_env | |
| elif bot.BOT_TOKEN: | |
| assert bot._ADMIN_SECRET_SEED == bot.BOT_TOKEN | |
| else: | |
| # Оба пустые: случайная соль, а не литерал "default". | |
| assert re.fullmatch(r"[0-9a-f]{64}", bot._ADMIN_SECRET_SEED) | |