新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
1176 lines
42 KiB
Python
1176 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.urbit.format import (
|
|
build_channel_path,
|
|
build_chat_action_payload,
|
|
build_dm_channel_id,
|
|
build_group_channel_id,
|
|
build_media_content,
|
|
build_poke_payload,
|
|
ensure_ship_tilde,
|
|
extract_graph_attachments,
|
|
extract_graph_mentions,
|
|
is_valid_ship,
|
|
parse_graph_update,
|
|
strip_ship_tilde,
|
|
)
|
|
from yuxi.channels.adapters.urbit.monitor import SSEDeduplicator, _graph_update_to_channel_message
|
|
from yuxi.channels.adapters.urbit.rate_limiter import RateLimiter
|
|
from yuxi.channels.adapters.urbit.session import resolve_session_route, resolve_thread_key
|
|
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChannelType, ChatType
|
|
|
|
|
|
class TestShipFormat:
|
|
def test_is_valid_ship_valid(self):
|
|
assert is_valid_ship("~zod")
|
|
assert is_valid_ship("~sampel-palnet")
|
|
assert is_valid_ship("~marzod")
|
|
|
|
def test_is_valid_ship_invalid(self):
|
|
assert not is_valid_ship("zod")
|
|
assert not is_valid_ship("~12")
|
|
assert not is_valid_ship("~")
|
|
assert not is_valid_ship("")
|
|
|
|
def test_strip_ship_tilde(self):
|
|
assert strip_ship_tilde("~zod") == "zod"
|
|
assert strip_ship_tilde("zod") == "zod"
|
|
assert strip_ship_tilde("~~marzod") == "marzod"
|
|
|
|
def test_ensure_ship_tilde(self):
|
|
assert ensure_ship_tilde("~zod") == "~zod"
|
|
assert ensure_ship_tilde("zod") == "~zod"
|
|
|
|
def test_build_dm_channel_id(self):
|
|
result = build_dm_channel_id("~zod", "~marzod")
|
|
assert result == "~zod/dm--marzod"
|
|
|
|
def test_build_group_channel_id(self):
|
|
result = build_group_channel_id("~zod", "my-group")
|
|
assert result == "~zod/my-group/chat"
|
|
|
|
|
|
class TestChannelPath:
|
|
def test_build_channel_path_direct(self):
|
|
path = build_channel_path(chat_type="direct", target_ship="~zod")
|
|
assert path == "/dm/~zod"
|
|
|
|
def test_build_channel_path_group(self):
|
|
path = build_channel_path(chat_type="group", group_name="my-group")
|
|
assert path == "/chat/my-group"
|
|
|
|
def test_build_channel_path_group_with_type(self):
|
|
path = build_channel_path(chat_type="group", group_name="my-group", channel_type="heap")
|
|
assert path == "/heap/my-group"
|
|
|
|
|
|
class TestParseGraphUpdate:
|
|
def test_parse_text_message(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"resource": {"path": "/~zod/my-group/chat/1", "type": "chat"},
|
|
"ship": "zod",
|
|
"index": "/1700000000",
|
|
"time": 1700000000,
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"text": "hello world"}]}}
|
|
},
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert result["content"] == "hello world"
|
|
assert result["ship"] == "zod"
|
|
assert result["resource_path"] == "/~zod/my-group/chat/1"
|
|
|
|
def test_parse_with_mention(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"mention": "marzod"}]}}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "~marzod" in result["content"]
|
|
|
|
def test_parse_with_url(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"url": "https://example.com"}]}}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "https://example.com" in result["content"]
|
|
|
|
def test_parse_with_code_block(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"contents": [
|
|
{"code": {"lang": "python", "code": "print('hi')"}}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "```python" in result["content"]
|
|
assert "print('hi')" in result["content"]
|
|
|
|
def test_parse_with_reaction(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"reaction": {"react": "+1", "ship": "marzod", "msg": "/1700000000"}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert result["reaction"] == {"react": "+1", "ship": "marzod", "msg": "/1700000000"}
|
|
|
|
def test_parse_empty(self):
|
|
assert parse_graph_update({}) == {}
|
|
|
|
def test_parse_graph_update_not_dict(self):
|
|
assert parse_graph_update({"graph-update": "invalid"}) == {}
|
|
assert parse_graph_update({"graph-update": 42}) == {}
|
|
assert parse_graph_update({"graph-update": []}) == {}
|
|
|
|
def test_extract_graph_mentions(self):
|
|
additions = {
|
|
"1": {"post": {"contents": [{"mention": "zod"}, {"text": "hello"}, {"mention": "marzod"}]}}
|
|
}
|
|
mentions = extract_graph_mentions(additions)
|
|
assert mentions == ["zod", "marzod"]
|
|
|
|
def test_extract_graph_mentions_empty(self):
|
|
assert extract_graph_mentions({}) == []
|
|
|
|
|
|
class TestBuildPayload:
|
|
def test_build_poke_payload_basic(self):
|
|
payload = build_poke_payload(
|
|
host_ship="zod",
|
|
content="hello",
|
|
channel_path="/chat/my-group",
|
|
)
|
|
assert payload["action"] == "poke"
|
|
assert payload["ship"] == "zod"
|
|
assert payload["app"] == "chat"
|
|
assert payload["mark"] == "chat-message"
|
|
assert payload["json"]["path"] == "/chat/my-group"
|
|
assert payload["json"]["envelope"]["content"] == [{"text": "hello"}]
|
|
assert payload["json"]["envelope"]["author"] == "~zod"
|
|
|
|
def test_build_poke_payload_with_continuation(self):
|
|
payload = build_poke_payload(
|
|
host_ship="zod",
|
|
content="part2",
|
|
channel_path="/chat/my-group",
|
|
continuation=True,
|
|
)
|
|
assert payload["json"]["envelope"]["continuation"] is True
|
|
|
|
def test_build_poke_payload_with_reply_to(self):
|
|
payload = build_poke_payload(
|
|
host_ship="zod",
|
|
content="reply",
|
|
channel_path="/chat/my-group",
|
|
reply_to="/1700000000",
|
|
)
|
|
assert payload["json"]["envelope"]["reply_to"] == "/1700000000"
|
|
|
|
def test_build_poke_payload_with_media(self):
|
|
payload = build_poke_payload(
|
|
host_ship="zod",
|
|
content="",
|
|
channel_path="/chat/my-group",
|
|
media_content=[{"image": {"src": "https://img.com/a.png"}}],
|
|
)
|
|
assert payload["json"]["envelope"]["content"] == [{"image": {"src": "https://img.com/a.png"}}]
|
|
|
|
def test_build_poke_payload_content_too_long(self):
|
|
long_content = "x" * 100_001
|
|
with pytest.raises(ValueError, match="exceeds maximum"):
|
|
build_poke_payload(
|
|
host_ship="zod",
|
|
content=long_content,
|
|
channel_path="/chat/my-group",
|
|
)
|
|
|
|
def test_build_chat_action_payload_add_react(self):
|
|
payload = build_chat_action_payload(
|
|
host_ship="zod",
|
|
channel_path="/chat/my-group",
|
|
action="add-react",
|
|
msg_id="/1700000000",
|
|
emoji="+1",
|
|
)
|
|
assert payload["mark"] == "chat-action"
|
|
assert payload["json"]["action"] == {"add-react": {"react": "+1", "msg": "/1700000000"}}
|
|
|
|
def test_build_chat_action_payload_edit(self):
|
|
payload = build_chat_action_payload(
|
|
host_ship="zod",
|
|
channel_path="/chat/my-group",
|
|
action="edit",
|
|
msg_id="/1700000000",
|
|
content="edited text",
|
|
)
|
|
assert payload["json"]["action"]["edit"]["content"] == [{"text": "edited text"}]
|
|
|
|
def test_build_chat_action_payload_del(self):
|
|
payload = build_chat_action_payload(
|
|
host_ship="zod",
|
|
channel_path="/chat/my-group",
|
|
action="del",
|
|
msg_id="/1700000000",
|
|
)
|
|
assert payload["json"]["action"] == {"del": {"msg": "/1700000000"}}
|
|
|
|
def test_build_chat_action_payload_unknown_action(self):
|
|
with pytest.raises(ValueError, match="Unknown chat action"):
|
|
build_chat_action_payload(
|
|
host_ship="zod",
|
|
channel_path="/chat/my-group",
|
|
action="unknown",
|
|
msg_id="/1700000000",
|
|
)
|
|
|
|
|
|
class TestBuildMediaContent:
|
|
def test_image(self):
|
|
result = build_media_content("image", "https://img.com/a.png")
|
|
assert result[0]["image"]["src"] == "https://img.com/a.png"
|
|
|
|
def test_url(self):
|
|
result = build_media_content("url", "https://example.com")
|
|
assert result == [{"url": "https://example.com"}]
|
|
|
|
def test_default(self):
|
|
result = build_media_content("unknown", "hello")
|
|
assert result == [{"text": "hello"}]
|
|
|
|
|
|
class TestSSEDeduplicator:
|
|
@pytest.mark.asyncio
|
|
async def test_first_not_duplicate(self):
|
|
dedup = SSEDeduplicator()
|
|
assert not await dedup.is_duplicate("index-1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detect_duplicate(self):
|
|
dedup = SSEDeduplicator()
|
|
assert not await dedup.is_duplicate("index-1")
|
|
assert await dedup.is_duplicate("index-1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_indices(self):
|
|
dedup = SSEDeduplicator()
|
|
assert not await dedup.is_duplicate("index-1")
|
|
assert not await dedup.is_duplicate("index-2")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eviction(self):
|
|
dedup = SSEDeduplicator(max_size=3)
|
|
for i in range(5):
|
|
await dedup.is_duplicate(f"index-{i}")
|
|
assert not await dedup.is_duplicate("index-0")
|
|
|
|
|
|
class TestGraphUpdateToChannelMessage:
|
|
def test_basic_group_message(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"resource": {"path": "/~zod/my-group/chat/1", "type": "chat"},
|
|
"ship": "marzod",
|
|
"index": "/1700000000",
|
|
"time": 1700000000.0,
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"text": "hello"}]}}
|
|
},
|
|
}
|
|
}
|
|
msg = _graph_update_to_channel_message(raw, "zod")
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.content == "hello"
|
|
assert msg.identity.channel_chat_id == "/~zod/my-group/chat/1"
|
|
assert msg.identity.channel_user_id == "~marzod"
|
|
assert msg.identity.channel_type == ChannelType.URBIT
|
|
assert msg.metadata["chat_type"] == "group"
|
|
|
|
def test_direct_message(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"resource": {"path": "/~zod/dm--marzod", "type": "dm"},
|
|
"ship": "marzod",
|
|
"index": "/1700000000",
|
|
"time": 1700000000.0,
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"text": "hi"}]}}
|
|
},
|
|
}
|
|
}
|
|
msg = _graph_update_to_channel_message(raw, "zod")
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.metadata["chat_type"] == "direct"
|
|
|
|
def test_with_mentions(self):
|
|
raw = {
|
|
"graph-update": {
|
|
"resource": {"path": "/~zod/my-group/chat/1", "type": "chat"},
|
|
"ship": "marzod",
|
|
"index": "/1700000000",
|
|
"time": 1700000000.0,
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"mention": "zod"}]}}
|
|
},
|
|
}
|
|
}
|
|
msg = _graph_update_to_channel_message(raw, "zod")
|
|
assert msg.mentions is not None
|
|
assert "urbit:zod" in msg.mentions.mentioned_user_ids
|
|
|
|
|
|
class TestSession:
|
|
def test_resolve_thread_key_direct(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~marzod",
|
|
channel_chat_id="dm--zod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
result = resolve_thread_key(msg)
|
|
assert "direct:dm--zod" in result
|
|
|
|
def test_resolve_thread_key_group(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~marzod",
|
|
channel_chat_id="/~zod/my-group/chat/1",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "group", "group_name": "my-group", "urbit_resource_type": "chat"},
|
|
)
|
|
result = resolve_thread_key(msg)
|
|
assert "group:my-group:chat" in result
|
|
|
|
def test_resolve_session_route(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~marzod",
|
|
channel_chat_id="dm--zod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
result = resolve_session_route(msg)
|
|
assert result
|
|
|
|
|
|
class TestUrbitClient:
|
|
def test_init(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080", ship_name="~zod")
|
|
assert client.ship_url == "http://localhost:8080"
|
|
assert client.ship_name == "zod"
|
|
assert client.auth_cookie_name == "urbauth-~zod"
|
|
|
|
def test_cookies_none_when_no_session(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080", ship_name="~zod")
|
|
assert client._cookies() is None
|
|
|
|
def test_cookies_with_session(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080", ship_name="~zod")
|
|
client.set_session_cookie("test-cookie")
|
|
assert client._cookies() == {"urbauth-~zod": "test-cookie"}
|
|
|
|
def test_http_access_before_start(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080", ship_name="~zod")
|
|
with pytest.raises(RuntimeError, match="not started"):
|
|
_ = client.http
|
|
|
|
|
|
class TestRateLimiter:
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_returns_true_when_tokens_available(self):
|
|
limiter = RateLimiter(limit=10, window=60.0)
|
|
assert await limiter.acquire()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_exhausts_tokens(self):
|
|
limiter = RateLimiter(limit=3, window=60.0)
|
|
for _ in range(3):
|
|
assert await limiter.acquire()
|
|
assert not await limiter.acquire()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_refills_over_time(self):
|
|
limiter = RateLimiter(limit=100, window=0.01)
|
|
await limiter.acquire()
|
|
assert await limiter.acquire()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_available_tokens(self):
|
|
limiter = RateLimiter(limit=5, window=60.0)
|
|
await limiter.acquire()
|
|
assert limiter.available_tokens == 4.0
|
|
|
|
|
|
class TestExtractAttachments:
|
|
def test_extract_image(self):
|
|
additions = {
|
|
"1": {"post": {"contents": [{"image": {"src": "https://img.com/a.png"}}]}}
|
|
}
|
|
result = extract_graph_attachments(additions)
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "image"
|
|
assert result[0]["url"] == "https://img.com/a.png"
|
|
|
|
def test_extract_multiple(self):
|
|
additions = {
|
|
"1": {"post": {"contents": [
|
|
{"image": {"src": "https://img.com/a.png"}},
|
|
{"image": {"src": "https://img.com/b.png"}},
|
|
]}}
|
|
}
|
|
result = extract_graph_attachments(additions)
|
|
assert len(result) == 2
|
|
|
|
def test_extract_empty(self):
|
|
assert extract_graph_attachments({}) == []
|
|
|
|
def test_extract_text_only(self):
|
|
additions = {"1": {"post": {"contents": [{"text": "hello"}]}}}
|
|
assert extract_graph_attachments(additions) == []
|
|
|
|
|
|
class TestPreConnect:
|
|
def test_pre_connect_valid_config(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
|
|
from yuxi.channels.models import ChannelStatus
|
|
|
|
config = {
|
|
"ship_url": "http://localhost:8080",
|
|
"ship_name": "~zod",
|
|
"ship_code": "test-code",
|
|
}
|
|
adapter = UrbitAdapter(config=config)
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
|
|
result = asyncio.run(adapter.pre_connect())
|
|
assert result["ship_url"] == "http://localhost:8080"
|
|
assert result["ship_name"] == "zod"
|
|
|
|
def test_pre_connect_missing_ship_url(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
|
|
from yuxi.channels.exceptions import ChannelAuthenticationError
|
|
from yuxi.channels.models import ChannelStatus
|
|
|
|
config = {"ship_name": "~zod", "ship_code": "test"}
|
|
adapter = UrbitAdapter(config=config)
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
|
|
with pytest.raises(ChannelAuthenticationError, match="ship_url"):
|
|
asyncio.run(adapter.pre_connect())
|
|
|
|
def test_pre_connect_missing_ship_name(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
|
|
from yuxi.channels.exceptions import ChannelAuthenticationError
|
|
from yuxi.channels.models import ChannelStatus
|
|
|
|
config = {"ship_url": "http://localhost:8080", "ship_code": "test"}
|
|
adapter = UrbitAdapter(config=config)
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
|
|
with pytest.raises(ChannelAuthenticationError, match="ship_name"):
|
|
asyncio.run(adapter.pre_connect())
|
|
|
|
def test_pre_connect_missing_ship_code(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
|
|
from yuxi.channels.exceptions import ChannelAuthenticationError
|
|
from yuxi.channels.models import ChannelStatus
|
|
|
|
config = {"ship_url": "http://localhost:8080", "ship_name": "~zod"}
|
|
adapter = UrbitAdapter(config=config)
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
|
|
with pytest.raises(ChannelAuthenticationError, match="ship_code"):
|
|
asyncio.run(adapter.pre_connect())
|
|
|
|
|
|
class TestApprovalBlockCommand:
|
|
def test_block_ship(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
assert approval.block_ship("~dev") is True
|
|
assert approval.is_ship_blocked("~dev") is True
|
|
assert approval.is_ship_blocked("dev") is True
|
|
|
|
def test_unblock_ship(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.block_ship("~dev")
|
|
assert approval.unblock_ship("~dev") is True
|
|
assert approval.is_ship_blocked("~dev") is False
|
|
|
|
def test_unblock_nonexistent(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
assert approval.unblock_ship("~nonexistent") is False
|
|
|
|
def test_get_blocked_ships(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.block_ship("~dev")
|
|
approval.block_ship("~marzod")
|
|
blocked = approval.get_blocked_ships()
|
|
assert "dev" in blocked
|
|
assert "marzod" in blocked
|
|
|
|
def test_handle_block_command(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval._pending["req-1"] = _make_pending_approval("req-1", "dm", "~dev")
|
|
result = approval.handle_admin_command("block req-1", "zod")
|
|
assert result is not None
|
|
assert "Blocked" in result
|
|
assert approval.is_ship_blocked("~dev") is True
|
|
|
|
def test_handle_unblock_command(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.block_ship("~dev")
|
|
result = approval.handle_admin_command("unblock ~dev", "zod")
|
|
assert result is not None
|
|
assert "Unblocked" in result
|
|
|
|
def test_handle_blocked_command(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.block_ship("~dev")
|
|
result = approval.handle_admin_command("blocked", "zod")
|
|
assert result is not None
|
|
assert "dev" in result
|
|
|
|
def test_handle_pending_command(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval._pending["req-1"] = _make_pending_approval("req-1", "dm", "~dev")
|
|
result = approval.handle_admin_command("pending", "zod")
|
|
assert result is not None
|
|
assert "req-1" in result
|
|
|
|
def test_non_owner_cannot_block(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval._pending["req-1"] = _make_pending_approval("req-1", "dm", "~dev")
|
|
result = approval.handle_admin_command("block req-1", "marzod")
|
|
assert result is None
|
|
|
|
def test_has_duplicate_pending(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval._pending["req-1"] = _make_pending_approval("req-1", "dm", "~dev", "chat-1")
|
|
assert approval.has_duplicate_pending("dm", "~dev", "chat-1") is True
|
|
assert approval.has_duplicate_pending("group", "~dev", "chat-1") is False
|
|
assert approval.has_duplicate_pending("dm", "~other", "chat-1") is False
|
|
|
|
|
|
class TestStoryRuleFormat:
|
|
def test_rule_output_is_block_rule_format(self):
|
|
from yuxi.channels.adapters.urbit.story import markdown_to_story
|
|
|
|
result = markdown_to_story("text\n---\nmore text")
|
|
found = False
|
|
for item in result:
|
|
if isinstance(item, dict) and "block" in item:
|
|
if item["block"].get("rule") is None:
|
|
found = True
|
|
assert found, "Rule separator should produce {block: {rule: null}} format"
|
|
|
|
|
|
class TestTargetFormat:
|
|
def test_parse_tlon_dm_target(self):
|
|
from yuxi.channels.adapters.urbit.targets import parse_tlon_target
|
|
|
|
result = parse_tlon_target("tlon:dm/~zod")
|
|
assert result["chat_type"] == "direct"
|
|
assert result["peer_id"] == "~zod"
|
|
|
|
def test_parse_group_chat_target(self):
|
|
from yuxi.channels.adapters.urbit.targets import parse_tlon_target
|
|
|
|
result = parse_tlon_target("group:chat/~zod/test-group")
|
|
assert result["chat_type"] == "group"
|
|
assert result["channel_type"] == "chat"
|
|
|
|
def test_parse_group_target(self):
|
|
from yuxi.channels.adapters.urbit.targets import parse_tlon_target
|
|
|
|
result = parse_tlon_target("group:~zod/test-group")
|
|
assert result["chat_type"] == "group"
|
|
|
|
def test_format_target_hint_direct(self):
|
|
from yuxi.channels.adapters.urbit.targets import format_target_hint
|
|
|
|
hint = format_target_hint("direct", "~zod", "~host")
|
|
assert "dm/~zod" in hint
|
|
|
|
def test_format_target_hint_group(self):
|
|
from yuxi.channels.adapters.urbit.targets import format_target_hint
|
|
|
|
hint = format_target_hint("group", "test-group", "~host")
|
|
assert "~host/test-group" in hint
|
|
|
|
|
|
class TestSSRFValidation:
|
|
def test_validate_local_url_blocked(self):
|
|
from yuxi.channels.adapters.urbit.probe import validate_urbit_url
|
|
|
|
is_valid, warnings = validate_urbit_url("http://127.0.0.1/test")
|
|
assert is_valid is False
|
|
assert len(warnings) > 0
|
|
|
|
def test_validate_local_url_allowed_when_allow_private(self):
|
|
from yuxi.channels.adapters.urbit.probe import validate_urbit_url
|
|
|
|
is_valid, _ = validate_urbit_url("http://127.0.0.1/test", allow_private=True)
|
|
assert is_valid is True
|
|
|
|
def test_ssrf_policy_default(self):
|
|
from yuxi.channels.adapters.urbit.probe import ssrf_policy_from_dangerously_allow_private_network
|
|
|
|
policy = ssrf_policy_from_dangerously_allow_private_network()
|
|
assert policy["allowPrivate"] is False
|
|
assert policy["dangerouslyAllowPrivateNetwork"] is False
|
|
|
|
def test_ssrf_policy_dangerous(self):
|
|
from yuxi.channels.adapters.urbit.probe import ssrf_policy_from_dangerously_allow_private_network
|
|
|
|
policy = ssrf_policy_from_dangerously_allow_private_network(dangerously_allow_private_network=True)
|
|
assert policy["allowPrivate"] is True
|
|
|
|
|
|
class TestDoctorMigrations:
|
|
def test_detect_legacy_allow_private_network(self):
|
|
from yuxi.channels.adapters.urbit.doctor import is_legacy_private_network_config
|
|
|
|
assert is_legacy_private_network_config({"allowPrivateNetwork": True}) is True
|
|
assert is_legacy_private_network_config({"other": True}) is False
|
|
|
|
def test_create_migrations(self):
|
|
from yuxi.channels.adapters.urbit.doctor import create_legacy_private_network_doctor_contract
|
|
|
|
migrations = create_legacy_private_network_doctor_contract({"allowPrivateNetwork": True})
|
|
assert len(migrations) == 1
|
|
assert migrations[0]["from"] == "allowPrivateNetwork"
|
|
assert migrations[0]["to"] == "network.dangerouslyAllowPrivateNetwork"
|
|
|
|
def test_apply_migrations(self):
|
|
from yuxi.channels.adapters.urbit.doctor import create_legacy_private_network_doctor_contract, apply_doctor_migrations
|
|
|
|
config = {"allowPrivateNetwork": True}
|
|
migrations = create_legacy_private_network_doctor_contract(config)
|
|
result = apply_doctor_migrations(config, migrations)
|
|
assert "allowPrivateNetwork" not in result
|
|
assert result["network"]["dangerously_allow_private_network"] is True
|
|
|
|
|
|
def _make_pending_approval(
|
|
aid: str, atype: str, ship: str, channel: str = "chat-1"
|
|
):
|
|
from yuxi.channels.adapters.urbit.approval import PendingApproval
|
|
|
|
return PendingApproval(
|
|
id=aid,
|
|
approval_type=atype,
|
|
requester_ship=ship,
|
|
channel_id=channel,
|
|
message_content="test message",
|
|
created_at=1700000000.0,
|
|
status="pending",
|
|
)
|
|
|
|
|
|
class TestSanitizeFilename:
|
|
def test_sanitize_normal(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
assert sanitize_filename("image.png") == "image.png"
|
|
|
|
def test_sanitize_path_traversal(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
result = sanitize_filename("../../../etc/passwd")
|
|
assert ".." not in result
|
|
assert result == "passwd"
|
|
|
|
def test_sanitize_windows_path(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
result = sanitize_filename("C:\\Windows\\System32\\cmd.exe")
|
|
assert "\\" not in result
|
|
assert ":" not in result
|
|
|
|
def test_sanitize_special_chars(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
result = sanitize_filename("file:name*.txt")
|
|
assert ":" not in result
|
|
assert "*" not in result
|
|
assert result == "file_name_.txt"
|
|
|
|
def test_sanitize_all_special_returns_hash(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
result = sanitize_filename("...")
|
|
assert result.startswith("file_")
|
|
assert len(result) > len("file_")
|
|
|
|
def test_sanitize_long_name(self):
|
|
from yuxi.channels.adapters.urbit.upload import sanitize_filename
|
|
|
|
long_name = "a" * 300 + ".txt"
|
|
result = sanitize_filename(long_name)
|
|
assert len(result) <= 255
|
|
assert result.endswith(".txt")
|
|
|
|
|
|
class TestMediaExtensions:
|
|
def test_heic_extension(self):
|
|
from yuxi.channels.adapters.urbit.media import get_media_extension
|
|
|
|
assert get_media_extension("image/heic") == ".heic"
|
|
|
|
def test_heif_extension(self):
|
|
from yuxi.channels.adapters.urbit.media import get_media_extension
|
|
|
|
assert get_media_extension("image/heif") == ".heif"
|
|
|
|
def test_unknown_extension(self):
|
|
from yuxi.channels.adapters.urbit.media import get_media_extension
|
|
|
|
assert get_media_extension("application/octet-stream") == ".bin"
|
|
|
|
|
|
class TestConfigUIHints:
|
|
def test_get_hints_returns_list(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints
|
|
|
|
hints = get_config_ui_hints()
|
|
assert isinstance(hints, list)
|
|
assert len(hints) >= 30
|
|
|
|
def test_every_hint_has_key_and_label(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints
|
|
|
|
for hint in get_config_ui_hints():
|
|
assert "key" in hint
|
|
assert "label" in hint
|
|
assert "type" in hint
|
|
|
|
def test_required_fields_exist(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints
|
|
|
|
hints = get_config_ui_hints()
|
|
required_keys = {h["key"] for h in hints if h.get("required")}
|
|
assert "ship_url" in required_keys
|
|
assert "ship_name" in required_keys
|
|
assert "ship_code" in required_keys
|
|
|
|
def test_get_hints_by_category(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import get_hints_by_category
|
|
|
|
grouped = get_hints_by_category()
|
|
assert isinstance(grouped, dict)
|
|
assert "connection" in grouped
|
|
assert len(grouped["connection"]) >= 3
|
|
|
|
def test_get_categories(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import get_categories
|
|
|
|
categories = get_categories()
|
|
assert "connection" in categories
|
|
assert "authorization" in categories
|
|
assert "performance" in categories
|
|
|
|
def test_count_hints(self):
|
|
from yuxi.channels.adapters.urbit.config_ui_hints import count_hints
|
|
|
|
assert count_hints() >= 30
|
|
|
|
|
|
class TestDualPlugin:
|
|
def test_setup_plugin_validate(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({})
|
|
errors = plugin.validate_setup()
|
|
assert any("ship_url" in e for e in errors)
|
|
assert any("ship_name" in e for e in errors)
|
|
assert any("ship_code" in e for e in errors)
|
|
|
|
def test_setup_plugin_valid(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({
|
|
"ship_url": "http://localhost:8080",
|
|
"ship_name": "zod",
|
|
"ship_code": "XXXX-XXXX-XXXX-XXXX",
|
|
})
|
|
errors = plugin.validate_setup()
|
|
assert len(errors) == 0
|
|
|
|
def test_setup_plugin_invalid_policy(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({
|
|
"ship_url": "http://localhost:8080",
|
|
"ship_name": "zod",
|
|
"ship_code": "XXXX",
|
|
"dm_policy": "invalid",
|
|
})
|
|
errors = plugin.validate_setup()
|
|
assert any("dm_policy" in e for e in errors)
|
|
|
|
def test_detect_legacy_migrations(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({"allowPrivateNetwork": True})
|
|
migrations = plugin.detect_legacy_state_migrations()
|
|
assert len(migrations) == 1
|
|
assert plugin.has_legacy_migrations is True
|
|
|
|
def test_get_setup_schema(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({})
|
|
schema = plugin.get_setup_schema()
|
|
assert schema["name"] == "urbit"
|
|
assert len(schema["required_fields"]) == 3
|
|
|
|
def test_get_config_ui_hints(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import UrbitSetupPlugin
|
|
|
|
plugin = UrbitSetupPlugin({})
|
|
hints = plugin.get_config_ui_hints()
|
|
assert len(hints) >= 30
|
|
|
|
def test_registry_register_and_get(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import DualPluginRegistry, UrbitSetupPlugin
|
|
|
|
registry = DualPluginRegistry()
|
|
setup = UrbitSetupPlugin({"ship_url": "http://localhost:8080"})
|
|
registry.register_setup_plugin(setup)
|
|
assert registry.get_setup_plugin() is setup
|
|
|
|
def test_registry_clear(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import DualPluginRegistry, UrbitSetupPlugin
|
|
|
|
registry = DualPluginRegistry()
|
|
registry.register_setup_plugin(UrbitSetupPlugin({}))
|
|
registry.clear()
|
|
assert registry.get_setup_plugin() is None
|
|
|
|
def test_global_registry(self):
|
|
from yuxi.channels.adapters.urbit.dual_plugin import get_global_registry, DualPluginRegistry
|
|
|
|
registry = get_global_registry()
|
|
assert isinstance(registry, DualPluginRegistry)
|
|
|
|
|
|
class TestMultiAccount:
|
|
def test_account_manager_has_multi(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager([
|
|
{"ship": "zod", "url": "http://localhost", "code": "code1"},
|
|
{"ship": "marzod", "url": "http://localhost", "code": "code2"},
|
|
])
|
|
assert mgr.has_multi_accounts() is True
|
|
assert mgr.count == 2
|
|
|
|
def test_account_manager_single(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager([
|
|
{"ship": "zod", "url": "http://localhost", "code": "code"},
|
|
])
|
|
assert mgr.has_multi_accounts() is False
|
|
|
|
def test_multi_account_connection_manager_init(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager, MultiAccountConnectionManager
|
|
|
|
mgr = AccountManager()
|
|
conn_mgr = MultiAccountConnectionManager(mgr, lambda a: a)
|
|
assert not conn_mgr.is_running
|
|
assert len(conn_mgr.connections) == 0
|
|
|
|
def test_multi_account_get_nonexistent(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager, MultiAccountConnectionManager
|
|
|
|
mgr = AccountManager()
|
|
conn_mgr = MultiAccountConnectionManager(mgr, lambda a: a)
|
|
assert conn_mgr.get_connection("nonexistent") is None
|
|
|
|
|
|
class TestSettingsStoreJSONBoundary:
|
|
def _make_client_mock(self):
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
async def get(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {"moltbot": {}}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
async def put(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
return MockClient()
|
|
|
|
def test_parse_channel_rules_json_string(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_channel_rules
|
|
|
|
result = parse_channel_rules('{"chat/my-group": {"allow": ["zod"]}}')
|
|
assert isinstance(result, dict)
|
|
assert "chat/my-group" in result
|
|
|
|
def test_parse_channel_rules_dict(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_channel_rules
|
|
|
|
data = {"chat/my-group": {"allow": ["zod"]}}
|
|
result = parse_channel_rules(data)
|
|
assert result == data
|
|
|
|
def test_parse_channel_rules_none(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_channel_rules
|
|
|
|
assert parse_channel_rules(None) is None
|
|
|
|
def test_parse_channel_rules_invalid_json(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_channel_rules
|
|
|
|
result = parse_channel_rules("{invalid")
|
|
assert result is None
|
|
|
|
def test_parse_pending_approvals(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_pending_approvals
|
|
|
|
result = parse_pending_approvals('[{"id": "a1", "status": "pending"}]')
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
|
|
def test_parse_pending_approvals_none(self):
|
|
from yuxi.channels.adapters.urbit.settings import parse_pending_approvals
|
|
|
|
assert parse_pending_approvals(None) is None
|
|
|
|
def test_serialize_to_json(self):
|
|
from yuxi.channels.adapters.urbit.settings import serialize_to_json_value
|
|
|
|
result = serialize_to_json_value({"key": "val"})
|
|
assert isinstance(result, str)
|
|
assert '"key"' in result
|
|
|
|
|
|
class TestSummarizer:
|
|
def test_trigger_detection_summarize(self):
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
result = summ.is_summarization_request("summarize this chat")
|
|
assert result is True
|
|
|
|
def test_trigger_detection_tldr(self):
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
result = summ.is_summarization_request("tldr")
|
|
assert result is True
|
|
|
|
def test_trigger_detection_recap(self):
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
result = summ.is_summarization_request("what did i miss")
|
|
assert result is True
|
|
|
|
def test_trigger_no_summary(self):
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
result = summ.is_summarization_request("hello world")
|
|
assert result is False
|
|
|
|
def test_auto_detect(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
asyncio.run(cache.cache_message("m1", "chat-1", "hello", "zod"))
|
|
|
|
summ = Summarizer(cache)
|
|
info = summ.auto_detect_and_respond("chat-1", "catch up")
|
|
assert info is not None
|
|
assert info.get("channel_id") == "chat-1"
|
|
|
|
def test_auto_detect_no_match(self):
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
info = summ.auto_detect_and_respond("chat-1", "hello world")
|
|
assert info is None
|
|
|
|
|
|
class TestCitesParsing:
|
|
def test_parse_chan_cite(self):
|
|
from yuxi.channels.adapters.urbit.cites import parse_cite
|
|
|
|
result = parse_cite({"cite": "chan", "group": "test", "chan": "mychan"})
|
|
assert result is not None
|
|
assert result.get("type") == "chan"
|
|
assert result.get("group") == "test"
|
|
|
|
def test_parse_empty(self):
|
|
from yuxi.channels.adapters.urbit.cites import parse_cite
|
|
|
|
assert parse_cite({}) is None
|
|
assert parse_cite({"text": "hello"}) is None
|
|
|
|
|
|
class TestApprovalPersistence:
|
|
def _make_mock_client(self):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {"pending_approvals": [], "blocked_ships": []}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
async def get(self, path, **kwargs):
|
|
return MockResp()
|
|
|
|
async def put(self, path, **kwargs):
|
|
return MockResp()
|
|
|
|
return MockClient()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_from_empty_store(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
loaded = await approval.load_from_settings_store(self._make_mock_client())
|
|
assert isinstance(loaded, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_to_store(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.create_pending("dm", "~dev", "chat-1", "hello")
|
|
await approval.persist_to_settings_store(self._make_mock_client())
|
|
|
|
|
|
class TestApprovalDedupIntegration:
|
|
def test_create_pending_then_check_duplicate(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
approval.create_pending("dm", "~dev", "chat-1", "hello")
|
|
assert approval.has_duplicate_pending("dm", "~dev", "chat-1") is True
|
|
assert approval.has_duplicate_pending("dm", "~dev", "chat-2") is False
|
|
assert approval.has_duplicate_pending("channel", "~dev", "chat-1") is False
|
|
|
|
def test_approve_removes_from_pending(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
p = approval.create_pending("dm", "~dev", "chat-1", "hello")
|
|
result = approval.approve(p.id)
|
|
assert result is not None
|
|
assert result.status == "approved"
|
|
assert approval.has_duplicate_pending("dm", "~dev", "chat-1") is False
|
|
|
|
def test_deny_removes_from_pending(self):
|
|
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
|
|
|
approval = ApprovalSystem(owner_ship="~zod")
|
|
p = approval.create_pending("dm", "~dev", "chat-1", "hello")
|
|
result = approval.deny(p.id)
|
|
assert result is not None
|
|
assert result.status == "denied"
|
|
assert approval.has_duplicate_pending("dm", "~dev", "chat-1") is False |