"""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": "Test Bot 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:")