kin: BATON-FIX-005 Ротировать Telegram bot token — утечка в journalctl логах

This commit is contained in:
Gros Frumos 2026-03-21 09:27:37 +02:00
parent 33844a02ac
commit c838a775f7

172
tests/test_fix_005.py Normal file
View file

@ -0,0 +1,172 @@
"""
Tests for BATON-FIX-005: BOT_TOKEN leak prevention in logs.
Acceptance criteria covered by unit tests:
AC#4 — no places in source code where token is logged in plain text:
- _mask_token() returns masked representation (***XXXX format)
- validate_bot_token() exception handler does not log raw BOT_TOKEN
- validate_bot_token() exception handler logs type(exc).__name__ + masked token
- httpcore logger level >= WARNING (prevents URL leak via transport layer)
AC#1, AC#2, AC#3 (journalctl, webhook, service health) require live production
verification and are outside unit test scope.
"""
from __future__ import annotations
import logging
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import httpx
import pytest
import respx
from backend import config
from backend.telegram import _mask_token, validate_bot_token
GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
# ---------------------------------------------------------------------------
# _mask_token helper
# ---------------------------------------------------------------------------
def test_mask_token_shows_last_4_chars():
"""_mask_token returns '***XXXX' where XXXX is the last 4 chars of the token."""
token = "123456789:ABCDEFsomeLongTokenXYZW"
result = _mask_token(token)
assert result == f"***{token[-4:]}", f"Expected ***{token[-4:]}, got {result!r}"
def test_mask_token_hides_most_of_token():
"""_mask_token must NOT expose the full token — only last 4 chars."""
token = "123456789:ABCDEFsomeLongTokenXYZW"
result = _mask_token(token)
assert token[:-4] not in result, f"Masked token exposes too much: {result!r}"
def test_mask_token_short_token_returns_redacted():
"""_mask_token returns '***REDACTED***' for tokens shorter than 4 chars."""
assert _mask_token("abc") == "***REDACTED***"
def test_mask_token_empty_string_returns_redacted():
"""_mask_token on empty string returns '***REDACTED***'."""
assert _mask_token("") == "***REDACTED***"
def test_mask_token_exactly_4_chars_is_not_redacted():
"""_mask_token with exactly 4 chars returns '***XXXX' (not redacted)."""
result = _mask_token("1234")
assert result == "***1234", f"Expected ***1234, got {result!r}"
# ---------------------------------------------------------------------------
# httpcore logger suppression (new in FIX-005; httpx covered in test_fix_011)
# ---------------------------------------------------------------------------
def test_httpcore_logger_level_is_warning_or_higher():
"""logging.getLogger('httpcore').level must be WARNING or higher after app import."""
import backend.main # noqa: F401 — ensures telegram.py module-level setLevel is called
httpcore_logger = logging.getLogger("httpcore")
assert httpcore_logger.level >= logging.WARNING, (
f"httpcore logger level must be >= WARNING (30), got {httpcore_logger.level}. "
"httpcore logs transport-level requests including URLs with BOT_TOKEN."
)
def test_httpcore_logger_info_not_enabled():
"""httpcore logger must not propagate INFO-level messages (would leak BOT_TOKEN URL)."""
import backend.main # noqa: F401
httpcore_logger = logging.getLogger("httpcore")
assert not httpcore_logger.isEnabledFor(logging.INFO), (
"httpcore logger must not process INFO messages — could leak BOT_TOKEN via URL"
)
# ---------------------------------------------------------------------------
# validate_bot_token() exception handler — AC#4: no raw token in error logs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validate_bot_token_network_error_does_not_log_raw_token(caplog):
"""validate_bot_token() on ConnectError must NOT log the raw BOT_TOKEN.
AC#4: The exception handler logs type(exc).__name__ + _mask_token() instead
of raw exc, which embeds the Telegram API URL containing the token.
"""
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
result = await validate_bot_token()
assert result is False
raw_token = config.BOT_TOKEN
for record in caplog.records:
assert raw_token not in record.message, (
f"AC#4: Raw BOT_TOKEN leaked in log message: {record.message!r}"
)
@pytest.mark.asyncio
async def test_validate_bot_token_network_error_logs_exception_type_name(caplog):
"""validate_bot_token() on ConnectError logs the exception type name, not repr(exc).
The fixed handler: logger.error('...%s...', type(exc).__name__, ...) not str(exc).
"""
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
await validate_bot_token()
error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
assert error_messages, "Expected at least one ERROR log on network failure"
assert any("ConnectError" in msg for msg in error_messages), (
f"Expected 'ConnectError' (type name) in error log, got: {error_messages}"
)
@pytest.mark.asyncio
async def test_validate_bot_token_network_error_logs_masked_token(caplog):
"""validate_bot_token() on network error logs masked token (***XXXX), not raw token."""
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
await validate_bot_token()
token = config.BOT_TOKEN # "test-bot-token"
masked = f"***{token[-4:]}" # "***oken"
error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
assert any(masked in msg for msg in error_messages), (
f"Expected masked token '{masked}' in error log. Got: {error_messages}"
)
@pytest.mark.asyncio
async def test_validate_bot_token_network_error_no_api_url_in_logs(caplog):
"""validate_bot_token() on network error must not log the Telegram API URL.
httpx embeds the request URL (including the token) into exception repr/str.
The fixed handler avoids logging exc directly to prevent this leak.
"""
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
await validate_bot_token()
for record in caplog.records:
assert "api.telegram.org" not in record.message, (
f"AC#4: Telegram API URL (containing token) leaked in log: {record.message!r}"
)