""" 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)