468 lines
15 KiB
Python
468 lines
15 KiB
Python
|
|
"""MSTeams config_resolver / route_config / tool_policy 单元测试。"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.msteams.config_resolver import (
|
||
|
|
resolve_nested_config,
|
||
|
|
resolve_reply_style,
|
||
|
|
resolve_require_mention,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.route_config import resolve_route_config
|
||
|
|
from yuxi.channels.adapters.msteams.tool_policy import ToolPolicy, resolve_tool_policy
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveNestedConfig:
|
||
|
|
def test_empty_config(self):
|
||
|
|
result = resolve_nested_config({}, "team-1", "ch-1")
|
||
|
|
assert result == {}
|
||
|
|
|
||
|
|
def test_exact_team_match(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "team-1")
|
||
|
|
assert result["team_config"]["reply_style"] == "thread"
|
||
|
|
|
||
|
|
def test_wildcard_team_match(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"*": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "unknown-team")
|
||
|
|
assert result["team_config"]["reply_style"] == "thread"
|
||
|
|
|
||
|
|
def test_no_team_match(self):
|
||
|
|
config = {"teams": {"team-1": {"reply_style": "thread"}}}
|
||
|
|
result = resolve_nested_config(config, "team-2")
|
||
|
|
assert result == {}
|
||
|
|
|
||
|
|
def test_channel_config_in_team(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"reply_style": "thread",
|
||
|
|
"channels": {"ch-1": {"require_mention": True}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "team-1", "ch-1")
|
||
|
|
assert result["team_config"]["reply_style"] == "thread"
|
||
|
|
assert result["channel_config"]["require_mention"] is True
|
||
|
|
|
||
|
|
def test_channel_wildcard(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"channels": {"*": {"require_mention": True}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "team-1", "ch-unknown")
|
||
|
|
assert result["channel_config"]["require_mention"] is True
|
||
|
|
|
||
|
|
def test_channel_not_found_no_wildcard(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"channels": {"ch-1": {"require_mention": True}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "team-1", "ch-2")
|
||
|
|
assert result.get("channel_config") is None
|
||
|
|
|
||
|
|
def test_no_channel_id(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"reply_style": "thread",
|
||
|
|
"channels": {"ch-1": {}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result = resolve_nested_config(config, "team-1")
|
||
|
|
assert "channel_config" not in result
|
||
|
|
assert result["team_config"]["reply_style"] == "thread"
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveReplyStyle:
|
||
|
|
def test_channel_override(self):
|
||
|
|
config = {
|
||
|
|
"reply_style": "inline",
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"reply_style": "thread",
|
||
|
|
"channels": {"ch-1": {"reply_style": "new_thread"}},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
result = resolve_reply_style(config, "team-1", "ch-1")
|
||
|
|
assert result == "new_thread"
|
||
|
|
|
||
|
|
def test_team_override(self):
|
||
|
|
config = {
|
||
|
|
"reply_style": "inline",
|
||
|
|
"teams": {"team-1": {"reply_style": "thread"}},
|
||
|
|
}
|
||
|
|
result = resolve_reply_style(config, "team-1", "ch-1")
|
||
|
|
assert result == "thread"
|
||
|
|
|
||
|
|
def test_global_default(self):
|
||
|
|
config = {"reply_style": "inline"}
|
||
|
|
result = resolve_reply_style(config, "team-1")
|
||
|
|
assert result == "inline"
|
||
|
|
|
||
|
|
def test_fallback_default(self):
|
||
|
|
result = resolve_reply_style({}, "team-1")
|
||
|
|
assert result == "thread"
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveRequireMention:
|
||
|
|
def test_channel_override(self):
|
||
|
|
config = {
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"require_mention": False,
|
||
|
|
"channels": {"ch-1": {"require_mention": True}},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
result = resolve_require_mention(config, "team-1", "ch-1")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
def test_team_override(self):
|
||
|
|
config = {
|
||
|
|
"require_mention": False,
|
||
|
|
"teams": {"team-1": {"require_mention": True}},
|
||
|
|
}
|
||
|
|
result = resolve_require_mention(config, "team-1")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
def test_global_fallback(self):
|
||
|
|
config = {"require_mention": True}
|
||
|
|
result = resolve_require_mention(config, "team-1")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
def test_none_when_not_configured(self):
|
||
|
|
result = resolve_require_mention({}, "team-1")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveRouteConfig:
|
||
|
|
def test_no_routes_returns_original(self):
|
||
|
|
config = {"app_id": "test"}
|
||
|
|
result = resolve_route_config(config)
|
||
|
|
assert result == config
|
||
|
|
|
||
|
|
def test_empty_routes(self):
|
||
|
|
config = {"app_id": "test", "routes": []}
|
||
|
|
result = resolve_route_config(config)
|
||
|
|
assert result == config
|
||
|
|
|
||
|
|
def test_exact_team_id_match(self):
|
||
|
|
config = {
|
||
|
|
"app_id": "global",
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"config": {"app_id": "team-override"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="team-1")
|
||
|
|
assert result["app_id"] == "team-override"
|
||
|
|
|
||
|
|
def test_team_name_match(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"name": "My Team"},
|
||
|
|
"config": {"streaming_mode": "off"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_name="My Team")
|
||
|
|
assert result["streaming_mode"] == "off"
|
||
|
|
|
||
|
|
def test_team_name_case_insensitive(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"name": "My Team"},
|
||
|
|
"config": {"streaming_mode": "off"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_name="my team")
|
||
|
|
assert result["streaming_mode"] == "off"
|
||
|
|
|
||
|
|
def test_team_slug_match(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"slug": "my-team"},
|
||
|
|
"config": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_name="My Team")
|
||
|
|
assert result["reply_style"] == "thread"
|
||
|
|
|
||
|
|
def test_slug_no_match(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"slug": "other-team"},
|
||
|
|
"config": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_name="My Team")
|
||
|
|
assert "reply_style" not in result
|
||
|
|
|
||
|
|
def test_team_and_channel_match(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"match_channel": {"id": "ch-1"},
|
||
|
|
"config": {"require_mention": False},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="team-1", channel_id="ch-1")
|
||
|
|
assert result["require_mention"] is False
|
||
|
|
|
||
|
|
def test_channel_mismatch(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"match_channel": {"id": "ch-1"},
|
||
|
|
"config": {"require_mention": False},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="team-1", channel_id="ch-2")
|
||
|
|
assert result == config
|
||
|
|
|
||
|
|
def test_wildcard_route(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "*"},
|
||
|
|
"config": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="any-team")
|
||
|
|
assert result["reply_style"] == "thread"
|
||
|
|
|
||
|
|
def test_first_match_wins(self):
|
||
|
|
config = {
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"config": {"reply_style": "first"},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"config": {"reply_style": "second"},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="team-1")
|
||
|
|
assert result["reply_style"] == "first"
|
||
|
|
|
||
|
|
def test_merged_config_preserves_original(self):
|
||
|
|
config = {
|
||
|
|
"app_id": "test",
|
||
|
|
"routes": [
|
||
|
|
{
|
||
|
|
"match_team": {"id": "team-1"},
|
||
|
|
"config": {"reply_style": "thread"},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
result = resolve_route_config(config, team_id="team-1")
|
||
|
|
assert result["app_id"] == "test"
|
||
|
|
assert result["reply_style"] == "thread"
|
||
|
|
|
||
|
|
|
||
|
|
class TestToolPolicyBasic:
|
||
|
|
def test_default_allows_all(self):
|
||
|
|
policy = ToolPolicy()
|
||
|
|
assert policy.is_tool_allowed("any_tool") is True
|
||
|
|
assert policy.is_tool_allowed("web_search") is True
|
||
|
|
|
||
|
|
def test_global_allow_restricts(self):
|
||
|
|
policy = ToolPolicy({"tools_allow": ["web_search", "code_interpreter"]})
|
||
|
|
assert policy.is_tool_allowed("web_search") is True
|
||
|
|
assert policy.is_tool_allowed("file_upload") is False
|
||
|
|
|
||
|
|
def test_global_deny_blocks(self):
|
||
|
|
policy = ToolPolicy({"tools_deny": ["web_search"]})
|
||
|
|
assert policy.is_tool_allowed("web_search") is False
|
||
|
|
assert policy.is_tool_allowed("code_interpreter") is True
|
||
|
|
|
||
|
|
def test_global_deny_overrides_allow(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_allow": ["web_search"],
|
||
|
|
"tools_deny": ["web_search"],
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestToolPolicySender:
|
||
|
|
def test_sender_allow(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_by_sender": [{"sender_id": "user-1", "allow": ["web_search"]}],
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", sender_id="user-1") is True
|
||
|
|
assert policy.is_tool_allowed("code_interpreter", sender_id="user-1") is False
|
||
|
|
|
||
|
|
def test_sender_deny(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_by_sender": [{"sender_id": "user-1", "allow": ["web_search", "code"], "deny": ["web_search"]}],
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", sender_id="user-1") is False
|
||
|
|
assert policy.is_tool_allowed("code", sender_id="user-1") is True
|
||
|
|
|
||
|
|
def test_sender_not_matched_falls_back(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_allow": ["web_search"],
|
||
|
|
"tools_by_sender": [{"sender_id": "user-1", "allow": ["code_interpreter"]}],
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", sender_id="user-2") is True
|
||
|
|
assert policy.is_tool_allowed("code_interpreter", sender_id="user-2") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestToolPolicyTeamChannel:
|
||
|
|
def test_team_allow(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {"tools": {"allow": ["web_search"]}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", team_id="team-1") is True
|
||
|
|
assert policy.is_tool_allowed("code_interpreter", team_id="team-1") is False
|
||
|
|
|
||
|
|
def test_team_deny(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {"tools": {"deny": ["web_search"]}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", team_id="team-1") is False
|
||
|
|
|
||
|
|
def test_channel_allow_override_team(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"tools": {"allow": ["code"]},
|
||
|
|
"channels": {
|
||
|
|
"ch-1": {"tools": {"allow": ["web_search"]}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
assert policy.is_tool_allowed("web_search", team_id="team-1", channel_id="ch-1") is False
|
||
|
|
assert policy.is_tool_allowed("code", team_id="team-1", channel_id="ch-1") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestToolPolicyGetAllowed:
|
||
|
|
def test_global_allow_list(self):
|
||
|
|
policy = ToolPolicy({"tools_allow": ["tool_a", "tool_b"]})
|
||
|
|
allowed = policy.get_allowed_tools()
|
||
|
|
assert allowed == ["tool_a", "tool_b"]
|
||
|
|
|
||
|
|
def test_no_allow_list_returns_none(self):
|
||
|
|
policy = ToolPolicy()
|
||
|
|
assert policy.get_allowed_tools() is None
|
||
|
|
|
||
|
|
def test_sender_allow(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_by_sender": [{"sender_id": "user-1", "allow": ["tool_a", "tool_b"]}],
|
||
|
|
})
|
||
|
|
allowed = policy.get_allowed_tools(sender_id="user-1")
|
||
|
|
assert allowed == ["tool_a", "tool_b"]
|
||
|
|
|
||
|
|
def test_channel_overrides_team(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"tools": {"allow": ["team_tool"]},
|
||
|
|
"channels": {
|
||
|
|
"ch-1": {"tools": {"allow": ["channel_tool"]}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
allowed = policy.get_allowed_tools(team_id="team-1", channel_id="ch-1")
|
||
|
|
assert allowed == ["channel_tool"]
|
||
|
|
|
||
|
|
def test_team_fallback_when_no_channel(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {"tools": {"allow": ["team_tool"]}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
allowed = policy.get_allowed_tools(team_id="team-1")
|
||
|
|
assert allowed == ["team_tool"]
|
||
|
|
|
||
|
|
def test_deny_excludes_from_allow(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"tools": {"allow": ["tool_a", "tool_b"], "deny": ["tool_b"]},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
allowed = policy.get_allowed_tools(team_id="team-1")
|
||
|
|
assert "tool_b" not in allowed
|
||
|
|
assert "tool_a" in allowed
|
||
|
|
|
||
|
|
|
||
|
|
class TestToolPolicyGetDenied:
|
||
|
|
def test_empty_config_returns_empty(self):
|
||
|
|
policy = ToolPolicy()
|
||
|
|
denied = policy.get_denied_tools()
|
||
|
|
assert denied == set()
|
||
|
|
|
||
|
|
def test_global_deny(self):
|
||
|
|
policy = ToolPolicy({"tools_deny": ["tool_a", "tool_b"]})
|
||
|
|
denied = policy.get_denied_tools()
|
||
|
|
assert denied == {"tool_a", "tool_b"}
|
||
|
|
|
||
|
|
def test_combined_deny(self):
|
||
|
|
policy = ToolPolicy({
|
||
|
|
"tools_deny": ["global_deny"],
|
||
|
|
"teams": {
|
||
|
|
"team-1": {
|
||
|
|
"tools": {"deny": ["team_deny"]},
|
||
|
|
"channels": {
|
||
|
|
"ch-1": {"tools": {"deny": ["channel_deny"]}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
denied = policy.get_denied_tools(team_id="team-1", channel_id="ch-1")
|
||
|
|
assert "global_deny" in denied
|
||
|
|
assert "team_deny" in denied
|
||
|
|
assert "channel_deny" in denied
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveToolPolicy:
|
||
|
|
def test_allow(self):
|
||
|
|
assert resolve_tool_policy({"tools_allow": ["web_search"]}, "web_search") is True
|
||
|
|
|
||
|
|
def test_deny(self):
|
||
|
|
assert resolve_tool_policy({"tools_deny": ["web_search"]}, "web_search") is False
|
||
|
|
|
||
|
|
def test_allow_missing(self):
|
||
|
|
assert resolve_tool_policy({"tools_allow": ["web_search"]}, "code_interpreter") is False
|