File size: 33,261 Bytes
9983566
 
 
 
 
 
 
8cc0538
9983566
8cc0538
ade5ff6
8cc0538
 
7a11b13
9983566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0fd5ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9983566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c833d2
 
 
 
9983566
 
 
 
1c833d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350f2ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9983566
 
 
 
 
 
 
 
1c833d2
9983566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0562341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ade5ff6
 
 
0562341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5fc83f
 
 
 
 
 
 
 
 
 
 
 
9983566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b711b21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9983566
 
 
 
 
00454c6
 
 
 
9983566
 
 
 
 
 
 
62c1832
 
 
 
9983566
62c1832
 
 
 
 
 
 
 
 
 
 
 
 
 
9983566
 
 
 
 
3d3384a
 
 
 
 
 
 
 
 
 
 
9983566
8cc0538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3438464
 
 
 
 
 
 
 
 
 
 
 
 
8cc0538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1767cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e3aacf
e2c6512
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e3aacf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
"""
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 time
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_bearer_non_ascii_returns_false_not_500():
    # compare_digest на str падает TypeError на не-ASCII: отказ без исключения.
    original = bot.ADMIN_PANEL_KEY
    bot.ADMIN_PANEL_KEY = "real-admin-key"
    try:
        assert bot._check_bearer_token(_FakeAdminRequest(headers={"Authorization": "Bearer café"}), "real-admin-key") is False
        assert bot._check_admin_key(_FakeAdminRequest(headers={"Authorization": "Bearer café"})) is False
    finally:
        bot.ADMIN_PANEL_KEY = original


def test_webhook_non_ascii_secret_returns_false_not_500():
    original = bot.WEBHOOK_SECRET
    bot.WEBHOOK_SECRET = "real-webhook-secret"
    try:
        req = _FakeWebhookRequest(headers={"X-Telegram-Bot-Api-Secret-Token": "café"}, body={"update_id": 1})
        result = asyncio.run(_run_webhook_handler(req))
        assert result == {"ok": False}
    finally:
        bot.WEBHOOK_SECRET = 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_rejects_oversized_body_before_reading():
    # Тело сверх капа: отказ до разбора, сырые байты не читаем целиком.
    import lumen_admin
    original_secret = bot.WEBHOOK_SECRET
    bot.WEBHOOK_SECRET = "real-webhook-secret"
    try:
        req = _FakeWebhookRequest(
            headers={
                "X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret",
                "Content-Length": str(lumen_admin.WEBHOOK_MAX_BODY_BYTES + 1),
            },
            body={"update_id": 1},
        )
        assert asyncio.run(_run_webhook_handler(req)) == {"ok": False}
        assert req.body_calls == 0
        # Заголовок соврал (маленький), тело большое — тоже отказ.
        req2 = _FakeWebhookRequest(
            headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"},
            body={"update_id": 2},
        )
        req2._raw = b"x" * (lumen_admin.WEBHOOK_MAX_BODY_BYTES + 1)
        assert asyncio.run(_run_webhook_handler(req2)) == {"ok": False}
    finally:
        bot.WEBHOOK_SECRET = original_secret


def test_webhook_denied_log_is_throttled(caplog):
    # Гроздья отклонений — одной строкой, а не WARNING на каждый запрос в очередь.
    import logging
    import lumen_admin
    orig_count = lumen_admin._webhook_denied_count
    orig_last = lumen_admin._webhook_denied_last_log
    lumen_admin._webhook_denied_count = 0
    # Ноль monotonic вместо прошлого: на свежей CI-машине uptime < 60с и первый
    # отказ молча копится (прод 07.10.2026) — уводим метку в прошлое явно.
    lumen_admin._webhook_denied_last_log = time.monotonic() - lumen_admin._WEBHOOK_DENIED_LOG_INTERVAL_SEC - 1.0
    original_secret = bot.WEBHOOK_SECRET
    bot.WEBHOOK_SECRET = "real-webhook-secret"
    try:
        with caplog.at_level(logging.WARNING, logger="bot"):
            for _ in range(3):
                req = _FakeWebhookRequest(
                    headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
                    body={"update_id": 1},
                )
                assert asyncio.run(_run_webhook_handler(req)) == {"ok": False}
        denied = [r for r in caplog.records if "Rejected" in r.getMessage()]
        assert len(denied) == 1
    finally:
        bot.WEBHOOK_SECRET = original_secret
        lumen_admin._webhook_denied_count = orig_count
        lumen_admin._webhook_denied_last_log = orig_last


def test_webhook_handler_returns_503_when_inflight_full(monkeypatch):
    # Переполнение фоновых задач — 503 с повтором, апдейт не теряется молча.
    import lumen_admin
    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()
    calls = []

    async def fake_process(raw_update):
        calls.append(raw_update)

    bot._process_raw_update = fake_process
    monkeypatch.setattr(lumen_admin, "WEBHOOK_MAX_INFLIGHT_TASKS", 0)
    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


@pytest.mark.parametrize(("env_value", "expected"), [
    ("abc", 25.0), ("", 25.0), ("0", 25.0), ("-5", 25.0), ("30", 30.0),
])
def test_diag_budget_sec_falls_back_on_garbage(monkeypatch, env_value, expected):
    # Голый float() давал 500 на мусоре в env, а 0 молча ронял все зонды.
    import lumen_admin
    monkeypatch.setenv("DIAG_TOTAL_BUDGET_SEC", env_value)
    assert lumen_admin._diag_budget_sec() == expected
    monkeypatch.delenv("DIAG_TOTAL_BUDGET_SEC", raising=False)
    assert lumen_admin._diag_budget_sec() == 25.0


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_webhook_handler_retries_broken_body_instead_of_swallowing():
    # Битое тело раньше отвечало ok:true и Telegram считал дроп успехом (аудит M2).
    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()
    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},
        )
        req._raw = b"{broken-json"
        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 test_secret_log_formatter_survives_interpreter_teardown():
    # Финализация обнуляет глобалы модуля: форматирование не должно падать.
    import logging
    fmt = bot._SecretLogFormatter()
    rec = logging.LogRecord("bot", logging.WARNING, __file__, 1, "hello %s", ("world",), None)
    orig = bot._SECRET_NAMES
    bot._SECRET_NAMES = None
    try:
        assert fmt.format(rec) == "hello world"
    finally:
        bot._SECRET_NAMES = orig


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]


def test_main_exits_nonzero_when_server_dies_first(monkeypatch, caplog):
    # Падение uvicorn первым (порт занят) раньше терялось: процесс тихо выходил
    # с кодом 0. Теперь исключение сервера логируется и даёт SystemExit(1).
    from types import SimpleNamespace
    from unittest.mock import AsyncMock
    import lumen_chat_state
    monkeypatch.setattr(bot, "_require_bot_token", lambda: "123:abc")
    monkeypatch.setattr(bot, "Bot", lambda *a, **k: SimpleNamespace())
    monkeypatch.setattr(bot, "IPv4AiohttpSession", lambda *a, **k: SimpleNamespace())
    monkeypatch.setattr(bot.genai, "Client", lambda *a, **k: SimpleNamespace())

    async def _never():
        await asyncio.sleep(3600)

    monkeypatch.setattr(bot, "_webhook_startup", _never)
    monkeypatch.setattr(bot, "_flush_dirty_state", _never)
    monkeypatch.setattr(bot, "_drain_inflight_tasks", AsyncMock())
    monkeypatch.setattr(bot, "_close_sessions", AsyncMock())
    monkeypatch.setattr(bot.uvicorn, "Config", lambda *a, **k: SimpleNamespace())

    class _DeadServer:
        should_exit = False

        async def serve(self):
            raise RuntimeError("port busy")

    monkeypatch.setattr(bot.uvicorn, "Server", lambda cfg: _DeadServer())
    orig_bot, orig_client = bot.bot, bot.client
    orig_dirty, orig_deletions = set(bot._dirty_chat_ids), set(bot._pending_chat_deletions)
    orig_index_dirty, orig_quota_dirty = lumen_chat_state._index_dirty, lumen_chat_state._quota_dirty
    bot._dirty_chat_ids.clear()
    bot._pending_chat_deletions.clear()
    lumen_chat_state._index_dirty = False
    lumen_chat_state._quota_dirty = False
    try:
        with caplog.at_level(logging.ERROR, logger="bot"), pytest.raises(SystemExit) as exc_info:
            asyncio.run(bot.main())
        assert exc_info.value.code == 1
        assert "[serve] HTTP server failed" in caplog.text
    finally:
        bot.bot, bot.client = orig_bot, orig_client
        bot._dirty_chat_ids.clear()
        bot._dirty_chat_ids.update(orig_dirty)
        bot._pending_chat_deletions.clear()
        bot._pending_chat_deletions.update(orig_deletions)
        lumen_chat_state._index_dirty = orig_index_dirty
        lumen_chat_state._quota_dirty = orig_quota_dirty


def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
    # Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
    class _FakeResp:
        status = 200

        async def __aenter__(self):
            return self

        async def __aexit__(self, *args):
            return False

    class _FakeSession:
        def get(self, url, **kwargs):
            return _FakeResp()

    class _BoomSession:
        def get(self, url, **kwargs):
            raise RuntimeError("net down, token abc123")

    ok = asyncio.run(bot.probe_url(_FakeSession(), "http://x"))
    assert ok["ok"] is True and ok["status"] == 200 and ok["elapsed_sec"] >= 0
    err = asyncio.run(bot.probe_url(_BoomSession(), "http://x", redact="abc123"))
    assert err["ok"] is False
    assert "abc123" not in err["error"] and "<TOKEN>" in err["error"]