fischer-agentkit/tests/unit/channels/test_slack.py

321 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Slack IM 适配器单元测试 (U12)。
覆盖场景:
- 签名校验(有效/无效/过期)
- URL 验证challenge / token 不匹配)
- Events API 消息解析
- Slash Command 解析
- <@U12345> 提及剥离
- 不支持事件类型
- send_message 成功/失败
- bot_token 构造器存储
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import urlencode
import pytest
from agentkit.channels.base import ChannelType, IncomingMessage, OutgoingMessage
from agentkit.channels.slack import (
SlackMessageAdapter,
SignatureVerificationError,
URLVerificationChallenge,
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _sign(signing_secret: str, timestamp: int, body: bytes) -> str:
"""构造 Slack X-Slack-Signature 头值v0= + hmac-sha256(signing_secret, "v0:{ts}:{body}")。"""
base = f"v0:{timestamp}:{body.decode('utf-8')}"
digest = hmac.new(
signing_secret.encode("utf-8"),
base.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"v0={digest}"
def _make_event_body(
*,
text: str = "hello",
event_type: str = "message",
event_id: str = "evt_001",
user: str = "U123",
channel: str = "C456",
ts: str = "1700000000.000123",
) -> dict[str, Any]:
"""构造 Slack Events API 事件 JSON 体。"""
return {
"event_id": event_id,
"type": "event_callback",
"event": {
"type": event_type,
"user": user,
"channel": channel,
"text": text,
"ts": ts,
},
}
# ---------------------------------------------------------------------------
# 签名校验
# ---------------------------------------------------------------------------
class TestSignatureVerification:
"""Slack 签名校验。"""
async def test_valid_signature(self):
"""正确 v0 签名返回 True。"""
signing_secret = "sh_secret"
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret=signing_secret)
body = json.dumps(_make_event_body()).encode("utf-8")
ts = int(time.time())
headers = {
"X-Slack-Signature": _sign(signing_secret, ts, body),
"X-Slack-Request-Timestamp": str(ts),
}
assert await adapter.verify_signature(headers, body) is True
async def test_invalid_signature(self):
"""篡改签名返回 False。"""
signing_secret = "sh_secret"
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret=signing_secret)
body = json.dumps(_make_event_body()).encode("utf-8")
ts = int(time.time())
headers = {"X-Slack-Signature": "v0=tampered", "X-Slack-Request-Timestamp": str(ts)}
assert await adapter.verify_signature(headers, body) is False
async def test_expired_timestamp(self):
"""时间戳超过 5 分钟返回 False。"""
signing_secret = "sh_secret"
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret=signing_secret)
body = json.dumps(_make_event_body()).encode("utf-8")
old_ts = int(time.time()) - 600
headers = {
"X-Slack-Signature": _sign(signing_secret, old_ts, body),
"X-Slack-Request-Timestamp": str(old_ts),
}
assert await adapter.verify_signature(headers, body) is False
async def test_missing_signature_headers(self):
"""缺签名头返回 False。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
assert await adapter.verify_signature({}, b"{}") is False
# ---------------------------------------------------------------------------
# URL 验证
# ---------------------------------------------------------------------------
class TestURLVerification:
"""Slack URL 验证流程。"""
async def test_url_verification_raises_challenge(self):
"""url_verification 事件抛 URLVerificationChallenge。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
body = json.dumps(
{"type": "url_verification", "challenge": "verify_abc", "token": "t"}
).encode("utf-8")
with pytest.raises(URLVerificationChallenge) as exc_info:
await adapter.receive_message({}, body)
assert exc_info.value.challenge == "verify_abc"
async def test_url_verification_token_mismatch_raises(self):
"""verification_token 不匹配抛 SignatureVerificationError。"""
adapter = SlackMessageAdapter(
bot_token="xoxb-x",
signing_secret="s",
verification_token="right_token",
)
body = json.dumps(
{"type": "url_verification", "challenge": "abc", "token": "wrong_token"}
).encode("utf-8")
with pytest.raises(SignatureVerificationError):
await adapter.receive_message({}, body)
async def test_url_verification_token_match_passes(self):
"""verification_token 匹配时正常抛出 challenge。"""
adapter = SlackMessageAdapter(
bot_token="xoxb-x",
signing_secret="s",
verification_token="right_token",
)
body = json.dumps(
{"type": "url_verification", "challenge": "ok123", "token": "right_token"}
).encode("utf-8")
with pytest.raises(URLVerificationChallenge) as exc_info:
await adapter.receive_message({}, body)
assert exc_info.value.challenge == "ok123"
# ---------------------------------------------------------------------------
# 消息解析
# ---------------------------------------------------------------------------
class TestEventsAPIParsing:
"""Events API 消息解析。"""
async def test_event_message_parsing(self):
"""Events API 消息解析为 IncomingMessage。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
body = json.dumps(_make_event_body(text="hello slack")).encode("utf-8")
msg = await adapter.receive_message({}, body)
assert isinstance(msg, IncomingMessage)
assert msg.channel == ChannelType.SLACK
assert msg.content == "hello slack"
assert msg.platform_message_id == "evt_001"
assert msg.user_id == "U123"
assert msg.chat_id == "C456"
assert msg.timestamp == "1700000000.000123"
async def test_mention_stripping(self):
"""<@U12345> 提及标记被剥离。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
body = json.dumps(_make_event_body(text="<@U12345> hello there")).encode("utf-8")
msg = await adapter.receive_message({}, body)
assert msg.content == "hello there"
async def test_mention_with_name_stripped(self):
"""<@U12345|name> 形式的提及标记也被剥离。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
body = json.dumps(_make_event_body(text="<@U999|bob> hi")).encode("utf-8")
msg = await adapter.receive_message({}, body)
assert msg.content == "hi"
async def test_unsupported_event_type(self):
"""非 message/app_mention 事件返回 unsupported 占位内容。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
body = json.dumps(_make_event_body(event_type="reaction_added")).encode("utf-8")
msg = await adapter.receive_message({}, body)
assert msg.content.startswith("[unsupported event type: reaction_added]")
class TestSlashCommandParsing:
"""Slash Command 解析。"""
async def test_slash_command_parsing(self):
"""form-encoded slash command 解析为 IncomingMessage。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
form = urlencode(
{
"text": "echo me",
"user_id": "U_slash",
"channel_id": "C_slash",
"command": "/echo",
"response_url": "https://hooks.slack.com/x",
}
)
body = form.encode("utf-8")
msg = await adapter.receive_message(
{"Content-Type": "application/x-www-form-urlencoded"}, body
)
assert msg.channel == ChannelType.SLACK
assert msg.content == "echo me"
assert msg.user_id == "U_slash"
assert msg.chat_id == "C_slash"
assert msg.platform_message_id.startswith("slash-")
assert msg.raw_event["command"] == "/echo"
# ---------------------------------------------------------------------------
# send_message
# ---------------------------------------------------------------------------
class TestSendMessage:
"""send_message 行为。"""
async def test_send_message_success(self):
"""HTTP 200 + ok=true 返回 True。"""
adapter = SlackMessageAdapter(bot_token="xoxb-123", signing_secret="s")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"ok": True, "channel": "C1", "ts": "1.0"}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
adapter._client = mock_client
out = OutgoingMessage(channel=ChannelType.SLACK, chat_id="C1", content="hi")
assert await adapter.send_message(out) is True
call = mock_client.post.call_args
assert "chat.postMessage" in call.args[0]
assert call.kwargs["headers"]["Authorization"] == "Bearer xoxb-123"
assert call.kwargs["json"]["channel"] == "C1"
async def test_send_message_failure(self):
"""HTTP 200 但 ok=false 返回 False。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"ok": False, "error": "channel_not_found"}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
adapter._client = mock_client
out = OutgoingMessage(channel=ChannelType.SLACK, chat_id="C1", content="hi")
assert await adapter.send_message(out) is False
async def test_send_message_http_error(self):
"""非 200 HTTP 状态返回 False。"""
adapter = SlackMessageAdapter(bot_token="xoxb-x", signing_secret="s")
mock_resp = MagicMock()
mock_resp.status_code = 500
mock_resp.text = "server error"
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
adapter._client = mock_client
out = OutgoingMessage(channel=ChannelType.SLACK, chat_id="C1", content="hi")
assert await adapter.send_message(out) is False
# ---------------------------------------------------------------------------
# 构造器 / 资源释放
# ---------------------------------------------------------------------------
class TestConstructor:
"""构造器与资源释放。"""
def test_bot_token_stored(self):
"""bot_token 由构造器存储。"""
adapter = SlackMessageAdapter(
bot_token="xoxb-secret-token", signing_secret="s", verification_token="vt"
)
assert adapter.bot_token == "xoxb-secret-token"
assert adapter.signing_secret == "s"
assert adapter.verification_token == "vt"
async def test_close_no_client_is_noop(self):
"""未创建 httpx 客户端时 close 不抛异常。"""
adapter = SlackMessageAdapter(bot_token="x", signing_secret="s")
await adapter.close()
async def test_close_resets_client(self):
"""close 后客户端引用清空。"""
adapter = SlackMessageAdapter(bot_token="x", signing_secret="s")
adapter._get_client()
assert adapter._client is not None
await adapter.close()
assert adapter._client is None