Spaces:
Running
Running
File size: 23,303 Bytes
9983566 8cc0538 9983566 8cc0538 7a11b13 9983566 1c833d2 9983566 1c833d2 350f2ff 9983566 1c833d2 9983566 00454c6 9983566 62c1832 9983566 62c1832 9983566 3d3384a 9983566 8cc0538 c1767cf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 | """
test_bot_admin.py — Админка и webhook: гейты секретов, healthcheck, /export_state, /logs, /stats.
Выделено из test_bot.py (P2 аудита); общие фейки — в bot_test_helpers.py.
"""
import asyncio
import bot
import logging
import os
import threading
import pytest
from unittest.mock import Mock
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_returns_detached_snapshot():
# Аудит A11-05: экспорт отдавал живые ссылки на мутабельные объекты —
# запись между возвратом и сериализацией давала несогласованный бэкап.
original = bot.ADMIN_PANEL_KEY
bot.ADMIN_PANEL_KEY = "real-admin-key"
chat_id = 999980
state = bot.get_state(chat_id)
state["history"].append({"role": "user", "content": "hi"})
bot.GLOBAL_QUOTA.setdefault("groq", {})["snaptest"] = {"used": 1}
try:
result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"})))
bot.GLOBAL_QUOTA["groq"]["snaptest"]["used"] = 999
state["history"].append({"role": "user", "content": "after"})
assert result["global_quota"]["groq"]["snaptest"]["used"] == 1
assert all(m["content"] != "after" for m in result["chats"][str(chat_id)]["history"])
finally:
bot.GLOBAL_QUOTA.get("groq", {}).pop("snaptest", None)
bot.chat_state.pop(chat_id, None)
bot.ADMIN_PANEL_KEY = original
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)
# ─────────────── lifecycle: токен, часовой цикл, старт (S7) ───────────────
def test_require_bot_token_exits_on_empty_token(monkeypatch):
# A1-1: пустой токен раньше умирал внутри Bot() с TokenValidationError и
# рестартами — теперь чистый выход с понятной причиной.
monkeypatch.setattr(bot, "BOT_TOKEN", "")
with pytest.raises(SystemExit):
bot._require_bot_token()
def test_require_bot_token_returns_configured_token(monkeypatch):
# Сторож: непустой токен молча проходит дальше в Bot().
monkeypatch.setattr(bot, "BOT_TOKEN", "123:abc")
assert bot._require_bot_token() == "123:abc"
def _sleep_then_cancel(loop_sleeps):
# Первые loop_sleeps+1 вызовов sleep проходят (1.5с старта + тики),
# дальше CancelledError останавливает бесконечный цикл стартапа.
state = {"n": 0}
async def _fake_sleep(delay):
state["n"] += 1
if state["n"] > loop_sleeps + 1:
raise asyncio.CancelledError
return _fake_sleep
def _patch_startup(monkeypatch, tg_impl, loader=None):
monkeypatch.setattr(bot, "load_state_from_disk", loader or Mock())
monkeypatch.setattr(bot, "_check_temporary_free_models_expiry", Mock())
monkeypatch.setattr(bot, "_check_unconfirmed_model_quotas", Mock())
monkeypatch.setattr(bot, "_check_scheduled_removals_due", Mock())
monkeypatch.setattr(bot, "telegram_api_call", tg_impl)
monkeypatch.setattr(bot, "_cleanup_rate_limit_dict", Mock())
monkeypatch.setattr(bot, "_evict_orphan_chat_locks", Mock())
monkeypatch.setattr(bot, "_reset_quota_if_new_day", Mock())
# getMe в try_setup переписывает globals — снапшот для отката.
monkeypatch.setattr(bot, "BOT_USERNAME", bot.BOT_USERNAME)
monkeypatch.setattr(bot, "OPENROUTER_HTTP_REFERER", bot.OPENROUTER_HTTP_REFERER)
async def _fake_tg_ok(method, *args, **kwargs):
if method == "getMe":
return {"username": "testbot"}
return {}
def test_hourly_tick_survives_failing_step(monkeypatch):
# A1-2: упавшая чистка раньше убивала часовой цикл навсегда — теперь
# второй тик всё равно наступает.
monkeypatch.setattr(bot.asyncio, "sleep", _sleep_then_cancel(2))
_patch_startup(monkeypatch, _fake_tg_ok)
monkeypatch.setattr(
bot, "_cleanup_rate_limit_dict",
Mock(side_effect=[RuntimeError("boom"), None]),
)
with pytest.raises(asyncio.CancelledError):
asyncio.run(bot._webhook_startup())
# Упавший шаг пропускает остаток своего тика, но цикл живёт: чистка звалась
# дважды (второй тик наступил), evict — один раз (первый тик оборвался).
assert bot._cleanup_rate_limit_dict.call_count == 2
assert bot._evict_orphan_chat_locks.call_count == 1
def test_startup_loads_state_off_loop(monkeypatch):
# A1-3: синхронное чтение диска раньше стопорило loop на старте.
seen = {}
monkeypatch.setattr(bot.asyncio, "sleep", _sleep_then_cancel(0))
_patch_startup(monkeypatch, _fake_tg_ok,
loader=lambda: seen.setdefault("thread", threading.current_thread()))
with pytest.raises(asyncio.CancelledError):
asyncio.run(bot._webhook_startup())
assert seen["thread"] is not threading.main_thread()
def test_startup_logs_commands_failure_honestly(monkeypatch, caplog):
# A1-4: провал всех языков раньше логировался как успех (for-else без break).
async def _fake_tg_fail_commands(method, *args, **kwargs):
if method == "getMe":
return {"username": "testbot"}
if method == "setMyCommands":
raise RuntimeError("boom")
return {}
monkeypatch.setattr(bot.asyncio, "sleep", _sleep_then_cancel(0))
_patch_startup(monkeypatch, _fake_tg_fail_commands)
with caplog.at_level(logging.INFO, logger="bot"), pytest.raises(asyncio.CancelledError):
asyncio.run(bot._webhook_startup())
assert "were not set for any language" in caplog.text
assert "Bot commands set successfully" not in caplog.text
def test_startup_logs_commands_success(monkeypatch, caplog):
# Сторож: хотя бы один язык прошёл — лог успеха на месте.
monkeypatch.setattr(bot.asyncio, "sleep", _sleep_then_cancel(0))
_patch_startup(monkeypatch, _fake_tg_ok)
with caplog.at_level(logging.INFO, logger="bot"), pytest.raises(asyncio.CancelledError):
asyncio.run(bot._webhook_startup())
assert "Bot commands set successfully" in caplog.text
def test_message_mentions_bot_without_chat_returns_false():
# A1-6: chat None раньше давал AttributeError, апдейт глотался хендлером.
class _NoChatMessage:
chat = None
text = "@somebot hello"
caption = None
reply_to_message = None
assert bot.message_mentions_bot(_NoChatMessage()) is False
def test_webhook_setup_retries_then_raises(monkeypatch):
# Аудит D4: провал setWebhook без ретрая давал тихий мёртвый бот — теперь
# ретраи с бэкоффом и явный отказ вместо молчания.
sleeps = []
async def fast_sleep(delay):
sleeps.append(delay)
async def always_fail(method, *args, **kwargs):
if method == "getMe":
return {"username": "testbot"}
raise RuntimeError("boom")
monkeypatch.setattr(bot.asyncio, "sleep", fast_sleep)
monkeypatch.setattr(bot, "SETWEBHOOK_MAX_ATTEMPTS", 3)
_patch_startup(monkeypatch, always_fail)
with pytest.raises(RuntimeError, match="setWebhook"):
asyncio.run(bot._webhook_startup())
assert sleeps == [1.5, 5.0, 10.0]
def test_webhook_setup_succeeds_after_retries(monkeypatch):
# Сторож: два провала подряд — третий регистрирует, стартап живёт дальше.
calls = {"hook": 0}
sleeps = []
async def fast_sleep(delay):
sleeps.append(delay)
if len(sleeps) > 3:
raise asyncio.CancelledError
async def flaky(method, *args, **kwargs):
if method == "getMe":
return {"username": "testbot"}
if method == "setWebhook":
calls["hook"] += 1
if calls["hook"] < 3:
raise RuntimeError("boom")
return True
return {}
monkeypatch.setattr(bot.asyncio, "sleep", fast_sleep)
_patch_startup(monkeypatch, flaky)
with pytest.raises(asyncio.CancelledError):
asyncio.run(bot._webhook_startup())
assert calls["hook"] == 3
assert sleeps == [1.5, 5.0, 10.0, 3600]
|