Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
"""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 plugins.platforms.feishu.adapter as feishu_module
|
||||
from plugins.platforms.feishu.adapter 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"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# send_update_prompt — interactive card with buttons
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeishuUpdatePrompt:
|
||||
"""Test send_update_prompt 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_up_001"),
|
||||
)
|
||||
with patch.object(
|
||||
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_send:
|
||||
result = await adapter.send_update_prompt(
|
||||
chat_id="oc_12345",
|
||||
prompt="Restore stashed changes after update?",
|
||||
default="y",
|
||||
session_key="agent:main:feishu:group:oc_12345",
|
||||
metadata={"thread_id": "th_1"},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg_up_001"
|
||||
|
||||
kwargs = mock_send.call_args[1]
|
||||
assert kwargs["chat_id"] == "oc_12345"
|
||||
assert kwargs["msg_type"] == "interactive"
|
||||
assert kwargs["metadata"] == {"thread_id": "th_1"}
|
||||
|
||||
card = json.loads(kwargs["payload"])
|
||||
assert card["header"]["template"] == "orange"
|
||||
assert "Restore stashed changes after update?" in card["elements"][0]["content"]
|
||||
assert "Default: `y`" in card["elements"][0]["content"]
|
||||
actions = card["elements"][1]["actions"]
|
||||
assert [a["value"]["hermes_update_prompt_action"] for a in actions] == ["y", "n"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _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", open_id="ou_user1", chat_id="oc_12345")
|
||||
|
||||
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_unauthorized_click_does_not_resolve(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._admins = {"ou_admin"}
|
||||
adapter._approval_state[5] = {
|
||||
"session_key": "sess-5",
|
||||
"message_id": "msg_005",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._resolve_approval(5, "once", "Mallory", open_id="ou_intruder", chat_id="oc_12345")
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
assert 5 in adapter._approval_state
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _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)
|
||||
adapter._allowed_group_users = {"ou_bob"}
|
||||
adapter._approval_state[1] = {
|
||||
"session_key": "sess-1",
|
||||
"message_id": "msg-1",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
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_ignores_expired_cached_name(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_expired"}
|
||||
adapter._approval_state[4] = {
|
||||
"session_key": "sess-4",
|
||||
"message_id": "msg-4",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
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"]
|
||||
|
||||
def test_rejects_approval_click_from_unauthorized_user(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
adapter._approval_state[5] = {
|
||||
"session_key": "sess-5",
|
||||
"message_id": "msg-5",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 5},
|
||||
open_id="ou_attacker",
|
||||
)
|
||||
|
||||
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_rejects_approval_click_when_group_policy_open(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
adapter._group_policy = "open"
|
||||
adapter._default_group_policy = "open"
|
||||
adapter._approval_state[6] = {
|
||||
"session_key": "sess-6",
|
||||
"message_id": "msg-6",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 6},
|
||||
open_id="ou_attacker",
|
||||
)
|
||||
|
||||
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_update_prompt_unauthorized_operator_returns_no_card(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._update_prompt_state[1] = {
|
||||
"session_key": "sess-up-1",
|
||||
"message_id": "msg_up_006",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_update_prompt_action": "y", "update_prompt_id": 1},
|
||||
open_id="ou_intruder",
|
||||
)
|
||||
|
||||
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_update_prompt_unauthorized_click_rejected_when_group_policy_open(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
adapter._group_policy = "open"
|
||||
adapter._default_group_policy = "open"
|
||||
adapter._update_prompt_state[7] = {
|
||||
"session_key": "sess-up-7",
|
||||
"message_id": "msg_up_007",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_update_prompt_action": "y", "update_prompt_id": 7},
|
||||
open_id="ou_intruder",
|
||||
)
|
||||
|
||||
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_update_prompt_chat_mismatch_returns_no_card(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_bob"}
|
||||
adapter._update_prompt_state[8] = {
|
||||
"session_key": "sess-up-8",
|
||||
"message_id": "msg_up_008",
|
||||
"chat_id": "oc_expected",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_update_prompt_action": "y", "update_prompt_id": 8},
|
||||
chat_id="oc_mismatch",
|
||||
open_id="ou_bob",
|
||||
)
|
||||
|
||||
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
|
||||
assert 8 in adapter._update_prompt_state
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
# Scenarios below are adapted from @liuliu0223's regression suite in
|
||||
# #99021: DM paired-mode (empty allowlist) positive paths, fail-closed
|
||||
# rejection of missing operator identity, and forwarded-card rejection.
|
||||
|
||||
def test_paired_mode_participant_can_approve(self, _patch_callback_card_types):
|
||||
"""Empty allowlist (DM paired mode): the card recipient can still approve."""
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._admins = set()
|
||||
adapter._allowed_group_users = set()
|
||||
adapter._approval_state[15] = {
|
||||
"session_key": "sess-15",
|
||||
"message_id": "msg-15",
|
||||
"chat_id": "oc_dm_chat",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 15},
|
||||
chat_id="oc_dm_chat",
|
||||
open_id="ou_dm_user",
|
||||
)
|
||||
adapter._sender_name_cache["ou_dm_user"] = ("DM User", 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 "Approved once" in response.card.data["header"]["title"]["content"]
|
||||
|
||||
def test_paired_mode_participant_can_confirm_update_prompt(self, _patch_callback_card_types):
|
||||
"""Empty allowlist (DM paired mode): the prompt recipient can still confirm."""
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._admins = set()
|
||||
adapter._allowed_group_users = set()
|
||||
adapter._update_prompt_state[23] = {
|
||||
"session_key": "sess-up-23",
|
||||
"message_id": "msg_up_023",
|
||||
"chat_id": "oc_dm_chat",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_update_prompt_action": "y", "update_prompt_id": 23},
|
||||
chat_id="oc_dm_chat",
|
||||
open_id="ou_dm_user",
|
||||
)
|
||||
adapter._sender_name_cache["ou_dm_user"] = ("DM User", 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
|
||||
|
||||
def test_empty_open_id_rejected_on_approval(self, _patch_callback_card_types):
|
||||
"""Approval click without an operator identity is rejected (fail-closed)."""
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._admins = set()
|
||||
adapter._allowed_group_users = set()
|
||||
adapter._approval_state[24] = {
|
||||
"session_key": "sess-24",
|
||||
"message_id": "msg-24",
|
||||
"chat_id": "oc_dm_chat",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 24},
|
||||
chat_id="oc_dm_chat",
|
||||
open_id="",
|
||||
)
|
||||
|
||||
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()
|
||||
assert 24 in adapter._approval_state
|
||||
|
||||
def test_empty_open_id_rejected_on_update_prompt(self, _patch_callback_card_types):
|
||||
"""Update prompt click without an operator identity is rejected (fail-closed)."""
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._admins = set()
|
||||
adapter._allowed_group_users = set()
|
||||
adapter._update_prompt_state[25] = {
|
||||
"session_key": "sess-up-25",
|
||||
"message_id": "msg_up_025",
|
||||
"chat_id": "oc_dm_chat",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_update_prompt_action": "y", "update_prompt_id": 25},
|
||||
chat_id="oc_dm_chat",
|
||||
open_id="",
|
||||
)
|
||||
|
||||
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()
|
||||
assert 25 in adapter._update_prompt_state
|
||||
|
||||
def test_approval_card_forwarded_to_different_chat_rejected(self, _patch_callback_card_types):
|
||||
"""Approval card forwarded out of its DM: chat mismatch rejects the click."""
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._admins = set()
|
||||
adapter._allowed_group_users = set()
|
||||
adapter._approval_state[26] = {
|
||||
"session_key": "sess-26",
|
||||
"message_id": "msg-26",
|
||||
"chat_id": "oc_dm_chat",
|
||||
}
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 26},
|
||||
chat_id="oc_forwarded_group",
|
||||
open_id="ou_dm_user",
|
||||
)
|
||||
|
||||
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()
|
||||
assert 26 in adapter._approval_state
|
||||
|
||||
|
||||
class TestResolveUpdatePrompt:
|
||||
"""Test update prompt resolution persists the response file."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_response_file(self, tmp_path, monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir()
|
||||
adapter._update_prompt_state[1] = {
|
||||
"session_key": "sess-up-1",
|
||||
"message_id": "msg_up_003",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
await adapter._resolve_update_prompt(1, "y", "Alice", open_id="ou_user1", chat_id="oc_12345")
|
||||
|
||||
assert (tmp_path / ".hermes" / ".update_response").read_text() == "y"
|
||||
assert 1 not in adapter._update_prompt_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_operator_does_not_write_response(self, tmp_path, monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir()
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
adapter._group_policy = "open"
|
||||
adapter._default_group_policy = "open"
|
||||
adapter._update_prompt_state[2] = {
|
||||
"session_key": "sess-up-2",
|
||||
"message_id": "msg_up_004",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
await adapter._resolve_update_prompt(2, "y", "Mallory", open_id="ou_intruder", chat_id="oc_12345")
|
||||
|
||||
assert not (tmp_path / ".hermes" / ".update_response").exists()
|
||||
assert 2 in adapter._update_prompt_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_operator_identity_does_not_write_response(self, tmp_path, monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir()
|
||||
adapter._allowed_group_users = {"ou_allowed"}
|
||||
adapter._update_prompt_state[3] = {
|
||||
"session_key": "sess-up-3",
|
||||
"message_id": "msg_up_005",
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
await adapter._resolve_update_prompt(3, "y", "Anonymous", open_id="", chat_id="oc_12345")
|
||||
|
||||
assert not (tmp_path / ".hermes" / ".update_response").exists()
|
||||
assert 3 in adapter._update_prompt_state
|
||||
|
||||
|
||||
Reference in New Issue
Block a user