416 lines
17 KiB
Python
416 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from yuxi.channel.constants import InboundRejectionReason, channel_rate_limit_key
|
|
from yuxi.channel.plugins.protocol import InboundMessage, OutboundMessage
|
|
from yuxi.channel.security.models import DmPolicy, GroupPolicy, RateLimitPolicy
|
|
from yuxi.channel.security.pairing import PairingChecker
|
|
from yuxi.channel.security.policy import SecurityCheckResult, SecurityPolicy
|
|
from yuxi.channel.security.registry import SecurityCheckerRegistry, SecurityContext
|
|
|
|
|
|
class _MockAllowlistChecker:
|
|
name = "allowlist"
|
|
default_priority = 100
|
|
|
|
def __init__(self, mock: MagicMock) -> None:
|
|
self._mock = mock
|
|
|
|
async def check(self, ctx: SecurityContext) -> object:
|
|
policy = self._resolve_policy(ctx)
|
|
allowed = self._mock.check(policy, ctx.inbound, resolved_sender_id=ctx.resolved_sender_id)
|
|
if allowed:
|
|
return None
|
|
|
|
reason = (
|
|
InboundRejectionReason.DM_NOT_ALLOWED
|
|
if ctx.chat_type == "private"
|
|
else InboundRejectionReason.GROUP_NOT_ALLOWED
|
|
)
|
|
return SecurityCheckResult(allowed=False, reason=reason)
|
|
|
|
def _resolve_policy(self, ctx: SecurityContext) -> object:
|
|
if ctx.chat_type == "private":
|
|
policy = ctx.plugin.resolve_dm_policy(ctx.config, ctx.inbound.account_id)
|
|
return policy if policy is not None else DmPolicy()
|
|
policy = ctx.plugin.resolve_group_policy(ctx.config, ctx.inbound.account_id)
|
|
return policy if policy is not None else GroupPolicy()
|
|
|
|
|
|
class _MockBotLoopChecker:
|
|
name = "bot_loop"
|
|
default_priority = 300
|
|
|
|
def __init__(self, mock: MagicMock) -> None:
|
|
self._mock = mock
|
|
|
|
async def check(self, ctx: SecurityContext) -> object:
|
|
actor_id = ctx.resolved_sender_id or ctx.inbound.sender_id or ctx.inbound.peer_id or ""
|
|
is_bot = False
|
|
if isinstance(ctx.inbound.raw_event, dict):
|
|
is_bot = bool(ctx.inbound.raw_event.get("is_bot", False))
|
|
if self._mock.check(actor_id, is_bot):
|
|
return None
|
|
return SecurityCheckResult(allowed=False, reason=InboundRejectionReason.BOT_LOOP_DETECTED)
|
|
|
|
|
|
class _MockRateLimitChecker:
|
|
name = "rate_limit"
|
|
default_priority = 400
|
|
|
|
def __init__(self, mock: AsyncMock) -> None:
|
|
self._mock = mock
|
|
|
|
async def check(self, ctx: SecurityContext) -> object:
|
|
rate_policy = ctx.plugin.resolve_rate_limit_policy(ctx.config, ctx.inbound.account_id)
|
|
max_requests = (
|
|
rate_policy.max_requests_per_minute
|
|
if rate_policy
|
|
else ctx.config.get("rate_limit", {}).get("max_requests_per_minute", 60)
|
|
)
|
|
actor_id = ctx.resolved_sender_id or ctx.inbound.sender_id or ctx.inbound.peer_id or ""
|
|
rate_key = channel_rate_limit_key(ctx.inbound.channel_type, ctx.inbound.account_id or "", actor_id)
|
|
if await self._mock.is_allowed(rate_key, max_requests, window_seconds=60):
|
|
return None
|
|
return SecurityCheckResult(allowed=False, reason=InboundRejectionReason.RATE_LIMITED)
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin() -> MagicMock:
|
|
p = MagicMock()
|
|
p.resolve_dm_policy.return_value = DmPolicy(mode="open")
|
|
p.resolve_group_policy.return_value = None
|
|
p.resolve_rate_limit_policy.return_value = None
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def deps() -> dict[str, MagicMock | AsyncMock]:
|
|
identity = MagicMock()
|
|
identity.resolve.return_value = None
|
|
return {
|
|
"allowlist": MagicMock(),
|
|
"pairing": AsyncMock(),
|
|
"bot_loop": MagicMock(),
|
|
"rate_limit": AsyncMock(),
|
|
"identity": identity,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def policy(deps: dict[str, MagicMock | AsyncMock]) -> SecurityPolicy:
|
|
registry = SecurityCheckerRegistry()
|
|
registry.register(_MockAllowlistChecker(deps["allowlist"]))
|
|
registry.register(PairingChecker(manager=deps["pairing"]))
|
|
registry.register(_MockBotLoopChecker(deps["bot_loop"]))
|
|
registry.register(_MockRateLimitChecker(deps["rate_limit"]))
|
|
return SecurityPolicy(registry, identity_link_resolver=deps["identity"])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_logger(monkeypatch) -> None:
|
|
monkeypatch.setattr("yuxi.channel.security.pairing.logger", MagicMock())
|
|
|
|
|
|
def make_inbound(
|
|
*,
|
|
chat_type: str = "private",
|
|
sender_id: str | None = "u1",
|
|
peer_id: str | None = "p1",
|
|
account_id: str = "a1",
|
|
channel_type: str = "feishu",
|
|
raw_event: dict | None = None,
|
|
is_at_bot: bool = False,
|
|
session_key: str | None = None,
|
|
) -> InboundMessage:
|
|
return InboundMessage(
|
|
channel_type=channel_type,
|
|
account_id=account_id,
|
|
sender_id=sender_id,
|
|
peer_id=peer_id,
|
|
chat_type=chat_type,
|
|
is_at_bot=is_at_bot,
|
|
raw_event=raw_event or {},
|
|
session_key=session_key,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSecurityPolicyDm:
|
|
async def test_dm_allowed_passes_all_checks(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
inbound = make_inbound()
|
|
deps["identity"].resolve.return_value = "canonical_u1"
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
assert result.reason is None
|
|
assert result.pairing_code is None
|
|
deps["identity"].resolve.assert_called_once_with("feishu", "u1")
|
|
deps["allowlist"].check.assert_called_once_with(
|
|
DmPolicy(mode="open"), inbound, resolved_sender_id="canonical_u1"
|
|
)
|
|
deps["bot_loop"].check.assert_called_once_with("canonical_u1", False)
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "canonical_u1"), 60, window_seconds=60
|
|
)
|
|
|
|
async def test_dm_not_allowed_triggers_pairing_code(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
plugin.resolve_dm_policy.return_value = DmPolicy(mode="allow_from", allow_list=[])
|
|
deps["allowlist"].check.return_value = False
|
|
deps["pairing"].generate_pairing_code.return_value = "654321"
|
|
inbound = make_inbound(peer_id="p1")
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.DM_PAIRING_REQUIRED
|
|
assert result.pairing_code == "654321"
|
|
deps["pairing"].generate_pairing_code.assert_awaited_once_with("feishu", "a1", "p1", pairing_mode="code")
|
|
|
|
async def test_dm_not_allowed_without_build_pairing_qr_reply_uses_default_qr_reply(
|
|
self, policy: SecurityPolicy, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
"""插件未实现 build_pairing_qr_reply 时,应返回默认 OutboundMessage 降级回复。"""
|
|
plugin = MagicMock(spec=["resolve_dm_policy", "resolve_group_policy", "resolve_rate_limit_policy"])
|
|
plugin.resolve_dm_policy.return_value = DmPolicy(mode="allow_from", allow_list=[])
|
|
plugin.resolve_group_policy.return_value = None
|
|
plugin.resolve_rate_limit_policy.return_value = None
|
|
assert not hasattr(plugin, "build_pairing_qr_reply")
|
|
|
|
deps["allowlist"].check.return_value = False
|
|
deps["pairing"].generate_pairing_code.return_value = "654321"
|
|
config = {"pairing": {"mode": "qr"}}
|
|
inbound = make_inbound(peer_id="p1")
|
|
|
|
result = await policy.check(config, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.DM_PAIRING_REQUIRED
|
|
assert result.pairing_code == "654321"
|
|
assert result.qr_content == "654321"
|
|
assert isinstance(result.qr_reply, OutboundMessage)
|
|
assert result.qr_reply.content == "654321"
|
|
assert result.qr_reply.content_type == "text"
|
|
|
|
async def test_dm_deny_mode_does_not_generate_pairing_code(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
plugin.resolve_dm_policy.return_value = DmPolicy(mode="deny")
|
|
deps["allowlist"].check.return_value = False
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.DM_NOT_ALLOWED
|
|
assert result.pairing_code is None
|
|
deps["pairing"].generate_pairing_code.assert_not_awaited()
|
|
|
|
async def test_dm_pairing_failure_falls_back_to_dm_not_allowed(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
plugin.resolve_dm_policy.return_value = DmPolicy(mode="allow_from", allow_list=[])
|
|
deps["allowlist"].check.return_value = False
|
|
deps["pairing"].generate_pairing_code.side_effect = RuntimeError("db down")
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.DM_NOT_ALLOWED
|
|
assert result.pairing_code is None
|
|
|
|
async def test_dm_defaults_to_open_policy_when_plugin_returns_none(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
plugin.resolve_dm_policy.return_value = None
|
|
deps["identity"].resolve.return_value = None
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["allowlist"].check.assert_called_once_with(DmPolicy(), inbound, resolved_sender_id=None)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSecurityPolicyGroup:
|
|
async def test_group_not_allowed(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
inbound = make_inbound(chat_type="group", session_key="g1")
|
|
plugin.resolve_group_policy.return_value = GroupPolicy(deny_groups=["g1"])
|
|
deps["allowlist"].check.return_value = False
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.GROUP_NOT_ALLOWED
|
|
plugin.resolve_group_policy.assert_called_once_with({}, "a1")
|
|
|
|
async def test_group_defaults_policy_when_plugin_returns_none(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
inbound = make_inbound(chat_type="group", session_key="g1")
|
|
deps["identity"].resolve.return_value = "u1"
|
|
plugin.resolve_group_policy.return_value = None
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["allowlist"].check.assert_called_once_with(GroupPolicy(), inbound, resolved_sender_id="u1")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSecurityPolicyBotLoopAndRateLimit:
|
|
async def test_bot_loop_detected(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
deps["identity"].resolve.return_value = "canonical_u1"
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = False
|
|
inbound = make_inbound(raw_event={"is_bot": True})
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.BOT_LOOP_DETECTED
|
|
deps["bot_loop"].check.assert_called_once_with("canonical_u1", True)
|
|
|
|
async def test_raw_event_not_dict_treated_as_not_bot(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound(raw_event="not_a_dict")
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["bot_loop"].check.assert_called_once_with("u1", False)
|
|
|
|
async def test_rate_limited(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = False
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is False
|
|
assert result.reason == InboundRejectionReason.RATE_LIMITED
|
|
|
|
async def test_rate_limit_uses_config_when_plugin_returns_none(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
config = {"rate_limit": {"max_requests_per_minute": 42}}
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check(config, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "u1"), 42, window_seconds=60
|
|
)
|
|
|
|
async def test_rate_limit_defaults_to_60(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "u1"), 60, window_seconds=60
|
|
)
|
|
|
|
async def test_rate_limit_uses_plugin_policy(
|
|
self, policy: SecurityPolicy, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
plugin.resolve_rate_limit_policy.return_value = RateLimitPolicy(max_requests_per_minute=99)
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "u1"), 99, window_seconds=60
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSecurityPolicyIdentityFallback:
|
|
async def test_no_identity_resolver_uses_original_sender_id(
|
|
self, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
registry = SecurityCheckerRegistry()
|
|
registry.register(_MockAllowlistChecker(deps["allowlist"]))
|
|
registry.register(PairingChecker(manager=deps["pairing"]))
|
|
registry.register(_MockBotLoopChecker(deps["bot_loop"]))
|
|
registry.register(_MockRateLimitChecker(deps["rate_limit"]))
|
|
policy = SecurityPolicy(registry, identity_link_resolver=None)
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound()
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["allowlist"].check.assert_called_once_with(DmPolicy(mode="open"), inbound, resolved_sender_id=None)
|
|
deps["bot_loop"].check.assert_called_once_with("u1", False)
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "u1"), 60, window_seconds=60
|
|
)
|
|
|
|
async def test_actor_id_falls_back_to_peer_id(
|
|
self, plugin: MagicMock, deps: dict[str, MagicMock | AsyncMock]
|
|
) -> None:
|
|
registry = SecurityCheckerRegistry()
|
|
registry.register(_MockAllowlistChecker(deps["allowlist"]))
|
|
registry.register(PairingChecker(manager=deps["pairing"]))
|
|
registry.register(_MockBotLoopChecker(deps["bot_loop"]))
|
|
registry.register(_MockRateLimitChecker(deps["rate_limit"]))
|
|
policy = SecurityPolicy(registry, identity_link_resolver=None)
|
|
deps["allowlist"].check.return_value = True
|
|
deps["bot_loop"].check.return_value = True
|
|
deps["rate_limit"].is_allowed.return_value = True
|
|
inbound = make_inbound(sender_id=None, peer_id="peer_1")
|
|
|
|
result = await policy.check({}, plugin, inbound)
|
|
|
|
assert result.allowed is True
|
|
deps["bot_loop"].check.assert_called_once_with("peer_1", False)
|
|
deps["rate_limit"].is_allowed.assert_awaited_once_with(
|
|
channel_rate_limit_key("feishu", "a1", "peer_1"), 60, window_seconds=60
|
|
)
|