Both WeCom adapters dropped everything past the platform limit with no error: the AI Bot adapter sliced markdown content at 4000 chars, and the callback (self-built app) adapter sliced text at 2048 *characters* while message/send keeps only the first 2048 *bytes* (so CJK replies lost everything past about 680 characters). Cron output was additionally cut to 4000 chars with a gateway-host file pointer. AI Bot: send() queues one markdown message per MAX_MESSAGE_LENGTH chunk, so every chunk draws its own token from the 30 msgs/min per-chat bucket (errcode 846607), and each goes out on the cached reply req_id like any other send in that chat (group chats cannot send proactively). Callback: chunk by UTF-8 bytes at 2048. Both declare splits_long_messages so cron delivery hands over the full payload. Each stops at the first failed chunk and returns its error. Fixes #19689 Fixes #107443 Co-authored-by: luyao618 <364939526@qq.com> Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com> Co-authored-by: Andy <sz.private@gmail.com>
239 lines
8.4 KiB
Python
239 lines
8.4 KiB
Python
"""Tests for the WeCom callback-mode adapter."""
|
|
|
|
import asyncio
|
|
from xml.etree import ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from plugins.platforms.wecom import callback_adapter as _callback_mod
|
|
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
|
|
from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt
|
|
|
|
# ``_build_event`` parses inbound XML with defusedxml, which ships with the
|
|
# ``wecom`` extra (and transitively with ``youtube``, hence ``[all]`` in CI). A
|
|
# dev venv without either extra cannot exercise the parser; the crypto,
|
|
# routing, token-refresh and body-size tests below do not touch it and run
|
|
# regardless. This is a dependency gate, not a host-OS gate.
|
|
requires_defusedxml = pytest.mark.skipif(
|
|
not _callback_mod.DEFUSEDXML_AVAILABLE,
|
|
reason="defusedxml not installed (uv sync --extra wecom)",
|
|
)
|
|
|
|
|
|
def _app(name="test-app", corp_id="ww1234567890", agent_id="1000002"):
|
|
return {
|
|
"name": name,
|
|
"corp_id": corp_id,
|
|
"corp_secret": "test-secret",
|
|
"agent_id": agent_id,
|
|
"token": "test-callback-token",
|
|
"encoding_aes_key": "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
|
}
|
|
|
|
|
|
def _config(apps=None):
|
|
return PlatformConfig(
|
|
enabled=True,
|
|
extra={"mode": "callback", "host": "127.0.0.1", "port": 0, "apps": apps or [_app()]},
|
|
)
|
|
|
|
|
|
class TestWecomCrypto:
|
|
def test_roundtrip_encrypt_decrypt(self):
|
|
app = _app()
|
|
crypt = WXBizMsgCrypt(app["token"], app["encoding_aes_key"], app["corp_id"])
|
|
encrypted_xml = crypt.encrypt(
|
|
"<xml><Content>hello</Content></xml>", nonce="nonce123", timestamp="123456",
|
|
)
|
|
root = ET.fromstring(encrypted_xml)
|
|
decrypted = crypt.decrypt(
|
|
root.findtext("MsgSignature", default=""),
|
|
root.findtext("TimeStamp", default=""),
|
|
root.findtext("Nonce", default=""),
|
|
root.findtext("Encrypt", default=""),
|
|
)
|
|
assert b"<Content>hello</Content>" in decrypted
|
|
|
|
|
|
class TestWecomCallbackEventConstruction:
|
|
@requires_defusedxml
|
|
def test_build_event_extracts_text_message(self):
|
|
adapter = WecomCallbackAdapter(_config())
|
|
xml_text = """
|
|
<xml>
|
|
<ToUserName>ww1234567890</ToUserName>
|
|
<FromUserName>zhangsan</FromUserName>
|
|
<CreateTime>1710000000</CreateTime>
|
|
<MsgType>text</MsgType>
|
|
<Content>\u4f60\u597d</Content>
|
|
<MsgId>123456789</MsgId>
|
|
</xml>
|
|
"""
|
|
event = adapter._build_event(_app(), xml_text)
|
|
assert event is not None
|
|
assert event.source is not None
|
|
assert event.source.user_id == "zhangsan"
|
|
assert event.source.chat_id == "ww1234567890:zhangsan"
|
|
assert event.message_id == "123456789"
|
|
assert event.text == "\u4f60\u597d"
|
|
|
|
|
|
class TestWecomCallbackRouting:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_selects_correct_app_for_scoped_chat_id(self):
|
|
apps = [
|
|
_app(name="corp-a", corp_id="corpA", agent_id="1001"),
|
|
_app(name="corp-b", corp_id="corpB", agent_id="2002"),
|
|
]
|
|
adapter = WecomCallbackAdapter(_config(apps=apps))
|
|
adapter._user_app_map["corpB:alice"] = "corp-b"
|
|
adapter._access_tokens["corp-b"] = {"token": "tok-b", "expires_at": 9999999999}
|
|
|
|
calls = {}
|
|
|
|
class FakeResponse:
|
|
def json(self):
|
|
return {"errcode": 0, "msgid": "ok1"}
|
|
|
|
class FakeClient:
|
|
async def post(self, url, json):
|
|
calls["url"] = url
|
|
calls["json"] = json
|
|
return FakeResponse()
|
|
|
|
adapter._http_client = FakeClient()
|
|
result = await adapter.send("corpB:alice", "hello")
|
|
|
|
assert result.success is True
|
|
assert calls["json"]["touser"] == "alice"
|
|
assert calls["json"]["agentid"] == 2002
|
|
assert "tok-b" in calls["url"]
|
|
|
|
|
|
class TestWecomCallbackSendTokenRefresh:
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_with_fresh_token_on_errcode_40001(self):
|
|
"""errcode=40001 must evict the cached token, refresh, and retry once."""
|
|
adapter = WecomCallbackAdapter(_config())
|
|
adapter._access_tokens["test-app"] = {"token": "stale", "expires_at": 9999999999}
|
|
adapter._user_app_map["ww1234567890:alice"] = "test-app"
|
|
|
|
responses = [
|
|
{"errcode": 40001, "errmsg": "invalid credential"},
|
|
{"errcode": 0, "msgid": "msg-ok"},
|
|
]
|
|
post_calls = []
|
|
|
|
class FakeClient:
|
|
async def post(self, url, json=None, **kw):
|
|
post_calls.append(url)
|
|
|
|
class R:
|
|
def json(inner):
|
|
return responses[len(post_calls) - 1]
|
|
return R()
|
|
|
|
async def get(self, url, params=None, **kw):
|
|
class R:
|
|
def json(inner):
|
|
return {"errcode": 0, "access_token": "fresh", "expires_in": 7200}
|
|
return R()
|
|
|
|
adapter._http_client = FakeClient()
|
|
result = await adapter.send("ww1234567890:alice", "hello")
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "msg-ok"
|
|
assert len(post_calls) == 2
|
|
assert "fresh" in post_calls[1]
|
|
assert adapter._access_tokens["test-app"]["token"] == "fresh"
|
|
|
|
|
|
class TestWecomCallbackPollLoop:
|
|
@requires_defusedxml
|
|
@pytest.mark.asyncio
|
|
async def test_poll_loop_dispatches_handle_message(self, monkeypatch):
|
|
adapter = WecomCallbackAdapter(_config())
|
|
calls = []
|
|
|
|
async def fake_handle_message(event):
|
|
calls.append(event.text)
|
|
|
|
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
|
event = adapter._build_event(
|
|
_app(),
|
|
"""
|
|
<xml>
|
|
<ToUserName>ww1234567890</ToUserName>
|
|
<FromUserName>lisi</FromUserName>
|
|
<CreateTime>1710000000</CreateTime>
|
|
<MsgType>text</MsgType>
|
|
<Content>test</Content>
|
|
<MsgId>m2</MsgId>
|
|
</xml>
|
|
""",
|
|
)
|
|
task = asyncio.create_task(adapter._poll_loop())
|
|
await adapter._message_queue.put(event)
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert calls == ["test"]
|
|
|
|
|
|
class TestWecomCallbackBodySizeLimit:
|
|
"""Pre-auth oversized-body rejection (DoS hardening, PR #10192)."""
|
|
|
|
def _request(self, body_bytes):
|
|
from unittest.mock import Mock
|
|
|
|
from aiohttp import StreamReader
|
|
from aiohttp.test_utils import make_mocked_request
|
|
|
|
protocol = Mock(_reading_paused=False)
|
|
reader = StreamReader(protocol=protocol, limit=2 ** 20)
|
|
reader.feed_data(body_bytes)
|
|
reader.feed_eof()
|
|
return make_mocked_request(
|
|
"POST", "/wecom/callback?msg_signature=s×tamp=1&nonce=n",
|
|
payload=reader,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_body_rejected_with_413(self):
|
|
from plugins.platforms.wecom.callback_adapter import _MAX_BODY
|
|
|
|
adapter = WecomCallbackAdapter(_config())
|
|
oversized = b"<xml>" + b"A" * (_MAX_BODY + 1) + b"</xml>"
|
|
response = await adapter._handle_callback(self._request(oversized))
|
|
assert response.status == 413
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_cron_output_is_split_under_the_2048_byte_text_limit():
|
|
"""message/send keeps only the first 2048 BYTES of text.content; CJK output used to lose
|
|
everything past ~680 characters. The router now hands over the full payload and send()
|
|
splits it by UTF-8 bytes."""
|
|
from gateway.config import GatewayConfig
|
|
from gateway.delivery import DeliveryRouter
|
|
|
|
adapter = WecomCallbackAdapter(_config())
|
|
adapter._access_tokens["test-app"] = {"token": "tok", "expires_at": 9999999999}
|
|
sent = []
|
|
|
|
class _Client:
|
|
async def post(self, url, json):
|
|
sent.append(json["text"]["content"])
|
|
return type("R", (), {"json": lambda self: {"errcode": 0, "msgid": "m"}})()
|
|
|
|
adapter._http_client = _Client()
|
|
content = "\n\n".join(f"第{i}段 " + "数据" * 100 for i in range(40))
|
|
payload = DeliveryRouter(GatewayConfig())._cap_oversized_output(adapter, content, "job")
|
|
result = await adapter.send("test-app:user1", payload)
|
|
|
|
assert result.success
|
|
assert len(sent) > 1 and max(len(s.encode()) for s in sent) <= 2048
|
|
assert "第39段" in sent[-1]
|