hermes-agent/tests/gateway/test_telegram_approval_buttons.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

362 lines
13 KiB
Python

"""Tests for Telegram inline keyboard approval buttons."""
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Ensure the repo root is importable
# ---------------------------------------------------------------------------
_repo = str(Path(__file__).resolve().parents[2])
if _repo not in sys.path:
sys.path.insert(0, _repo)
# ---------------------------------------------------------------------------
# Minimal Telegram mock so TelegramAdapter can be imported
# ---------------------------------------------------------------------------
def _ensure_telegram_mock():
"""Wire up the minimal mocks required to import TelegramAdapter."""
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
mod.constants.ParseMode.MARKDOWN = "Markdown"
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
mod.constants.ParseMode.HTML = "HTML"
mod.constants.ChatType.PRIVATE = "private"
mod.constants.ChatType.GROUP = "group"
mod.constants.ChatType.SUPERGROUP = "supergroup"
mod.constants.ChatType.CHANNEL = "channel"
# Provide real exception classes so ``except (NetworkError, ...)`` in
# connect() doesn't blow up under xdist when this mock leaks.
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from plugins.platforms.telegram.adapter import TelegramAdapter
from gateway.config import Platform, PlatformConfig
def _make_adapter(extra=None):
"""Create a TelegramAdapter with mocked internals."""
config = PlatformConfig(enabled=True, token="test-token", extra=extra or {})
adapter = TelegramAdapter(config)
adapter._bot = AsyncMock()
adapter._app = MagicMock()
return adapter
class _AuthRunner:
"""Minimal runner shim for callback auth tests."""
def __init__(self, authorized: bool):
self.authorized = authorized
self.last_source = None
async def _handle_message(self, event):
return None
def _is_user_authorized(self, source):
self.last_source = source
return self.authorized
# ===========================================================================
# send_exec_approval — inline keyboard buttons
# ===========================================================================
class TestTelegramExecApproval:
"""Test the send_exec_approval method sends InlineKeyboard buttons."""
@pytest.mark.asyncio
async def test_sends_inline_keyboard(self):
adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 42
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
result = await adapter.send_exec_approval(
chat_id="12345",
command="rm -rf /important",
session_key="agent:main:telegram:group:12345:99",
description="dangerous deletion",
)
assert result.success is True
assert result.message_id == "42"
adapter._bot.send_message.assert_called_once()
kwargs = adapter._bot.send_message.call_args[1]
assert kwargs["chat_id"] == 12345
assert "rm -rf /important" in kwargs["text"]
assert "dangerous deletion" in kwargs["text"]
assert kwargs["reply_markup"] is not None # InlineKeyboardMarkup
@pytest.mark.asyncio
async def test_non_smart_allow_permanent_false_keeps_session(self, monkeypatch):
adapter = _make_adapter()
adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
buttons = []
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardButton",
lambda text, callback_data: buttons.append(text) or text,
)
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardMarkup", lambda rows: rows
)
await adapter.send_exec_approval(
chat_id="12345", command="curl example.test", session_key="s",
allow_permanent=False,
)
assert buttons == ["✅ Allow Once", "✅ Session", "❌ Deny"]
@pytest.mark.asyncio
async def test_full_approval_keyboard_is_two_by_two(self, monkeypatch):
"""Regression: d48bf743f flattened all buttons into one row (4x1)."""
adapter = _make_adapter()
adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
captured_rows = []
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardButton",
lambda text, callback_data: text,
)
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardMarkup",
lambda rows: captured_rows.extend(rows) or rows,
)
await adapter.send_exec_approval(
chat_id="12345", command="curl example.test", session_key="s",
)
assert captured_rows == [
["✅ Allow Once", "✅ Session"],
["✅ Always", "❌ Deny"],
]
@pytest.mark.asyncio
async def test_smart_deny_two_buttons_share_one_row(self, monkeypatch):
"""smart_deny yields 2 buttons — they pair into a single readable row."""
adapter = _make_adapter()
adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
captured_rows = []
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardButton",
lambda text, callback_data: text,
)
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.InlineKeyboardMarkup",
lambda rows: captured_rows.extend(rows) or rows,
)
await adapter.send_exec_approval(
chat_id="12345", command="curl example.test", session_key="s",
allow_permanent=False, smart_denied=True,
)
assert captured_rows == [
["✅ Allow Once", "❌ Deny"],
]
@pytest.mark.asyncio
async def test_send_update_prompt_escapes_dynamic_prompt(self):
adapter = _make_adapter()
sent = {}
async def mock_send_message(**kwargs):
sent.update(kwargs)
return SimpleNamespace(message_id=55)
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
result = await adapter.send_update_prompt(
chat_id="12345",
prompt="Fix [issue]_1 and verify *markdown*",
default="alpha_beta",
metadata={"thread_id": "999"},
)
assert result.success is True
assert "MARKDOWN_V2" in repr(sent["parse_mode"])
assert "Fix \\[issue\\]\\_1" in sent["text"]
assert "alpha\\_beta" in sent["text"]
# _handle_callback_query — approval button clicks
# ===========================================================================
class TestTelegramApprovalCallback:
"""Test the approval callback handling in _handle_callback_query."""
@pytest.mark.asyncio
async def test_resume_typing_after_inline_approval(self):
"""Clicking an inline approval button must un-pause the chat's typing.
Regression for #27853: the text /approve path resumed typing, but the
ea: callback path did not, so the typing indicator stayed gone for the
rest of a long-running turn after a button click.
"""
adapter = _make_adapter()
adapter._approval_state[5] = "agent:main:telegram:group:12345:99"
adapter.pause_typing_for_chat("12345")
assert "12345" in adapter._typing_paused
query = AsyncMock()
query.data = "ea:once:5"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.first_name = "Norbert"
query.from_user.id = "12345"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
with patch("tools.approval.resolve_gateway_approval", return_value=1):
await adapter._handle_callback_query(update, context)
assert "12345" not in adapter._typing_paused
@pytest.mark.asyncio
async def test_approval_callback_escapes_dynamic_user_name(self):
adapter = _make_adapter()
adapter._approval_state[3] = "agent:main:telegram:group:12345:99"
query = AsyncMock()
query.data = "ea:once:3"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.first_name = "Alice_Bob"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
query.from_user.id = "12345"
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
with patch("tools.approval.resolve_gateway_approval", return_value=1):
await adapter._handle_callback_query(update, context)
edit_kwargs = query.edit_message_text.call_args[1]
assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"])
assert "Alice\\_Bob" in edit_kwargs["text"]
assert "Approved once" in edit_kwargs["text"]
@pytest.mark.asyncio
async def test_update_prompt_callback_not_affected(self, tmp_path):
"""Ensure update prompt callbacks still work."""
adapter = _make_adapter()
query = AsyncMock()
query.data = "update_prompt:y"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.id = 123
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
with patch("hermes_constants.get_hermes_home", return_value=tmp_path):
# Allow the caller — the new fail-closed allowlist gate
# (#24457) rejects empty TELEGRAM_ALLOWED_USERS, but this
# test isn't exercising that gate; it's verifying the
# update_prompt callback still writes the response.
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}):
await adapter._handle_callback_query(update, context)
# Should NOT have triggered approval resolution
mock_resolve.assert_not_called()
assert (tmp_path / ".update_response").read_text() == "y"
@pytest.mark.asyncio
async def test_update_prompt_callback_rejects_unauthorized_user(self, tmp_path):
"""Update prompt buttons should honor TELEGRAM_ALLOWED_USERS."""
adapter = _make_adapter()
query = AsyncMock()
query.data = "update_prompt:y"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.id = 222
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch("hermes_constants.get_hermes_home", return_value=tmp_path):
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "111"}):
await adapter._handle_callback_query(update, context)
query.answer.assert_called_once()
assert "not authorized" in query.answer.call_args[1]["text"].lower()
query.edit_message_text.assert_not_called()
assert not (tmp_path / ".update_response").exists()
@pytest.mark.asyncio
async def test_update_prompt_callback_rejects_user_blocked_by_global_allowlist(self, tmp_path):
adapter = _make_adapter()
runner = _AuthRunner(authorized=False)
adapter._message_handler = runner._handle_message
query = AsyncMock()
query.data = "update_prompt:y"
query.message = MagicMock()
query.message.chat_id = 12345
query.message.chat.type = "private"
query.from_user = MagicMock()
query.from_user.id = 222
query.from_user.first_name = "Mallory"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch("hermes_constants.get_hermes_home", return_value=tmp_path):
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": ""}):
await adapter._handle_callback_query(update, context)
query.answer.assert_called_once()
assert "not authorized" in query.answer.call_args[1]["text"].lower()
query.edit_message_text.assert_not_called()
assert not (tmp_path / ".update_response").exists()
assert runner.last_source is not None
assert runner.last_source.platform == Platform.TELEGRAM
assert runner.last_source.user_id == "222"