新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""Phase 1 P0 安全与补齐 — 单元测试。
|
|
|
|
覆盖:
|
|
- P0-01: SecurityPolicy DM/Group Policy
|
|
- P0-02: messageReaction 事件标准化
|
|
- P0-03: adaptiveCard/action invoke 路由
|
|
- P0-08: 入站上下文字段
|
|
- P0-10: HTTP 错误分类
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.msteams.security import (
|
|
SecurityPolicy,
|
|
clean_allow_entry,
|
|
DM_POLICY_OPEN,
|
|
DM_POLICY_PAIRING,
|
|
DM_POLICY_ALLOWLIST,
|
|
DM_POLICY_DISABLED,
|
|
GROUP_POLICY_OPEN,
|
|
GROUP_POLICY_ALLOWLIST,
|
|
GROUP_POLICY_DISABLED,
|
|
)
|
|
from yuxi.channels.adapters.msteams.invoke_handler import (
|
|
normalize_reaction,
|
|
normalize_card_action,
|
|
)
|
|
from yuxi.channels.adapters.msteams.normalizer import normalize_inbound
|
|
from yuxi.channels.adapters.msteams.send import classify_http_error
|
|
from yuxi.channels.models import EventType
|
|
|
|
|
|
# ── P0-01 + P0-05: SecurityPolicy ──────────────────────────────────────────
|
|
|
|
|
|
class TestSecurityPolicy:
|
|
def test_dm_policy_open_allows_all(self):
|
|
sp = SecurityPolicy({"dm_policy": DM_POLICY_OPEN})
|
|
assert sp.check_dm("any-user") is True
|
|
assert sp.check_dm("another-user") is True
|
|
|
|
def test_dm_policy_disabled_rejects_all(self):
|
|
sp = SecurityPolicy({"dm_policy": DM_POLICY_DISABLED})
|
|
assert sp.check_dm("any-user") is False
|
|
|
|
def test_dm_policy_allowlist_with_exact_match(self):
|
|
sp = SecurityPolicy({
|
|
"dm_policy": DM_POLICY_ALLOWLIST,
|
|
"allow_from": ["user-001", "user-002"],
|
|
})
|
|
assert sp.check_dm("user-001") is True
|
|
assert sp.check_dm("user-002") is True
|
|
assert sp.check_dm("user-003") is False
|
|
|
|
def test_dm_policy_allowlist_with_wildcard(self):
|
|
sp = SecurityPolicy({
|
|
"dm_policy": DM_POLICY_ALLOWLIST,
|
|
"allow_from": ["*"],
|
|
})
|
|
assert sp.check_dm("any-user") is True
|
|
|
|
def test_dm_policy_pairing_allows_paired_users(self):
|
|
sp = SecurityPolicy({"dm_policy": DM_POLICY_PAIRING})
|
|
assert sp.check_dm("user-001") is False
|
|
sp.add_paired_user("user-001")
|
|
assert sp.check_dm("user-001") is True
|
|
sp.remove_paired_user("user-001")
|
|
assert sp.check_dm("user-001") is False
|
|
|
|
def test_dm_policy_allowlist_name_matching(self):
|
|
sp = SecurityPolicy({
|
|
"dm_policy": DM_POLICY_ALLOWLIST,
|
|
"allow_from": ["*@example.com"],
|
|
"allow_name_matching": True,
|
|
})
|
|
assert sp.check_dm("user@example.com") is True
|
|
assert sp.check_dm("user@other.com") is False
|
|
|
|
def test_group_policy_open_allows_all(self):
|
|
sp = SecurityPolicy({"group_policy": GROUP_POLICY_OPEN})
|
|
assert sp.check_group("any-user", conversation_id="conv-001") is True
|
|
|
|
def test_group_policy_disabled_rejects_all(self):
|
|
sp = SecurityPolicy({"group_policy": GROUP_POLICY_DISABLED})
|
|
assert sp.check_group("any-user", conversation_id="conv-001") is False
|
|
|
|
def test_group_policy_allowlist_user_match(self):
|
|
sp = SecurityPolicy({
|
|
"group_policy": GROUP_POLICY_ALLOWLIST,
|
|
"group_allow_from": ["user-001"],
|
|
})
|
|
assert sp.check_group("user-001") is True
|
|
assert sp.check_group("user-002") is False
|
|
|
|
def test_group_policy_allowlist_conv_match(self):
|
|
sp = SecurityPolicy({
|
|
"group_policy": GROUP_POLICY_ALLOWLIST,
|
|
"group_allow_from": ["conv-abc"],
|
|
})
|
|
assert sp.check_group("user-001", conversation_id="conv-abc") is True
|
|
assert sp.check_group("user-001", conversation_id="conv-xyz") is False
|
|
|
|
def test_require_mention_direct_chat_bypass(self):
|
|
sp = SecurityPolicy()
|
|
assert sp.check_require_mention(False, "direct") is True
|
|
assert sp.check_require_mention(True, "direct") is True
|
|
|
|
def test_require_mention_group_requires_mention(self):
|
|
sp = SecurityPolicy()
|
|
assert sp.check_require_mention(False, "group") is False
|
|
assert sp.check_require_mention(True, "group") is True
|
|
|
|
def test_require_mention_config_override(self):
|
|
sp = SecurityPolicy()
|
|
assert sp.check_require_mention(False, "group", {"require_mention": False}) is True
|
|
assert sp.check_require_mention(True, "group", {"require_mention": False}) is True
|
|
|
|
def test_invalid_dm_policy_fallback(self):
|
|
sp = SecurityPolicy({"dm_policy": "invalid"})
|
|
assert sp.dm_policy == DM_POLICY_OPEN
|
|
|
|
def test_invalid_group_policy_fallback(self):
|
|
sp = SecurityPolicy({"group_policy": "invalid"})
|
|
assert sp.group_policy == GROUP_POLICY_OPEN
|
|
|
|
def test_clean_allow_entry_strips_prefix(self):
|
|
assert clean_allow_entry("28:abc123") == "abc123"
|
|
|
|
def test_clean_allow_entry_strips_did_prefix(self):
|
|
assert clean_allow_entry("did:example:123") == "example:123"
|
|
|
|
def test_allow_from_count(self):
|
|
sp = SecurityPolicy({"allow_from": ["user-001", "user-002"]})
|
|
assert sp.allow_from_count == 2
|
|
assert sp.group_allow_from_count == 0
|
|
|
|
|
|
# ── P0-02: Reaction Events ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestReactionNormalization:
|
|
def test_reactions_added(self):
|
|
activity = {
|
|
"type": "messageReaction",
|
|
"id": "reaction-001",
|
|
"from": {"id": "user-001", "name": "Test User"},
|
|
"conversation": {"id": "conv-001"},
|
|
"reactionsAdded": [{"type": "like"}, {"type": "heart"}],
|
|
"reactionsRemoved": [],
|
|
"replyToId": "msg-001",
|
|
}
|
|
result = normalize_reaction(activity)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert result.metadata["reaction_direction"] == "added"
|
|
assert len(result.metadata["reactions"]["added"]) == 2
|
|
assert result.reply_to_message_id == "msg-001"
|
|
|
|
def test_reactions_removed(self):
|
|
activity = {
|
|
"type": "messageReaction",
|
|
"id": "reaction-002",
|
|
"from": {"id": "user-001", "name": "Test User"},
|
|
"conversation": {"id": "conv-001"},
|
|
"reactionsAdded": [],
|
|
"reactionsRemoved": [{"type": "like"}],
|
|
"replyToId": "msg-001",
|
|
}
|
|
result = normalize_reaction(activity)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert result.metadata["reaction_direction"] == "removed"
|
|
|
|
def test_reaction_no_direction(self):
|
|
activity = {
|
|
"type": "messageReaction",
|
|
"id": "reaction-003",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"reactionsAdded": [],
|
|
"reactionsRemoved": [],
|
|
}
|
|
result = normalize_reaction(activity)
|
|
assert result.metadata["reaction_direction"] == "unknown"
|
|
|
|
|
|
# ── P0-03: Invoke Card Action Routing ──────────────────────────────────────
|
|
|
|
|
|
class TestCardActionNormalization:
|
|
def test_vote_action(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "adaptiveCard/action",
|
|
"id": "invoke-001",
|
|
"from": {"id": "user-001", "name": "Voter"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"voteOption": "Option A"},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.event_type == EventType.CARD_ACTION
|
|
assert result.metadata["action_type"] == "vote"
|
|
assert result.content == "Option A"
|
|
|
|
def test_confirm_action(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "adaptiveCard/action",
|
|
"id": "invoke-002",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"data": {"action": "confirm", "id": "123"}},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "confirm"
|
|
|
|
def test_cancel_action(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "adaptiveCard/action",
|
|
"id": "invoke-003",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"data": {"action": "cancel"}},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "cancel"
|
|
|
|
def test_input_action(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "adaptiveCard/action",
|
|
"id": "invoke-004",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"data": {"action": "submit"}, "field1": "value1"},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "input"
|
|
|
|
def test_feedback_invoke(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "message/submitAction",
|
|
"id": "invoke-005",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"feedbackValue": "like"},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "feedback"
|
|
assert result.content == "like"
|
|
|
|
def test_file_consent_invoke(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "fileConsent/invoke",
|
|
"id": "invoke-006",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {"accept": True},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "file_consent"
|
|
|
|
def test_generic_card_action(self):
|
|
activity = {
|
|
"type": "invoke",
|
|
"name": "adaptiveCard/action",
|
|
"id": "invoke-007",
|
|
"from": {"id": "user-001"},
|
|
"conversation": {"id": "conv-001"},
|
|
"value": {},
|
|
}
|
|
result = normalize_card_action(activity)
|
|
assert result.metadata["action_type"] == "card_action"
|
|
|
|
|
|
# ── P0-08: Context Fields ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestContextFields:
|
|
def test_message_includes_context_fields(self):
|
|
activity = {
|
|
"type": "message",
|
|
"id": "ctx-001",
|
|
"from": {"id": "user-001", "name": "Context User", "aadObjectId": "aad-001"},
|
|
"conversation": {"id": "conv-001", "conversationType": "personal"},
|
|
"recipient": {"id": "bot-001"},
|
|
"text": "Hello bot",
|
|
"textFormat": "plain",
|
|
"channelId": "msteams",
|
|
"serviceUrl": "https://example.com",
|
|
}
|
|
result = normalize_inbound(activity)
|
|
assert result.metadata.get("Body") == "Hello bot"
|
|
assert result.metadata.get("BodyForAgent") == "Hello bot"
|
|
assert result.metadata.get("RawBody") == "Hello bot"
|
|
assert result.metadata.get("SessionKey") is not None
|
|
assert result.metadata.get("Provider") == "msteams"
|
|
assert result.metadata.get("Surface") == "personal"
|
|
assert result.metadata.get("WasMentioned") is False
|
|
|
|
def test_command_body_extraction(self):
|
|
activity = {
|
|
"type": "message",
|
|
"id": "ctx-002",
|
|
"from": {"id": "user-001", "name": "Cmd User", "aadObjectId": "aad-001"},
|
|
"conversation": {"id": "conv-001", "conversationType": "personal"},
|
|
"recipient": {"id": "bot-001"},
|
|
"text": "/help",
|
|
"textFormat": "plain",
|
|
}
|
|
result = normalize_inbound(activity)
|
|
assert result.metadata.get("CommandBody") == "/help"
|
|
|
|
def test_mentioned_context(self):
|
|
activity = {
|
|
"type": "message",
|
|
"id": "ctx-003",
|
|
"from": {"id": "user-001", "name": "Mention User", "aadObjectId": "aad-001"},
|
|
"conversation": {"id": "conv-001", "conversationType": "groupChat"},
|
|
"recipient": {"id": "bot-001"},
|
|
"text": "<at>Test Bot</at> hello",
|
|
"textFormat": "markdown",
|
|
"entities": [
|
|
{"type": "mention", "mentioned": {"id": "bot-001", "name": "Test Bot"}},
|
|
],
|
|
}
|
|
result = normalize_inbound(activity)
|
|
assert result.metadata.get("WasMentioned") is True
|
|
assert "Bot mentioned" in result.metadata.get("BodyForAgent", "")
|
|
|
|
|
|
# ── P0-10: HTTP Error Classification ───────────────────────────────────────
|
|
|
|
|
|
class TestErrorClassification:
|
|
def test_401_auth_failed(self):
|
|
error = classify_http_error(401, "Unauthorized")
|
|
assert "auth_failed:401" in error
|
|
assert "token_expired_or_invalid" in error
|
|
|
|
def test_403_insufficient_permissions(self):
|
|
error = classify_http_error(403, "Forbidden")
|
|
assert "auth_failed:403" in error
|
|
assert "insufficient_permissions" in error
|
|
|
|
def test_429_rate_limited(self):
|
|
error = classify_http_error(429, "Too Many Requests")
|
|
assert error.startswith("rate_limited:")
|
|
|
|
def test_5xx_server_error(self):
|
|
error = classify_http_error(500)
|
|
assert error.startswith("server_error:500")
|
|
|
|
def test_other_status(self):
|
|
error = classify_http_error(404, "Not Found")
|
|
assert error.startswith("http_404:") |