Files
EvoScientist-Multi/tests/test_feishu_channel.py
T
houren Antony da15b70535 fix(channels): reject unsigned webhook POSTs on encryption-configured channels (#401)
Closes #392 (uncontroversial part).

WeChat (`_handle_message`) and Feishu (`_handle_event`) gated their
signature/decryption checks behind a condition the REQUEST controls:

- WeChat: `if encrypt and self._crypto:` -- a POST with no `<Encrypt>`
  element took the false branch and reached `_safe_process_message`
  without any verification, even when `encoding_aes_key` + `token` were
  configured.
- Feishu: `if self.config.encrypt_key and "encrypt" in body:` -- a
  plaintext body skipped decryption entirely and was processed directly.

Since the webhook port is the channel's only inbound boundary, an
attacker could POST forged plaintext and reach the agent, spoofing
`sender_id` / `FromUserName` (and, with an empty allowlist, passing the
sender gate).

Fix: when encryption is configured, an inbound POST MUST carry the
encrypted field (`<Encrypt>` / `encrypt`) -- otherwise it is rejected
with 403 and never reaches the agent. Plaintext mode (no encryption
configured) is unchanged, so existing plaintext deployments are not
affected. The remaining fail-closed question (what to do when
credentials are entirely unset) is left for the maintainers to decide
as the policy part of the issue.

Regression tests (9 new):
- WeChat: plaintext rejected / missing Encrypt rejected / bad signature
  rejected / valid signature decrypts and processes / plaintext still
  accepted when no crypto.
- Feishu: plaintext rejected / non-dict body rejected / encrypted body
  decrypts and processes / plaintext still accepted when no encrypt_key.

93 tests in the two channel files pass; full suite 3045 passed, 13
skipped; ruff clean.

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-08-14 17:25:57 +01:00

719 lines
26 KiB
Python

"""Tests for Feishu channel implementation."""
import json
import sys
from typing import ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from EvoScientist.channels.base import ChannelError, OutboundMessage
from EvoScientist.channels.feishu.channel import (
FeishuChannel,
FeishuConfig,
_markdown_to_feishu_post,
_parse_inline_elements,
_parse_inline_text,
)
class TestFeishuConfig:
def test_default_values(self):
config = FeishuConfig()
assert config.app_id == ""
assert config.app_secret == ""
assert config.verification_token == ""
assert config.encrypt_key == ""
assert config.webhook_port == 9000
assert config.text_chunk_limit == 4096
assert config.feishu_domain == "https://open.feishu.cn"
assert config.allowed_senders is None
assert config.subscription_mode == "webhook"
def test_custom_values(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
verification_token="token123",
encrypt_key="key123",
webhook_port=8080,
allowed_senders={"user1"},
feishu_domain="https://open.larksuite.com",
)
assert config.app_id == "test-id"
assert config.app_secret == "test-secret"
assert config.verification_token == "token123"
assert config.encrypt_key == "key123"
assert config.webhook_port == 8080
assert config.allowed_senders == {"user1"}
assert config.feishu_domain == "https://open.larksuite.com"
class TestFeishuChannel:
def test_init(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
assert channel.config is config
assert channel._running is False
assert channel.name == "feishu"
async def test_start_raises_without_app_id(self):
config = FeishuConfig(app_id="", app_secret="test-secret")
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"):
await channel.start()
async def test_start_raises_without_app_secret(self):
config = FeishuConfig(app_id="test-id", app_secret="")
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_secret"):
await channel.start()
async def test_stop_when_not_running(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
await channel.stop()
async def test_send_returns_false_without_client(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
msg = OutboundMessage(
channel="feishu",
chat_id="oc_test",
content="hello",
metadata={"chat_id": "oc_test"},
)
result = await channel.send(msg)
assert result is False
def test_capabilities(self):
from EvoScientist.channels.capabilities import FEISHU
config = FeishuConfig()
channel = FeishuChannel(config)
assert channel.capabilities is FEISHU
assert channel.capabilities.format_type == "markdown"
assert channel.capabilities.groups is True
assert channel.capabilities.mentions is True
assert channel.capabilities.media_send is True
assert channel.capabilities.media_receive is True
assert channel.capabilities.reactions is True
assert channel.capabilities.voice is True
assert channel.capabilities.stickers is True
def test_extract_post_text(self):
content = {
"zh_cn": {
"title": "Test Title",
"content": [
[
{"tag": "text", "text": "Hello "},
{"tag": "a", "text": "world", "href": "http://example.com"},
],
[{"tag": "text", "text": "Second line"}],
],
}
}
result = FeishuChannel._extract_post_text(content)
assert "Test Title" in result
assert "Hello world" in result
assert "Second line" in result
def test_extract_post_text_empty(self):
result = FeishuChannel._extract_post_text({})
assert result == ""
def test_extract_post_text_skips_at_mentions(self):
content = {
"zh_cn": {
"content": [
[{"tag": "text", "text": "Hi "}, {"tag": "at", "user_id": "bot"}],
],
}
}
result = FeishuChannel._extract_post_text(content)
assert result == "Hi"
def test_strip_mention(self):
config = FeishuConfig()
channel = FeishuChannel(config)
channel._mention_names = ["@_user_1"]
result = channel._strip_mention("@_user_1 hello world")
assert result == "hello world"
def test_strip_mention_multiple(self):
config = FeishuConfig()
channel = FeishuChannel(config)
channel._mention_names = ["@_user_1", "@_user_2"]
result = channel._strip_mention("@_user_1 @_user_2 hello")
assert result == "hello"
def test_strip_mention_no_match(self):
config = FeishuConfig()
channel = FeishuChannel(config)
channel._mention_names = []
result = channel._strip_mention("hello world")
assert result == "hello world"
class TestFeishuErrorPatterns:
"""Test non-retryable and rate-limit pattern detection."""
def test_non_retryable_patterns_defined(self):
config = FeishuConfig()
channel = FeishuChannel(config)
assert len(channel._non_retryable_patterns) > 0
assert "10003" in channel._non_retryable_patterns
assert "99991401" in channel._non_retryable_patterns
def test_non_retryable_returns_none(self):
config = FeishuConfig()
channel = FeishuChannel(config)
exc = Exception("error code 10003: invalid app_id")
result = channel._extract_retry_after(exc)
assert result is None
def test_rate_limit_patterns_defined(self):
config = FeishuConfig()
channel = FeishuChannel(config)
assert "99991400" in channel._rate_limit_patterns
assert "频率限制" in channel._rate_limit_patterns
def test_rate_limit_returns_delay(self):
config = FeishuConfig()
channel = FeishuChannel(config)
exc = Exception("99991400 频率限制")
result = channel._extract_retry_after(exc)
assert result is not None
assert result == channel._rate_limit_delay
class TestFeishuWebhookEvent:
"""Test _on_message parsing with mocked bus."""
def _make_channel(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._running = True
channel._http_client = MagicMock()
channel._access_token = "fake-token"
channel._token_expires = 9999999999
channel._enqueue_raw = AsyncMock()
return channel
async def test_text_message_v2(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_test123"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat1",
"message_type": "text",
"message_id": "msg_1",
"chat_type": "p2p",
"content": json.dumps({"text": "hello feishu"}),
"create_time": "1700000000000",
},
}
await channel._on_message(event)
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello feishu"
assert raw.sender_id == "ou_test123"
assert raw.chat_id == "oc_chat1"
assert raw.is_group is False
async def test_group_message_with_mention(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_sender"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_group1",
"message_type": "text",
"message_id": "msg_2",
"chat_type": "group",
"content": json.dumps({"text": "@_user_1 do something"}),
"create_time": "1700000000000",
"mentions": [{"key": "@_user_1", "id": {}}],
},
}
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is True
assert channel._mention_names == ["@_user_1"]
async def test_group_message_no_mention(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_sender"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_group2",
"message_type": "text",
"message_id": "msg_3",
"chat_type": "group",
"content": json.dumps({"text": "just talking"}),
"create_time": "1700000000000",
},
}
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is False
async def test_skips_bot_messages(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_bot"},
"sender_type": "app",
},
"message": {
"chat_id": "oc_chat",
"message_type": "text",
"message_id": "msg_bot",
"content": json.dumps({"text": "bot reply"}),
},
}
await channel._on_message(event)
channel._enqueue_raw.assert_not_called()
async def test_post_message(self):
channel = self._make_channel()
post_content = {
"zh_cn": {
"title": "Test",
"content": [[{"tag": "text", "text": "Post body"}]],
}
}
event = {
"sender": {
"sender_id": {"open_id": "ou_test"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat",
"message_type": "post",
"message_id": "msg_post",
"chat_type": "p2p",
"content": json.dumps(post_content),
"create_time": "1700000000000",
},
}
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert "Test" in raw.text
assert "Post body" in raw.text
async def test_unsupported_msg_type_annotation(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_test"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat",
"message_type": "share_chat",
"message_id": "msg_share",
"chat_type": "p2p",
"content": "{}",
"create_time": "1700000000000",
},
}
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert "share_chat" in raw.text
class TestFeishuSendChunk:
"""Test _send_chunk with mocked HTTP client."""
async def test_send_chunk_post_format(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
channel._token_expires = 9999999999
mock_response = MagicMock()
mock_response.json.return_value = {"code": 0}
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
await channel._send_chunk("oc_chat1", "formatted", "raw **text**", None, {})
channel._http_client.post.assert_called()
# Should try post format first
call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json")
assert body["receive_id"] == "oc_chat1"
async def test_send_chunk_with_reply(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
channel._token_expires = 9999999999
mock_response = MagicMock()
mock_response.json.return_value = {"code": 0}
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
await channel._send_chunk("oc_chat1", "reply", "reply text", "om_reply_id", {})
# Should call the reply API endpoint
first_call_url = channel._http_client.post.call_args_list[0][0][0]
assert "reply" in first_call_url
class TestFeishuMarkdownConversion:
def test_empty_text(self):
assert _markdown_to_feishu_post("") is None
assert _markdown_to_feishu_post(" ") is None
def test_plain_text(self):
result = _markdown_to_feishu_post("Hello world")
assert result is not None
assert "zh_cn" in result
content = result["zh_cn"]["content"]
assert len(content) >= 1
def test_code_block(self):
md = "```python\nprint('hello')\n```"
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
found = False
for para in content:
for elem in para:
if elem.get("tag") == "code_block":
found = True
assert elem["language"] == "python"
assert "print" in elem["text"]
assert found
def test_code_block_no_language(self):
md = "```\nsome code\n```"
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
for para in content:
for elem in para:
if elem.get("tag") == "code_block":
assert elem["language"] == "plain"
def test_bold_text(self):
elements = _parse_inline_text("**bold text**")
assert any(
e.get("style") == ["bold"] and e["text"] == "bold text" for e in elements
)
def test_inline_code(self):
elements = _parse_inline_text("`code`")
assert any(
"code_block" in (e.get("style") or []) and e["text"] == "code"
for e in elements
)
def test_link(self):
elements = _parse_inline_text("[click](http://example.com)")
assert any(e.get("tag") == "a" and e["text"] == "click" for e in elements)
def test_strikethrough(self):
elements = _parse_inline_text("~~deleted~~")
assert any(
e.get("style") == ["strikethrough"] and e["text"] == "deleted"
for e in elements
)
def test_italic(self):
elements = _parse_inline_text("_italic text_")
assert any(
e.get("style") == ["italic"] and e["text"] == "italic text"
for e in elements
)
def test_heading(self):
elements = _parse_inline_elements("## My Heading")
assert any(
e.get("style") == ["bold"] and e["text"] == "My Heading" for e in elements
)
def test_blockquote(self):
elements = _parse_inline_elements("> quoted text")
# Should have ▎ prefix with italic style
assert any(e.get("text") == "▎" for e in elements)
assert any(e.get("text") == "quoted text" for e in elements)
def test_unordered_list(self):
elements = _parse_inline_elements("- list item")
assert any(e.get("text") == "• " for e in elements)
assert any(e.get("text") == "list item" for e in elements)
def test_ordered_list(self):
elements = _parse_inline_elements("3. third item")
assert any(e.get("text") == "3. " for e in elements)
assert any(e.get("text") == "third item" for e in elements)
def test_multi_paragraph(self):
md = "First paragraph\n\nSecond paragraph"
result = _markdown_to_feishu_post(md)
content = result["zh_cn"]["content"]
assert len(content) == 2
def test_mixed_content(self):
md = "# Title\n\nSome **bold** text\n\n```python\ncode\n```"
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
assert len(content) >= 3
class TestFeishuChannelRegistration:
def test_feishu_registered(self):
from EvoScientist.channels.channel_manager import available_channels
channels = available_channels()
assert "feishu" in channels
class TestFeishuProbe:
async def test_missing_app_id(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = await validate_feishu_credentials("", "secret")
assert ok is False
assert "app_id" in msg
async def test_missing_app_secret(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = await validate_feishu_credentials("id", "")
assert ok is False
assert "app_secret" in msg
class TestFeishuWebSocketMode:
"""Tests for WebSocket (长连接) subscription mode."""
def test_config_subscription_mode_websocket(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
subscription_mode="websocket",
)
assert config.subscription_mode == "websocket"
async def test_start_websocket_raises_without_lark_oapi(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
subscription_mode="websocket",
)
channel = FeishuChannel(config)
# Temporarily hide lark_oapi if it's installed
with patch.dict(sys.modules, {"lark_oapi": None}):
with pytest.raises(ChannelError, match="lark-oapi"):
await channel.start()
async def test_start_webhook_mode_still_works(self):
"""Ensure subscription_mode='webhook' still validates as before."""
config = FeishuConfig(
app_id="",
app_secret="test-secret",
subscription_mode="webhook",
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"):
await channel.start()
async def test_invalid_subscription_mode_raises(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
subscription_mode="websockeet",
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="Invalid feishu_subscription_mode"):
await channel.start()
async def test_on_lark_sdk_message_bridges_to_on_message(self):
"""Test that _on_lark_sdk_message enqueues event dict via queue."""
import queue as queue_mod
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._running = True
channel._http_client = MagicMock()
channel._access_token = "fake-token"
channel._token_expires = 9999999999
channel._enqueue_raw = AsyncMock()
channel._ws_event_queue = queue_mod.Queue()
# Build a mock SDK event object matching lark_oapi structure
mock_sender_id = MagicMock()
mock_sender_id.open_id = "ou_test_ws"
mock_sender_id.user_id = "user_ws"
mock_sender = MagicMock()
mock_sender.sender_id = mock_sender_id
mock_sender.sender_type = "user"
mock_msg = MagicMock()
mock_msg.chat_id = "oc_ws_chat"
mock_msg.message_type = "text"
mock_msg.message_id = "msg_ws_1"
mock_msg.chat_type = "p2p"
mock_msg.content = json.dumps({"text": "hello from websocket"})
mock_msg.create_time = "1700000000000"
mock_msg.mentions = None
mock_event = MagicMock()
mock_event.message = mock_msg
mock_event.sender = mock_sender
mock_data = MagicMock()
mock_data.event = mock_event
# Call the SDK callback (sync, puts on queue)
channel._on_lark_sdk_message(mock_data)
# Verify event was queued
assert not channel._ws_event_queue.empty()
event_dict = channel._ws_event_queue.get_nowait()
assert event_dict["sender"]["sender_id"]["open_id"] == "ou_test_ws"
assert event_dict["message"]["chat_id"] == "oc_ws_chat"
assert event_dict["message"]["content"] == json.dumps(
{"text": "hello from websocket"}
)
# Verify the consumer processes it correctly
await channel._on_message(event_dict)
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello from websocket"
assert raw.sender_id == "ou_test_ws"
assert raw.is_group is False
async def test_cleanup_websocket_mode(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
subscription_mode="websocket",
)
channel = FeishuChannel(config)
mock_client = MagicMock()
mock_client.aclose = AsyncMock()
channel._http_client = mock_client
channel._lark_ws_thread = MagicMock()
channel._main_loop = MagicMock()
channel._ws_event_queue = MagicMock()
channel._ws_consumer_task = None
channel._access_token = "fake-token"
await channel._cleanup()
mock_client.aclose.assert_called_once()
assert channel._http_client is None
assert channel._lark_ws_thread is None
assert channel._main_loop is None
assert channel._ws_event_queue is None
assert channel._access_token is None
# ── Webhook signature bypass regression (issue #392) ──────────────
class _FakeFeishuRequest:
"""Minimal stand-in for aiohttp.web.Request for _handle_event tests."""
def __init__(self, json_body):
self._json = json_body
async def json(self):
return self._json
class TestFeishuWebhookSignatureBypass:
"""Regression tests for issue #392: when ``encrypt_key`` is configured,
a plaintext POST (no ``encrypt`` field) must NOT reach the agent."""
FORGED_V2_EVENT: ClassVar[dict] = {
"schema": "2.0",
"header": {"event_type": "im.message.receive_v1", "token": ""},
"event": {
"sender": {"sender_id": {"open_id": "attacker"}, "sender_type": "user"},
"message": {
"chat_id": "oc_chat",
"message_type": "text",
"message_id": "om_msg",
"content": json.dumps({"text": "forged"}),
},
},
}
def _make_channel_with_encrypt_key(self) -> FeishuChannel:
config = FeishuConfig(
app_id="id",
app_secret="secret",
encrypt_key="my-encrypt-key",
)
channel = FeishuChannel(config)
channel._running = True
channel._http_client = MagicMock()
channel._access_token = "fake-token"
channel._token_expires = 9999999999
channel._on_message = AsyncMock() # type: ignore[assignment]
return channel
def _make_channel_without_encrypt_key(self) -> FeishuChannel:
config = FeishuConfig(app_id="id", app_secret="secret")
channel = FeishuChannel(config)
channel._running = True
channel._http_client = MagicMock()
channel._access_token = "fake-token"
channel._token_expires = 9999999999
channel._on_message = AsyncMock() # type: ignore[assignment]
return channel
async def test_plaintext_rejected_when_encrypt_key_configured(self):
"""Plaintext POST with no `encrypt` field → 403, agent not reached."""
channel = self._make_channel_with_encrypt_key()
resp = await channel._handle_event(_FakeFeishuRequest(self.FORGED_V2_EVENT))
assert resp.status == 403
channel._on_message.assert_not_called()
async def test_non_dict_body_rejected_when_encrypt_key_configured(self):
"""Defensive: a non-dict JSON body (list/str/etc.) → 403."""
channel = self._make_channel_with_encrypt_key()
for junk in ([1, 2, 3], "string-body", 42):
resp = await channel._handle_event(_FakeFeishuRequest(junk))
assert resp.status == 403, f"body={junk!r} should be rejected"
channel._on_message.assert_not_called()
async def test_encrypted_body_decrypts_and_processes(self):
"""A valid encrypted body → 200, agent reached (no behavior change)."""
channel = self._make_channel_with_encrypt_key()
decrypted_event = self.FORGED_V2_EVENT
with patch.object(
FeishuChannel, "_decrypt_event", return_value=decrypted_event
):
resp = await channel._handle_event(
_FakeFeishuRequest({"encrypt": "encrypted-blob"})
)
assert resp.status == 200
channel._on_message.assert_called_once()
async def test_plaintext_accepted_when_encrypt_key_not_configured(self):
"""No-regression: plaintext mode keeps working when no encrypt_key."""
channel = self._make_channel_without_encrypt_key()
resp = await channel._handle_event(_FakeFeishuRequest(self.FORGED_V2_EVENT))
assert resp.status == 200
channel._on_message.assert_called_once()