1. webhook路由新增记录请求源IP与请求头 2. 修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑 3. 新增Nostr适配器测试通用fixture 4. 更新NextcloudTalk签名相关测试与函数命名
1379 lines
50 KiB
Python
1379 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.exceptions import ChannelAuthenticationError, ChannelNotConnectedError
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
from yuxi.channels.adapters.synologychat.adapter import SynologyChatAdapter
|
|
from yuxi.channels.adapters.synologychat.client import DSMClient
|
|
from yuxi.channels.adapters.synologychat.normalize import normalize_event
|
|
from yuxi.channels.adapters.synologychat.probe import probe_dsm, resolve_api_version
|
|
|
|
|
|
def make_dsm_event(
|
|
user_id: str = "user-001",
|
|
channel_id: str = "channel-001",
|
|
message_id: str = "msg-001",
|
|
text: str = "Hello",
|
|
channel_type: str = "user",
|
|
) -> dict:
|
|
return {
|
|
"user_id": user_id,
|
|
"channel_id": channel_id,
|
|
"message_id": message_id,
|
|
"message": {"text": text},
|
|
"channel_type": channel_type,
|
|
"channel_name": "test-channel",
|
|
"user_name": "test-user",
|
|
"timestamp": 1746691200,
|
|
}
|
|
|
|
|
|
def make_adapter_config(**overrides) -> dict:
|
|
config = {
|
|
"dsm_url": "https://synology-nas.local:5001",
|
|
"username": "bot",
|
|
"password": "test123",
|
|
"dm_policy": "open",
|
|
}
|
|
config.update(overrides)
|
|
return config
|
|
|
|
|
|
class TestSynologyChatAdapter:
|
|
def test_channel_id_and_type(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.channel_id == "synologychat"
|
|
assert adapter.channel_type == ChannelType.SYNOLOGYCHAT
|
|
|
|
def test_default_config_values(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.text_chunk_limit == 4000
|
|
assert adapter.supports_markdown is True
|
|
assert adapter.supports_streaming is True
|
|
assert adapter.streaming_modes == ["block"]
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
def test_direct_message(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event(channel_type="user")
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert msg.identity.channel_id == "synologychat"
|
|
assert msg.identity.channel_type == ChannelType.SYNOLOGYCHAT
|
|
assert msg.identity.channel_user_id == "user-001"
|
|
assert msg.identity.channel_chat_id == "channel-001"
|
|
assert msg.identity.channel_message_id == "msg-001"
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.content == "Hello"
|
|
assert msg.event_type == EventType.MESSAGE_RECEIVED
|
|
assert msg.message_type == MessageType.TEXT
|
|
|
|
def test_group_message(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event(channel_type="channel", text="Group hello")
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.content == "Group hello"
|
|
assert msg.metadata["dsm_chat_type"] == "group"
|
|
|
|
def test_with_attachment(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event()
|
|
event["message"]["file"] = {
|
|
"url": "https://nas.local/file.jpg",
|
|
"filename": "photo.jpg",
|
|
"file_size": 1024,
|
|
"mime_type": "image/jpeg",
|
|
"is_image": True,
|
|
}
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "image"
|
|
assert msg.attachments[0].url == "https://nas.local/file.jpg"
|
|
assert msg.attachments[0].filename == "photo.jpg"
|
|
assert msg.attachments[0].mime_type == "image/jpeg"
|
|
|
|
def test_without_file(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event()
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert len(msg.attachments) == 0
|
|
|
|
def test_timestamp_parsing(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event()
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert msg.timestamp is not None
|
|
assert msg.timestamp.year == 2025
|
|
|
|
def test_empty_text(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
event = make_dsm_event(text="")
|
|
msg = adapter.normalize_inbound(event)
|
|
|
|
assert msg.content == ""
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_text_message(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="channel-001",
|
|
),
|
|
content="Hello back",
|
|
)
|
|
payload = adapter.format_outbound(response)
|
|
|
|
assert payload["channel_id"] == "channel-001"
|
|
assert payload["text"] == "Hello back"
|
|
|
|
def test_with_image_attachment(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="channel-001",
|
|
),
|
|
content="Check this",
|
|
attachments=[Attachment(type="image", url="https://nas.local/img.png")],
|
|
)
|
|
payload = adapter.format_outbound(response)
|
|
|
|
assert payload["file_url"] == "https://nas.local/img.png"
|
|
|
|
def test_without_attachment_url(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="channel-001",
|
|
),
|
|
content="Hello",
|
|
attachments=[Attachment(type="image", url=None)],
|
|
)
|
|
payload = adapter.format_outbound(response)
|
|
|
|
assert "file_url" not in payload
|
|
|
|
|
|
class TestHealthCheck:
|
|
@pytest.mark.asyncio
|
|
async def test_no_http_client(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
status = await adapter.health_check()
|
|
assert status.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_http_client_probe_fails(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
adapter._http_client = AsyncMock()
|
|
adapter._http_client.get = AsyncMock(return_value=MagicMock())
|
|
adapter._http_client.get.return_value.raise_for_status = MagicMock()
|
|
adapter._http_client.get.return_value.json.return_value = {
|
|
"success": False,
|
|
"error": {"code": 102},
|
|
}
|
|
|
|
status = await adapter.health_check()
|
|
assert status.status == "degraded"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_healthy(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
adapter._http_client = AsyncMock()
|
|
adapter._dsm_client = AsyncMock()
|
|
adapter._dsm_client.list_channels.return_value = {
|
|
"success": True,
|
|
"data": {"channels": []},
|
|
}
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"SYNO.API.Auth": {"maxVersion": 6},
|
|
"SYNO.Chat.External": {"maxVersion": 1},
|
|
},
|
|
}
|
|
adapter._http_client.get.return_value = mock_response
|
|
|
|
status = await adapter.health_check()
|
|
assert status.status == "healthy"
|
|
|
|
|
|
class TestPreConnect:
|
|
@pytest.mark.asyncio
|
|
async def test_missing_dsm_url(self):
|
|
adapter = SynologyChatAdapter({})
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_success(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
mock_client = AsyncMock()
|
|
mock_client_cls.return_value.__aenter__.return_value = mock_client
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"SYNO.API.Auth": {"maxVersion": 6},
|
|
"SYNO.Chat.External": {"maxVersion": 1},
|
|
},
|
|
}
|
|
mock_client.get.return_value = mock_response
|
|
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "ok"
|
|
assert "SYNO.API.Auth" in result["available_apis"]
|
|
|
|
|
|
class TestProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_probe_success(self):
|
|
mock_client = AsyncMock()
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"SYNO.API.Auth": {"maxVersion": 6, "minVersion": 1},
|
|
"SYNO.Chat.External": {"maxVersion": 1, "minVersion": 1},
|
|
},
|
|
}
|
|
mock_client.get.return_value = mock_response
|
|
|
|
result = await probe_dsm(mock_client, "https://nas.local:5001")
|
|
assert "SYNO.API.Auth" in result
|
|
assert "SYNO.Chat.External" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_not_success(self):
|
|
mock_client = AsyncMock()
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {"success": False, "error": {"code": 102}}
|
|
mock_client.get.return_value = mock_response
|
|
|
|
result = await probe_dsm(mock_client, "https://nas.local:5001")
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_missing_api(self):
|
|
mock_client = AsyncMock()
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"SYNO.API.Auth": {"maxVersion": 6},
|
|
},
|
|
}
|
|
mock_client.get.return_value = mock_response
|
|
|
|
result = await probe_dsm(mock_client, "https://nas.local:5001")
|
|
assert result == {}
|
|
|
|
|
|
class TestResolveApiVersion:
|
|
def test_with_api_info(self):
|
|
api_info = {"SYNO.API.Auth": {"maxVersion": 6, "minVersion": 1}}
|
|
assert resolve_api_version(api_info, "SYNO.API.Auth") == 6
|
|
|
|
def test_without_api_info_auth(self):
|
|
assert resolve_api_version(None, "SYNO.API.Auth") == 6
|
|
|
|
def test_without_api_info_chat(self):
|
|
assert resolve_api_version(None, "SYNO.Chat.External") == 1
|
|
|
|
def test_without_api_info_unknown(self):
|
|
assert resolve_api_version(None, "UNKNOWN") == 1
|
|
|
|
def test_fallback_to_min_version(self):
|
|
api_info = {"SYNO.API.Auth": {"minVersion": 3}}
|
|
assert resolve_api_version(api_info, "SYNO.API.Auth") == 3
|
|
|
|
|
|
class TestDSMClient:
|
|
def test_client_init(self):
|
|
mock_http = AsyncMock()
|
|
client = DSMClient(mock_http, "https://nas.local:5001", {"username": "bot"}, None)
|
|
assert client.sid is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_success(self):
|
|
mock_http = AsyncMock()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {"sid": "test_sid_12345"},
|
|
}
|
|
mock_http.get.return_value = mock_response
|
|
|
|
client = DSMClient(
|
|
mock_http,
|
|
"https://nas.local:5001",
|
|
{"username": "bot", "password": "test123"},
|
|
{"SYNO.API.Auth": {"maxVersion": 6}},
|
|
)
|
|
|
|
sid = await client.login()
|
|
assert sid == "test_sid_12345"
|
|
assert client.sid == "test_sid_12345"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_failure(self):
|
|
mock_http = AsyncMock()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": False,
|
|
"error": {"code": 400},
|
|
}
|
|
mock_http.get.return_value = mock_response
|
|
|
|
client = DSMClient(
|
|
mock_http,
|
|
"https://nas.local:5001",
|
|
{"username": "bot", "password": "wrong"},
|
|
{"SYNO.API.Auth": {"maxVersion": 6}},
|
|
)
|
|
|
|
with pytest.raises(ChannelAuthenticationError):
|
|
await client.login()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_with_auth(self):
|
|
mock_http = AsyncMock()
|
|
|
|
def make_response(params=None):
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
if "login" in str(params):
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {"sid": "test_sid_12345"},
|
|
}
|
|
else:
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"data": {"events": []},
|
|
}
|
|
return mock_response
|
|
|
|
mock_http.get.side_effect = lambda url, params=None, **kwargs: make_response(params)
|
|
|
|
client = DSMClient(
|
|
mock_http,
|
|
"https://nas.local:5001",
|
|
{"username": "bot", "password": "test123"},
|
|
{"SYNO.API.Auth": {"maxVersion": 6}, "SYNO.Chat.External": {"maxVersion": 1}},
|
|
)
|
|
|
|
result = await client.call("SYNO.Chat.External", "Polling", {})
|
|
assert result["success"] is True
|
|
|
|
|
|
class TestSessionRoute:
|
|
def test_direct_route(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_session_route
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="dm-channel-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
)
|
|
|
|
route = resolve_session_route(msg)
|
|
assert route == "agent:default:synologychat:default:direct:user-001"
|
|
|
|
def test_group_route(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_session_route
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="group-channel-001",
|
|
),
|
|
chat_type=ChatType.GROUP,
|
|
content="Hello",
|
|
)
|
|
|
|
route = resolve_session_route(msg)
|
|
assert route == "agent:default:synologychat:default:group:group-channel-001"
|
|
|
|
def test_custom_agent_id(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_session_route
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="chat-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
)
|
|
|
|
route = resolve_session_route(msg, "custom-agent")
|
|
assert route == "agent:custom-agent:synologychat:default:direct:user-001"
|
|
|
|
|
|
# ========== New tests for fixes and new features ==========
|
|
|
|
|
|
class TestSynologyChatSecurityPolicy:
|
|
@pytest.fixture
|
|
def security_open(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
return SynologyChatSecurityPolicy({"security": {"dm_policy": "open", "group_policy": "open"}})
|
|
|
|
@pytest.fixture
|
|
def security_allowlist(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
return SynologyChatSecurityPolicy(
|
|
{
|
|
"security": {
|
|
"dm_policy": "allowlist",
|
|
"group_policy": "allowlist",
|
|
"allow_from": ["user-001"],
|
|
"group_allow_from": ["user-003"],
|
|
}
|
|
}
|
|
)
|
|
|
|
@pytest.fixture
|
|
def security_disabled(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
return SynologyChatSecurityPolicy({"security": {"dm_policy": "disabled", "group_policy": "disabled"}})
|
|
|
|
def make_dm_msg(self, user_id="user-001"):
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id=user_id,
|
|
channel_chat_id="chat-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
)
|
|
|
|
def make_group_msg(self, user_id="user-001", chat_id="chat-001"):
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id=user_id,
|
|
channel_chat_id=chat_id,
|
|
),
|
|
chat_type=ChatType.GROUP,
|
|
content="Hello",
|
|
)
|
|
|
|
def test_dm_open_allows_all(self, security_open):
|
|
assert security_open.check(self.make_dm_msg("anyone")) is True
|
|
|
|
def test_dm_disabled_blocks_all(self, security_disabled):
|
|
assert security_disabled.check(self.make_dm_msg("anyone")) is False
|
|
|
|
def test_dm_allowlist_allows_known(self, security_allowlist):
|
|
assert security_allowlist.check(self.make_dm_msg("user-001")) is True
|
|
|
|
def test_dm_allowlist_blocks_unknown(self, security_allowlist):
|
|
assert security_allowlist.check(self.make_dm_msg("user-002")) is False
|
|
|
|
def test_group_open_allows_all(self, security_open):
|
|
assert security_open.check(self.make_group_msg("anyone")) is True
|
|
|
|
def test_group_disabled_blocks_all(self, security_disabled):
|
|
assert security_disabled.check(self.make_group_msg("anyone")) is False
|
|
|
|
def test_group_allowlist_allows_known(self, security_allowlist):
|
|
assert security_allowlist.check(self.make_group_msg("user-003")) is True
|
|
|
|
def test_group_allowlist_blocks_unknown(self, security_allowlist):
|
|
assert security_allowlist.check(self.make_group_msg("user-004")) is False
|
|
|
|
def test_unknown_dm_policy_defaults_open(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "invalid_policy"}})
|
|
assert policy.check(self.make_dm_msg("anyone")) is True
|
|
|
|
def test_unknown_group_policy_defaults_open(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"group_policy": "invalid_policy"}})
|
|
assert policy.check(self.make_group_msg("anyone")) is True
|
|
|
|
|
|
class TestNormalizeWithBotMention:
|
|
def test_bot_mentioned(self):
|
|
event = make_dsm_event(text="Hey @bot help me")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT, bot_name="bot")
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
assert "bot" in msg.mentions.mentioned_user_ids
|
|
|
|
def test_bot_not_mentioned(self):
|
|
event = make_dsm_event(text="Hey @someone help me")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT, bot_name="bot")
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is False
|
|
|
|
def test_no_mentions_without_bot_name(self):
|
|
event = make_dsm_event(text="Hey @bot help me")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is False
|
|
|
|
|
|
class TestNormalizeUrlExtraction:
|
|
def test_extract_single_url(self):
|
|
event = make_dsm_event(text="Check https://example.com/page")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert "https://example.com/page" in msg.extracted_urls
|
|
|
|
def test_extract_multiple_urls(self):
|
|
event = make_dsm_event(text="See https://a.com and http://b.org/path")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert len(msg.extracted_urls) == 2
|
|
assert "https://a.com" in msg.extracted_urls
|
|
assert "http://b.org/path" in msg.extracted_urls
|
|
|
|
def test_no_url_in_text(self):
|
|
event = make_dsm_event(text="Just plain text")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.extracted_urls == []
|
|
|
|
|
|
class TestNormalizeCommand:
|
|
def test_command_slash(self):
|
|
event = make_dsm_event(text="/help")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.message_type == MessageType.COMMAND
|
|
|
|
def test_command_with_args(self):
|
|
event = make_dsm_event(text="/search something")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.message_type == MessageType.COMMAND
|
|
|
|
def test_regular_text_not_command(self):
|
|
event = make_dsm_event(text="not a /command")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.message_type == MessageType.TEXT
|
|
|
|
|
|
class TestNormalizeEventTypeMapping:
|
|
@pytest.mark.parametrize(
|
|
"event_type_str,expected",
|
|
[
|
|
("", EventType.MESSAGE_RECEIVED),
|
|
("message", EventType.MESSAGE_RECEIVED),
|
|
("message_received", EventType.MESSAGE_RECEIVED),
|
|
("message_updated", EventType.MESSAGE_UPDATED),
|
|
("message_edited", EventType.MESSAGE_UPDATED),
|
|
("message_deleted", EventType.MESSAGE_DELETED),
|
|
("bot_added", EventType.BOT_ADDED),
|
|
("bot_removed", EventType.BOT_REMOVED),
|
|
("member_joined", EventType.MEMBER_JOINED),
|
|
("member_left", EventType.MEMBER_LEFT),
|
|
("unknown_type", EventType.MESSAGE_RECEIVED),
|
|
],
|
|
)
|
|
def test_event_type_resolution(self, event_type_str, expected):
|
|
from yuxi.channels.adapters.synologychat.normalize import _resolve_event_type
|
|
|
|
assert _resolve_event_type(event_type_str) == expected
|
|
|
|
|
|
class TestAdapterNewMethods:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return SynologyChatAdapter(make_adapter_config())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_info_no_client(self, adapter):
|
|
result = await adapter.get_user_info("user-001")
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_info_with_client(self, adapter):
|
|
adapter._dsm_client = AsyncMock()
|
|
adapter._dsm_client.user_list.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"users": [
|
|
{"user_id": "user-001", "username": "alice", "name": "Alice"},
|
|
{"user_id": "user-002", "username": "bob", "name": "Bob"},
|
|
]
|
|
},
|
|
}
|
|
result = await adapter.get_user_info("user-001")
|
|
assert result["username"] == "alice"
|
|
assert result["name"] == "Alice"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_info_not_found(self, adapter):
|
|
adapter._dsm_client = AsyncMock()
|
|
adapter._dsm_client.user_list.return_value = {
|
|
"success": True,
|
|
"data": {"users": []},
|
|
}
|
|
result = await adapter.get_user_info("user-999")
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_no_client(self, adapter):
|
|
with pytest.raises(ChannelNotConnectedError):
|
|
await adapter.download_media("file-001")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_success(self, adapter):
|
|
adapter._dsm_client = AsyncMock()
|
|
adapter._dsm_client.call.return_value = {
|
|
"success": True,
|
|
"data": b"fake-binary-data",
|
|
}
|
|
result = await adapter.download_media("file-001")
|
|
assert result == b"fake-binary-data"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chat_action_no_client(self, adapter):
|
|
result = await adapter.send_chat_action("chat-001", "typing")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chat_action_success(self, adapter):
|
|
adapter._dsm_client = AsyncMock()
|
|
adapter._dsm_client.call.return_value = {"success": True}
|
|
result = await adapter.send_chat_action("chat-001", "typing")
|
|
assert result.success is True
|
|
|
|
|
|
class TestSendReplyPrefixLogging:
|
|
def test_build_text_drops_reply_prefix_with_log(self):
|
|
from yuxi.channels.adapters.synologychat.send import _build_text
|
|
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="chat-001",
|
|
),
|
|
content="x" * 3990,
|
|
reply_to_message_id="msg-001",
|
|
)
|
|
config = {"text_chunk_limit": 4000, "reply_to_mode": "first"}
|
|
result = _build_text(response, config)
|
|
assert "msg-001" not in result
|
|
|
|
|
|
class TestPollingLoopCircuitBreaker:
|
|
@pytest.mark.asyncio
|
|
async def test_polling_loop_handles_circuit_breaker_open(self):
|
|
from yuxi.channels.adapters.synologychat.monitor import polling_loop
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
|
|
client = AsyncMock()
|
|
client.poll_messages.side_effect = CircuitBreakerOpenError("Open")
|
|
|
|
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)
|
|
cb.state = "open"
|
|
|
|
status_values = [ChannelStatus.CONNECTED, ChannelStatus.CONNECTED, ChannelStatus.DISCONNECTED]
|
|
status_iter = iter(status_values)
|
|
|
|
def get_status():
|
|
return next(status_iter)
|
|
|
|
normalize_fn = MagicMock()
|
|
handle_fn = AsyncMock()
|
|
|
|
await polling_loop(
|
|
client,
|
|
{"polling_interval_seconds": 0.01, "polling_max_backoff_seconds": 1},
|
|
normalize_fn,
|
|
handle_fn,
|
|
get_status,
|
|
cb,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_polling_loop_resets_failures_on_success(self):
|
|
from yuxi.channels.adapters.synologychat.monitor import polling_loop
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
|
|
client = AsyncMock()
|
|
poll_results = [
|
|
CircuitBreakerOpenError("Open"),
|
|
{"success": True, "data": {"events": [], "next_cursor": "cursor-1"}},
|
|
CircuitBreakerOpenError("Open"),
|
|
]
|
|
poll_iter = iter(poll_results)
|
|
|
|
async def mock_poll(*args, **kwargs):
|
|
val = next(poll_iter)
|
|
if isinstance(val, Exception):
|
|
raise val
|
|
return val
|
|
|
|
client.poll_messages = mock_poll
|
|
|
|
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1)
|
|
cb.state = "open"
|
|
|
|
status_values = [ChannelStatus.CONNECTED] * 5 + [ChannelStatus.DISCONNECTED]
|
|
status_iter = iter(status_values)
|
|
|
|
def get_status():
|
|
return next(status_iter)
|
|
|
|
def normalize_fn(x):
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="test",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="u1",
|
|
channel_chat_id="c1",
|
|
),
|
|
content="test",
|
|
)
|
|
|
|
handle_fn = AsyncMock()
|
|
|
|
await polling_loop(
|
|
client,
|
|
{"polling_interval_seconds": 0.01, "polling_max_backoff_seconds": 1},
|
|
normalize_fn,
|
|
handle_fn,
|
|
get_status,
|
|
cb,
|
|
)
|
|
|
|
|
|
class TestMarkdownFormatting:
|
|
def test_empty_text(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
assert _format_markdown_to_chat("") == ""
|
|
|
|
def test_bold_conversion(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
result = _format_markdown_to_chat("**hello**")
|
|
assert result == "*hello*"
|
|
|
|
def test_bold_and_italic_not_confused(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
result = _format_markdown_to_chat("**bold** *italic*")
|
|
assert result == "*bold* _italic_"
|
|
result2 = _format_markdown_to_chat("*italic* **bold**")
|
|
assert result2 == "_italic_ *bold*"
|
|
|
|
def test_italic_conversion(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
result = _format_markdown_to_chat("*hello*")
|
|
assert result == "_hello_"
|
|
|
|
def test_code_conversion(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
result = _format_markdown_to_chat("use `print()`")
|
|
assert result == "use `print()`"
|
|
|
|
def test_strikethrough_conversion(self):
|
|
from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat
|
|
|
|
result = _format_markdown_to_chat("~~deleted~~")
|
|
assert result == "~deleted~"
|
|
|
|
|
|
# ========== P0 tests: Capability fixes & session isolation ==========
|
|
|
|
|
|
class TestCapabilityFixes:
|
|
def test_edit_unsend_reactions_declared_false(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.capabilities.edit is False
|
|
assert adapter.capabilities.unsend is False
|
|
assert adapter.capabilities.reactions is False
|
|
|
|
def test_supports_chat_formatting(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.supports_chat_formatting is True
|
|
|
|
def test_block_streaming_remains_true(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.capabilities.block_streaming is True
|
|
assert adapter.capabilities.streaming_modes == ["block"]
|
|
|
|
def test_dm_scope_declared(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter.dm_scope == "per-account-channel-peer"
|
|
|
|
|
|
class TestSessionRouteFix:
|
|
def test_dm_route_uses_user_id(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_session_route
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="dm-channel-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
)
|
|
route = resolve_session_route(msg)
|
|
assert route == "agent:default:synologychat:default:direct:user-001"
|
|
|
|
def test_group_route_uses_chat_id(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_session_route
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="group-channel-001",
|
|
),
|
|
chat_type=ChatType.GROUP,
|
|
content="Hello",
|
|
)
|
|
route = resolve_session_route(msg)
|
|
assert route == "agent:default:synologychat:default:group:group-channel-001"
|
|
|
|
|
|
# ========== P0 tests: Rate limiter account isolation ==========
|
|
|
|
|
|
class TestRateLimiterAccountIsolation:
|
|
def test_account_specific_rate_key(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 10}}, account_id="acct-1")
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="chat-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
)
|
|
assert policy.check(msg) is True
|
|
assert policy.check_rate("user-001") is True
|
|
|
|
|
|
class TestRateLimiterRebuild:
|
|
def test_rebuild_rate_limiter(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 5}})
|
|
assert policy._rate_limiter._max_requests == 5
|
|
policy.rebuild_rate_limiter(max_per_minute=20, window_seconds=120)
|
|
assert policy._rate_limiter._max_requests == 20
|
|
assert policy._rate_limiter._window_seconds == 120
|
|
|
|
def test_rebuild_partial(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 10, "window_seconds": 60}})
|
|
policy.rebuild_rate_limiter(max_per_minute=15)
|
|
assert policy._rate_limiter._max_requests == 15
|
|
assert policy._rate_limiter._window_seconds == 60
|
|
|
|
|
|
# ========== P0 tests: Invalid token rate limiter ==========
|
|
|
|
|
|
class TestInvalidTokenRateLimiter:
|
|
def test_allow_under_limit(self):
|
|
from yuxi.channels.adapters.synologychat.webhook_handler import (
|
|
InvalidTokenRateLimiter,
|
|
clear_invalid_token_limiter_for_test,
|
|
)
|
|
|
|
clear_invalid_token_limiter_for_test()
|
|
limiter = InvalidTokenRateLimiter(max_attempts=3, window_seconds=60)
|
|
assert limiter.allow("192.168.1.1") is True
|
|
assert limiter.allow("192.168.1.1") is True
|
|
|
|
def test_record_failure_and_check(self):
|
|
from yuxi.channels.adapters.synologychat.webhook_handler import (
|
|
InvalidTokenRateLimiter,
|
|
clear_invalid_token_limiter_for_test,
|
|
)
|
|
|
|
clear_invalid_token_limiter_for_test()
|
|
limiter = InvalidTokenRateLimiter(max_attempts=2, window_seconds=60, block_seconds=1)
|
|
limiter.record_failure("10.0.0.1")
|
|
assert limiter.allow("10.0.0.1") is True
|
|
limiter.record_failure("10.0.0.1")
|
|
assert limiter.allow("10.0.0.1") is True
|
|
limiter.record_failure("10.0.0.1")
|
|
assert limiter.allow("10.0.0.1") is False
|
|
|
|
def test_clear(self):
|
|
from yuxi.channels.adapters.synologychat.webhook_handler import InvalidTokenRateLimiter
|
|
|
|
limiter = InvalidTokenRateLimiter(max_attempts=1, window_seconds=60, block_seconds=300)
|
|
limiter.record_failure("10.0.0.1")
|
|
limiter.record_failure("10.0.0.1")
|
|
assert limiter.allow("10.0.0.1") is False
|
|
limiter.clear("10.0.0.1")
|
|
assert limiter.allow("10.0.0.1") is True
|
|
|
|
|
|
# ========== P1 tests: Agent context injection ==========
|
|
|
|
|
|
class TestAgentContextInjection:
|
|
def test_chat_type_in_metadata(self):
|
|
event = make_dsm_event(channel_type="user")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.metadata["chat_type"] == "direct"
|
|
assert msg.metadata["command_authorized"] is True
|
|
|
|
def test_group_chat_type_in_metadata(self):
|
|
event = make_dsm_event(channel_type="channel")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.metadata["chat_type"] == "group"
|
|
|
|
|
|
# ========== P1 tests: Forwarded message recognition ==========
|
|
|
|
|
|
class TestForwardedMessage:
|
|
def test_forwarded_message_metadata(self):
|
|
event = make_dsm_event(text="check this")
|
|
event["forward"] = {"user_id": "forwarder-001", "username": "alice"}
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.metadata["is_forwarded"] is True
|
|
assert msg.metadata["forwarded_from"] == "forwarder-001"
|
|
|
|
def test_forwarded_in_message_field(self):
|
|
event = make_dsm_event(text="fw msg")
|
|
event["message"]["forward"] = {"username": "bob"}
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.metadata["is_forwarded"] is True
|
|
assert msg.metadata["forwarded_from"] == "bob"
|
|
|
|
def test_not_forwarded(self):
|
|
event = make_dsm_event(text="normal")
|
|
msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT)
|
|
assert msg.metadata.get("is_forwarded") is None
|
|
|
|
|
|
# ========== P1 tests: Pairing approved/denied messages ==========
|
|
|
|
|
|
class TestPairingMessages:
|
|
def test_approve_returns_message(self):
|
|
from yuxi.channels.adapters.synologychat.pairing import PairingManager
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "pairing", "allow_from": []}})
|
|
manager = PairingManager(policy)
|
|
manager.request_pairing("user-001")
|
|
result = manager.approve("user-001")
|
|
assert result["status"] == "approved"
|
|
assert "approved_message" in result
|
|
assert "access has been approved" in result["approved_message"]
|
|
|
|
def test_deny_returns_message(self):
|
|
from yuxi.channels.adapters.synologychat.pairing import PairingManager
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "pairing", "allow_from": []}})
|
|
manager = PairingManager(policy)
|
|
manager.request_pairing("user-002")
|
|
result = manager.deny("user-002")
|
|
assert result["status"] == "denied"
|
|
assert "denied_message" in result
|
|
assert "denied" in result["denied_message"]
|
|
|
|
|
|
# ========== P1 tests: Sent message cache TTL ==========
|
|
|
|
|
|
class TestSentMessageCache:
|
|
def test_cache_with_ttl(self):
|
|
mock_http = AsyncMock()
|
|
client = DSMClient(mock_http, "https://nas.local:5001", {}, None)
|
|
assert client._sent_message_ttl == 600
|
|
|
|
def test_get_nonexistent(self):
|
|
mock_http = AsyncMock()
|
|
client = DSMClient(mock_http, "https://nas.local:5001", {}, None)
|
|
assert client.get_sent_message_id("ch-1", "hello") is None
|
|
|
|
|
|
# ========== P2 tests: Dual ID space user matching ==========
|
|
|
|
|
|
class TestDualIdSpace:
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_user_id_by_username(self):
|
|
mock_http = AsyncMock()
|
|
client = DSMClient(mock_http, "https://nas.local:5001", {}, None)
|
|
client.user_list = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"data": {
|
|
"users": [
|
|
{"user_id": "101", "username": "alice"},
|
|
{"user_id": "202", "username": "bob"},
|
|
]
|
|
},
|
|
}
|
|
)
|
|
result = await client.resolve_user_id_by_username("alice")
|
|
assert result == "101"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_user_id_not_found(self):
|
|
mock_http = AsyncMock()
|
|
client = DSMClient(mock_http, "https://nas.local:5001", {}, None)
|
|
client.user_list = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"data": {"users": []},
|
|
}
|
|
)
|
|
result = await client.resolve_user_id_by_username("nobody")
|
|
assert result is None
|
|
|
|
|
|
# ========== P2 tests: clearBaseFields ==========
|
|
|
|
|
|
class TestClearBaseFields:
|
|
def test_named_account_clears_unset_fields(self):
|
|
from yuxi.channels.adapters.synologychat.accounts import resolve_account
|
|
|
|
config = {
|
|
"dsm_url": "https://base.local:5001",
|
|
"username": "base_user",
|
|
"password": "base_secret",
|
|
"password_file": "/run/secrets/base_pwd",
|
|
"accounts": {
|
|
"acct-a": {
|
|
"dsm_url": "https://acct-a.local:5001",
|
|
}
|
|
},
|
|
}
|
|
result = resolve_account(config, "acct-a")
|
|
assert result.dsm_url == "https://acct-a.local:5001"
|
|
assert result.username == ""
|
|
assert result.password == ""
|
|
|
|
def test_default_account_inherits_base(self):
|
|
from yuxi.channels.adapters.synologychat.accounts import resolve_account
|
|
|
|
config = {
|
|
"dsm_url": "https://base.local:5001",
|
|
"username": "base_user",
|
|
"password": "base_secret",
|
|
}
|
|
result = resolve_account(config, "default")
|
|
assert result.dsm_url == "https://base.local:5001"
|
|
assert result.username == "base_user"
|
|
|
|
|
|
# ========== P3 tests: Identity links ==========
|
|
|
|
|
|
class TestIdentityLinks:
|
|
def test_build_identity_link(self):
|
|
from yuxi.channels.adapters.synologychat.session import build_identity_link
|
|
|
|
link = build_identity_link("user-001", account_id="acct-1", username="alice", chat_id="chat-1")
|
|
assert link["channel"] == "synologychat"
|
|
assert link["user_id"] == "user-001"
|
|
assert link["account_id"] == "acct-1"
|
|
assert link["username"] == "alice"
|
|
|
|
def test_resolve_identity_links(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_identity_links
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="chat-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
metadata={"dsm_user_name": "alice"},
|
|
)
|
|
links = resolve_identity_links(msg)
|
|
assert len(links) == 1
|
|
assert links[0]["user_id"] == "user-001"
|
|
|
|
def test_resolve_with_forwarded(self):
|
|
from yuxi.channels.adapters.synologychat.session import resolve_identity_links
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="synologychat",
|
|
channel_type=ChannelType.SYNOLOGYCHAT,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="chat-001",
|
|
),
|
|
chat_type=ChatType.DIRECT,
|
|
content="Hello",
|
|
metadata={"forwarded_from": "forwarder-001"},
|
|
)
|
|
links = resolve_identity_links(msg)
|
|
assert len(links) == 2
|
|
assert links[1]["user_id"] == "forwarder-001"
|
|
|
|
|
|
# ========== P3 tests: Send message cache TTL ==========
|
|
|
|
|
|
class TestSetupWizardNamedAccount:
|
|
def test_named_account_path_prefix(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import build_setup_prompt
|
|
|
|
config = {"accounts": {"acct-1": {"dsm_url": "https://nas2.local:5001"}}}
|
|
result = build_setup_prompt(config, account_id="acct-1")
|
|
assert result["path_prefix"] == "accounts.acct-1."
|
|
assert result["is_named_account"] is True
|
|
assert result["steps"][0]["env"] is None
|
|
|
|
def test_default_account_env_enabled(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import build_setup_prompt
|
|
|
|
result = build_setup_prompt({})
|
|
assert result["path_prefix"] == ""
|
|
assert result["is_named_account"] is False
|
|
assert result["steps"][0]["env"] == "DSM_URL"
|
|
|
|
def test_named_account_instructions(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import get_setup_instructions
|
|
|
|
result = get_setup_instructions("acct-1")
|
|
assert "Named Account: acct-1" in result
|
|
assert "only available" in result
|
|
|
|
|
|
# ========== P0 tests: New config fields & validation ==========
|
|
|
|
|
|
class TestDangerousConfigFields:
|
|
def test_dangerously_allow_name_matching_default(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter._dangerously_allow_name_matching is False
|
|
|
|
def test_dangerously_allow_name_matching_enabled(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config(dangerously_allow_name_matching=True))
|
|
assert adapter._dangerously_allow_name_matching is True
|
|
issues = adapter.startup_validation()
|
|
codes = [i["code"] for i in issues]
|
|
assert "dangerous_name_matching" in codes
|
|
|
|
def test_dangerously_allow_inherited_webhook_path_default(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter._dangerously_allow_inherited_webhook_path is False
|
|
|
|
def test_webhook_path_source_default(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config())
|
|
assert adapter._webhook_path_source == "default"
|
|
|
|
|
|
class TestStartupValidationEnhanced:
|
|
def test_name_matching_warning(self):
|
|
adapter = SynologyChatAdapter(make_adapter_config(dangerously_allow_name_matching=True))
|
|
issues = adapter.startup_validation()
|
|
name_issues = [i for i in issues if i["code"] == "dangerous_name_matching"]
|
|
assert len(name_issues) == 1
|
|
assert name_issues[0]["severity"] == "warning"
|
|
|
|
def test_webhook_path_conflict_detection(self):
|
|
config = make_adapter_config(
|
|
connect_mode="webhook",
|
|
webhook_path="/webhook/synology",
|
|
accounts={
|
|
"acct-a": {"webhook_path": "/webhook/synology"},
|
|
"acct-b": {"webhook_path": "/webhook/synology"},
|
|
},
|
|
)
|
|
adapter = SynologyChatAdapter(config)
|
|
issues = adapter.startup_validation()
|
|
conflict_issues = [i for i in issues if i["code"] == "webhook_path_conflict"]
|
|
assert len(conflict_issues) >= 1
|
|
|
|
def test_distinct_webhook_paths_no_conflict(self):
|
|
config = make_adapter_config(
|
|
connect_mode="webhook",
|
|
webhook_path="/webhook/synology",
|
|
accounts={
|
|
"acct-a": {"webhook_path": "/webhook/synology/a"},
|
|
"acct-b": {"webhook_path": "/webhook/synology/b"},
|
|
},
|
|
)
|
|
adapter = SynologyChatAdapter(config)
|
|
issues = adapter.startup_validation()
|
|
conflict_issues = [i for i in issues if i["code"] == "webhook_path_conflict"]
|
|
assert len(conflict_issues) == 0
|
|
|
|
|
|
class TestPatchConfigAllowFromEnforcement:
|
|
def test_allow_from_forces_dm_allowlist(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
|
|
|
|
config = {"dm_policy": "open"}
|
|
result = patch_config({"security.allow_from": "user-001"}, config)
|
|
sec = result.get("security", {})
|
|
assert sec.get("dm_policy") == "allowlist"
|
|
assert "user-001" in sec.get("allow_from", [])
|
|
|
|
def test_allow_from_keeps_existing_policy(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
|
|
|
|
config = {"security": {"dm_policy": "pairing"}}
|
|
result = patch_config({"security.allow_from": "user-001"}, config)
|
|
sec = result.get("security", {})
|
|
assert sec.get("dm_policy") == "pairing"
|
|
|
|
def test_named_account_allow_from(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
|
|
|
|
config = {"accounts": {"acct-1": {"dm_policy": "open"}}}
|
|
result = patch_config({"accounts.acct-1.security.allow_from": "user-002"}, config, account_id="acct-1")
|
|
acct = result.get("accounts", {}).get("acct-1", {})
|
|
sec = acct.get("security", {})
|
|
assert sec.get("dm_policy") == "allowlist"
|
|
|
|
def test_empty_allow_from_no_change(self):
|
|
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
|
|
|
|
config = {"dm_policy": "open"}
|
|
result = patch_config({"security.allow_from": ""}, config)
|
|
sec = result.get("security", {})
|
|
assert sec.get("dm_policy", "open") == "open"
|
|
|
|
|
|
class TestSecurityAddAllowedUser:
|
|
def test_add_to_dm_allowlist(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "allowlist", "allow_from": []}})
|
|
assert policy.add_allowed_user("user-001") is True
|
|
assert policy.add_allowed_user("user-001") is False
|
|
|
|
def test_add_to_group_allowlist(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"group_policy": "allowlist", "group_allow_from": []}})
|
|
assert policy.add_allowed_user("user-003", scope="group") is True
|
|
|
|
def test_add_invalid_scope(self):
|
|
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "allowlist", "allow_from": []}})
|
|
assert policy.add_allowed_user("user-001", scope="invalid") is False
|
|
|
|
|
|
class TestSynologyNasHostEnvVar:
|
|
def test_nas_host_env_var(self):
|
|
import os
|
|
from yuxi.channels.adapters.synologychat.auth import apply_env_defaults
|
|
|
|
os.environ["SYNOLOGY_NAS_HOST"] = "https://nas-from-env.local:5001"
|
|
try:
|
|
config = apply_env_defaults({})
|
|
assert config["dsm_url"] == "https://nas-from-env.local:5001"
|
|
finally:
|
|
del os.environ["SYNOLOGY_NAS_HOST"]
|
|
|
|
def test_nas_host_does_not_override_dsm_url(self):
|
|
import os
|
|
from yuxi.channels.adapters.synologychat.auth import apply_env_defaults
|
|
|
|
os.environ["SYNOLOGY_NAS_HOST"] = "https://nas-env.local:5001"
|
|
try:
|
|
config = apply_env_defaults({"dsm_url": "https://explicit.local:5001"})
|
|
assert config["dsm_url"] == "https://explicit.local:5001"
|
|
finally:
|
|
del os.environ["SYNOLOGY_NAS_HOST"]
|
|
|
|
|
|
class TestInitExports:
|
|
def test_clear_invalid_token_limiter_exported(self):
|
|
from yuxi.channels.adapters.synologychat import clear_invalid_token_limiter_for_test
|
|
|
|
assert callable(clear_invalid_token_limiter_for_test)
|
|
clear_invalid_token_limiter_for_test()
|
|
|
|
def test_patch_config_exported(self):
|
|
from yuxi.channels.adapters.synologychat import patch_config
|
|
|
|
result = patch_config({"name": "test-channel"}, {})
|
|
assert result["name"] == "test-channel"
|
|
|
|
def test_security_policy_exported(self):
|
|
from yuxi.channels.adapters.synologychat import SynologyChatSecurityPolicy
|
|
|
|
policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "open"}})
|
|
assert policy.dm_policy == "open"
|