hermes-agent/tests/gateway/test_feishu_approval_buttons.py
Roy-oss1 1aa76620d4 fix(feishu): keep approval clicks synchronized with callback card state
Feishu approval clicks need the resolved card to come back from the
synchronous callback path itself. Leaving approval resolution to the
generic asynchronous card-action flow made button feedback depend on
later loop work instead of the callback response the client is waiting
for.

Change-Id: I574997cbbcaa097fdba759b47367e28d1b56b040
Constraint: Feishu card-action callbacks must acknowledge quickly and reflect final approval state from the callback response path
Rejected: Keep approval handling on the generic async card-action route | leaves card state synchronization vulnerable to callback timing and follow-up update ordering
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep approval callback response construction separate from async queue unblocking unless Feishu callback semantics change
Tested: pytest tests/gateway/test_feishu.py tests/gateway/test_feishu_approval_buttons.py tests/gateway/test_approve_deny_commands.py tests/gateway/test_slack_approval_buttons.py tests/gateway/test_telegram_approval_buttons.py -q
Not-tested: Live Feishu workspace end-to-end callback rendering
2026-04-14 14:22:11 -07:00

444 lines
16 KiB
Python

"""Tests for Feishu interactive card approval buttons."""
import importlib.util
import json
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 Feishu mock so FeishuAdapter can be imported without lark-oapi
# ---------------------------------------------------------------------------
def _ensure_feishu_mocks():
"""Provide stubs for lark-oapi / aiohttp.web so the import succeeds."""
if importlib.util.find_spec("lark_oapi") is None and "lark_oapi" not in sys.modules:
mod = MagicMock()
for name in (
"lark_oapi", "lark_oapi.api.im.v1",
"lark_oapi.event", "lark_oapi.event.callback_type",
):
sys.modules.setdefault(name, mod)
if importlib.util.find_spec("aiohttp") is None and "aiohttp" not in sys.modules:
aio = MagicMock()
sys.modules.setdefault("aiohttp", aio)
sys.modules.setdefault("aiohttp.web", aio.web)
_ensure_feishu_mocks()
from gateway.config import PlatformConfig
import gateway.platforms.feishu as feishu_module
from gateway.platforms.feishu import FeishuAdapter
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter() -> FeishuAdapter:
"""Create a FeishuAdapter with mocked internals."""
config = PlatformConfig(enabled=True)
adapter = FeishuAdapter(config)
adapter._client = MagicMock()
return adapter
def _make_card_action_data(
action_value: dict,
chat_id: str = "oc_12345",
open_id: str = "ou_user1",
token: str = "tok_abc",
) -> SimpleNamespace:
"""Create a mock Feishu card action callback data object."""
return SimpleNamespace(
event=SimpleNamespace(
token=token,
context=SimpleNamespace(open_chat_id=chat_id),
operator=SimpleNamespace(open_id=open_id),
action=SimpleNamespace(
tag="button",
value=action_value,
),
),
)
def _close_submitted_coro(coro, _loop):
"""Close scheduled coroutines in sync-handler tests to avoid unawaited warnings."""
coro.close()
return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)
# ===========================================================================
# send_exec_approval — interactive card with buttons
# ===========================================================================
class TestFeishuExecApproval:
"""Test send_exec_approval sends an interactive card."""
@pytest.mark.asyncio
async def test_sends_interactive_card(self):
adapter = _make_adapter()
mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_001"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
) as mock_send:
result = await adapter.send_exec_approval(
chat_id="oc_12345",
command="rm -rf /important",
session_key="agent:main:feishu:group:oc_12345",
description="dangerous deletion",
)
assert result.success is True
assert result.message_id == "msg_001"
mock_send.assert_called_once()
kwargs = mock_send.call_args[1]
assert kwargs["chat_id"] == "oc_12345"
assert kwargs["msg_type"] == "interactive"
# Verify card payload contains the command and buttons
card = json.loads(kwargs["payload"])
assert card["header"]["template"] == "orange"
assert "rm -rf /important" in card["elements"][0]["content"]
assert "dangerous deletion" in card["elements"][0]["content"]
# Check buttons
actions = card["elements"][1]["actions"]
assert len(actions) == 4
action_names = [a["value"]["hermes_action"] for a in actions]
assert action_names == [
"approve_once", "approve_session", "approve_always", "deny"
]
@pytest.mark.asyncio
async def test_stores_approval_state(self):
adapter = _make_adapter()
mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_002"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
):
await adapter.send_exec_approval(
chat_id="oc_12345",
command="echo test",
session_key="my-session-key",
)
assert len(adapter._approval_state) == 1
approval_id = list(adapter._approval_state.keys())[0]
state = adapter._approval_state[approval_id]
assert state["session_key"] == "my-session-key"
assert state["message_id"] == "msg_002"
assert state["chat_id"] == "oc_12345"
@pytest.mark.asyncio
async def test_not_connected(self):
adapter = _make_adapter()
adapter._client = None
result = await adapter.send_exec_approval(
chat_id="oc_12345", command="ls", session_key="s"
)
assert result.success is False
@pytest.mark.asyncio
async def test_truncates_long_command(self):
adapter = _make_adapter()
mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_003"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
) as mock_send:
long_cmd = "x" * 5000
await adapter.send_exec_approval(
chat_id="oc_12345", command=long_cmd, session_key="s"
)
card = json.loads(mock_send.call_args[1]["payload"])
content = card["elements"][0]["content"]
assert "..." in content
assert len(content) < 5000
@pytest.mark.asyncio
async def test_multiple_approvals_get_unique_ids(self):
adapter = _make_adapter()
mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_x"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
):
await adapter.send_exec_approval(
chat_id="oc_1", command="cmd1", session_key="s1"
)
await adapter.send_exec_approval(
chat_id="oc_2", command="cmd2", session_key="s2"
)
assert len(adapter._approval_state) == 2
ids = list(adapter._approval_state.keys())
assert ids[0] != ids[1]
# ===========================================================================
# _resolve_approval — approval state pop + gateway resolution
# ===========================================================================
class TestResolveApproval:
"""Test _resolve_approval pops state and calls resolve_gateway_approval."""
@pytest.mark.asyncio
async def test_resolves_once(self):
adapter = _make_adapter()
adapter._approval_state[1] = {
"session_key": "agent:main:feishu:group:oc_12345",
"message_id": "msg_001",
"chat_id": "oc_12345",
}
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(1, "once", "Norbert")
mock_resolve.assert_called_once_with("agent:main:feishu:group:oc_12345", "once")
assert 1 not in adapter._approval_state
@pytest.mark.asyncio
async def test_resolves_deny(self):
adapter = _make_adapter()
adapter._approval_state[2] = {
"session_key": "some-session",
"message_id": "msg_002",
"chat_id": "oc_12345",
}
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(2, "deny", "Alice")
mock_resolve.assert_called_once_with("some-session", "deny")
@pytest.mark.asyncio
async def test_resolves_session(self):
adapter = _make_adapter()
adapter._approval_state[3] = {
"session_key": "sess-3",
"message_id": "msg_003",
"chat_id": "oc_99",
}
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(3, "session", "Bob")
mock_resolve.assert_called_once_with("sess-3", "session")
@pytest.mark.asyncio
async def test_resolves_always(self):
adapter = _make_adapter()
adapter._approval_state[4] = {
"session_key": "sess-4",
"message_id": "msg_004",
"chat_id": "oc_55",
}
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(4, "always", "Carol")
mock_resolve.assert_called_once_with("sess-4", "always")
@pytest.mark.asyncio
async def test_already_resolved_drops_silently(self):
adapter = _make_adapter()
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
await adapter._resolve_approval(99, "once", "Nobody")
mock_resolve.assert_not_called()
# ===========================================================================
# _handle_card_action_event — non-approval card actions
# ===========================================================================
class TestNonApprovalCardAction:
"""Non-approval card actions should still route as synthetic commands."""
@pytest.mark.asyncio
async def test_routes_as_synthetic_command(self):
adapter = _make_adapter()
data = _make_card_action_data(
action_value={"custom_action": "something_else"},
token="tok_normal",
)
with (
patch.object(
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
return_value={"user_id": "ou_u", "user_name": "Dave", "user_id_alt": None},
),
patch.object(adapter, "get_chat_info", new_callable=AsyncMock, return_value={"name": "Test Chat"}),
patch.object(adapter, "_handle_message_with_guards", new_callable=AsyncMock) as mock_handle,
):
await adapter._handle_card_action_event(data)
mock_handle.assert_called_once()
event = mock_handle.call_args[0][0]
assert "/card button" in event.text
# ===========================================================================
# _on_card_action_trigger — inline card response for approval actions
# ===========================================================================
class _FakeCallBackCard:
def __init__(self):
self.type = None
self.data = None
class _FakeP2Response:
def __init__(self):
self.card = None
@pytest.fixture(autouse=False)
def _patch_callback_card_types(monkeypatch):
"""Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests."""
monkeypatch.setattr(feishu_module, "P2CardActionTriggerResponse", _FakeP2Response)
monkeypatch.setattr(feishu_module, "CallBackCard", _FakeCallBackCard)
class TestCardActionCallbackResponse:
"""Test that _on_card_action_trigger returns updated card inline."""
def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = None
data = _make_card_action_data({"hermes_action": "approve_once", "approval_id": 1})
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
response = adapter._on_card_action_trigger(data)
assert response is not None
assert response.card is None
mock_submit.assert_not_called()
def test_returns_card_for_approve_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_once", "approval_id": 1},
open_id="ou_bob",
)
adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
assert response is not None
assert response.card is not None
assert response.card.type == "raw"
card = response.card.data
assert card["header"]["template"] == "green"
assert "Approved once" in card["header"]["title"]["content"]
assert "Bob" in card["elements"][0]["content"]
def test_returns_card_for_deny_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "deny", "approval_id": 2},
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
assert response.card is not None
card = response.card.data
assert card["header"]["template"] == "red"
assert "Denied" in card["header"]["title"]["content"]
def test_ignores_missing_approval_id(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data({"hermes_action": "approve_once"})
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
response = adapter._on_card_action_trigger(data)
assert response is not None
assert response.card is None
mock_submit.assert_not_called()
def test_no_card_for_non_approval_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data({"some_other": "value"})
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
assert response is not None
assert response.card is None
def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_session", "approval_id": 3},
open_id="ou_unknown",
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
card = response.card.data
assert "ou_unknown" in card["elements"][0]["content"]
def test_ignores_expired_cached_name(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_once", "approval_id": 4},
open_id="ou_expired",
)
adapter._sender_name_cache["ou_expired"] = ("Old Name", 1)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
card = response.card.data
assert "Old Name" not in card["elements"][0]["content"]
assert "ou_expired" in card["elements"][0]["content"]