1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
1787 lines
59 KiB
Python
1787 lines
59 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.irc.normalize import (
|
|
build_allowlist_candidates,
|
|
normalize_allow_entry,
|
|
looks_like_irc_target_id,
|
|
normalize_irc_messaging_target,
|
|
resolve_targets,
|
|
format_irc_sender_id,
|
|
resolve_allowlist_match,
|
|
)
|
|
from yuxi.channels.adapters.irc.accounts import (
|
|
resolve_merged_account_config,
|
|
list_irc_account_ids,
|
|
list_enabled_irc_accounts,
|
|
get_account_config,
|
|
is_configured,
|
|
resolve_default_irc_account_id,
|
|
)
|
|
from yuxi.channels.adapters.irc.known_servers import (
|
|
KNOWN_IRC_SERVERS,
|
|
list_known_servers,
|
|
lookup_server,
|
|
get_default_server,
|
|
)
|
|
from yuxi.channels.adapters.irc.secret_contract import (
|
|
IRC_SECRET_TARGETS,
|
|
SecretTarget,
|
|
list_secret_targets,
|
|
get_secret_target,
|
|
audit_secrets,
|
|
)
|
|
from yuxi.channels.adapters.irc.directory import (
|
|
DirectoryUser,
|
|
DirectoryGroup,
|
|
list_users,
|
|
list_groups,
|
|
)
|
|
from yuxi.channels.adapters.irc.doctor import (
|
|
DoctorDiagnosis,
|
|
diagnose,
|
|
)
|
|
from yuxi.channels.adapters.irc.config_schema import (
|
|
get_config_schema,
|
|
get_schema_keys,
|
|
validate_config,
|
|
)
|
|
from yuxi.channels.adapters.irc.setup_core import (
|
|
update_irc_account_config,
|
|
remove_irc_account_config,
|
|
set_irc_server_config,
|
|
set_irc_nick_config,
|
|
set_irc_auth_config,
|
|
)
|
|
from yuxi.channels.adapters.irc.setup_entry import (
|
|
get_setup_entry,
|
|
register_bundled_channel_setup,
|
|
)
|
|
from yuxi.channels.adapters.irc.commands import (
|
|
should_handle_text_commands,
|
|
has_control_command,
|
|
parse_command,
|
|
check_command_access,
|
|
handle_command,
|
|
)
|
|
from yuxi.channels.adapters.irc.security import (
|
|
IRCSecurityConfig,
|
|
SecurityCheckResult,
|
|
collect_security_warnings,
|
|
_find_mutable_allowlist_entries,
|
|
)
|
|
from yuxi.channels.adapters.irc.send_cache import (
|
|
SentMessageCache,
|
|
SentMessageEntry,
|
|
)
|
|
from yuxi.channels.adapters.irc.formatter import (
|
|
format_outbound,
|
|
convert_markdown_tables,
|
|
)
|
|
from yuxi.channels.adapters.irc.nickserv import (
|
|
nickserv_identify,
|
|
nickserv_ghost,
|
|
nickserv_recover,
|
|
nickserv_release,
|
|
nickserv_info,
|
|
nickserv_status,
|
|
)
|
|
from yuxi.channels.adapters.irc.media_vision import (
|
|
extract_media_urls,
|
|
has_media_url,
|
|
analyze_urls,
|
|
)
|
|
from yuxi.channels.adapters.irc.protocol import (
|
|
parse_irc_tags_dict,
|
|
extract_account_from_tags,
|
|
is_identified,
|
|
parse_irc_tags,
|
|
)
|
|
from yuxi.channels.adapters.irc.channel_runtime import ChannelRuntime, _merge_modes
|
|
from yuxi.channels.adapters.irc.splits import (
|
|
split_markdown,
|
|
split_irc_text,
|
|
)
|
|
from yuxi.channels.adapters.irc.auth import (
|
|
resolve_password,
|
|
get_server_password,
|
|
nick_recover,
|
|
)
|
|
from yuxi.channels.adapters.irc.ctcp import (
|
|
handle_ctcp,
|
|
extract_ctcp_action,
|
|
supports_ctcp,
|
|
)
|
|
from yuxi.channels.adapters.irc.config_ui_hints import get_config_ui_hints
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# normalize.py - Allowlist 候选/匹配/规范化
|
|
# ========================================================================
|
|
|
|
class TestBuildAllowlistCandidates:
|
|
def test_basic_nick(self):
|
|
candidates = build_allowlist_candidates("alice")
|
|
assert candidates == ["alice"]
|
|
|
|
def test_with_user(self):
|
|
candidates = build_allowlist_candidates("alice", sender_user="user1")
|
|
assert "alice!user1" in candidates
|
|
|
|
def test_with_host(self):
|
|
candidates = build_allowlist_candidates("alice", sender_host="example.com")
|
|
assert "alice@example.com" in candidates
|
|
|
|
def test_with_user_and_host(self):
|
|
candidates = build_allowlist_candidates("alice", "user1", "example.com")
|
|
assert "alice!user1@example.com" in candidates
|
|
assert len(candidates) == 4
|
|
|
|
def test_special_chars_in_nick(self):
|
|
candidates = build_allowlist_candidates("nick[name]")
|
|
assert "nick[name]" in candidates
|
|
|
|
def test_empty_user_and_host(self):
|
|
candidates = build_allowlist_candidates("alice", None, None)
|
|
assert candidates == ["alice"]
|
|
|
|
|
|
class TestResolveAllowlistMatch:
|
|
def test_exact_match(self):
|
|
result = resolve_allowlist_match(["alice"], ["alice", "bob"])
|
|
assert result == "alice"
|
|
|
|
def test_no_match(self):
|
|
result = resolve_allowlist_match(["charlie"], ["alice", "bob"])
|
|
assert result is None
|
|
|
|
def test_wildcard_star(self):
|
|
result = resolve_allowlist_match(["anyone"], ["*"])
|
|
assert result == "*"
|
|
|
|
def test_glob_match(self):
|
|
result = resolve_allowlist_match(["alice!user@host"], ["alice!*@*"])
|
|
assert result == "alice!*@*"
|
|
|
|
def test_multiple_candidates_first_wins(self):
|
|
candidates = ["alice!user@host", "alice@host", "alice"]
|
|
result = resolve_allowlist_match(candidates, ["alice"])
|
|
assert result == "alice"
|
|
|
|
def test_full_mask_before_wildcard(self):
|
|
candidates = ["bob!user@host"]
|
|
result = resolve_allowlist_match(candidates, ["bob!user@host", "*"])
|
|
assert result == "bob!user@host"
|
|
|
|
def test_empty_allowlist(self):
|
|
result = resolve_allowlist_match(["alice"], [])
|
|
assert result is None
|
|
|
|
def test_empty_candidates(self):
|
|
result = resolve_allowlist_match([], ["alice"])
|
|
assert result is None
|
|
|
|
def test_glob_match_host(self):
|
|
result = resolve_allowlist_match(["alice@trusted.com"], ["*@trusted.com"])
|
|
assert result == "*@trusted.com"
|
|
|
|
|
|
class TestNormalizeAllowEntry:
|
|
def test_strips_irc_prefix(self):
|
|
assert normalize_allow_entry("irc:alice") == "alice"
|
|
assert normalize_allow_entry("irc:user:alice") == "alice"
|
|
|
|
def test_strips_user_prefix(self):
|
|
assert normalize_allow_entry("user:alice") == "alice"
|
|
|
|
def test_no_prefix_unchanged(self):
|
|
assert normalize_allow_entry("alice") == "alice"
|
|
|
|
def test_mask_with_at(self):
|
|
assert normalize_allow_entry("alice@host") == "alice@host"
|
|
|
|
|
|
class TestLooksLikeIrcTargetId:
|
|
def test_channel_prefix(self):
|
|
assert looks_like_irc_target_id("irc:channel:#test") is True
|
|
|
|
def test_user_prefix(self):
|
|
assert looks_like_irc_target_id("irc:user:alice") is True
|
|
|
|
def test_full_mask(self):
|
|
assert looks_like_irc_target_id("nick!user@host") is True
|
|
|
|
def test_plain_nick(self):
|
|
assert looks_like_irc_target_id("alice") is False
|
|
|
|
def test_channel_hash(self):
|
|
assert looks_like_irc_target_id("#channel") is False
|
|
|
|
|
|
class TestNormalizeIrcMessagingTarget:
|
|
def test_channel_prefix(self):
|
|
assert normalize_irc_messaging_target("irc:channel:#test") == ("group", "#test")
|
|
|
|
def test_user_prefix(self):
|
|
assert normalize_irc_messaging_target("irc:user:alice") == ("direct", "alice")
|
|
|
|
def test_hash_channel(self):
|
|
assert normalize_irc_messaging_target("#channel") == ("group", "#channel")
|
|
|
|
def test_ampersand_channel(self):
|
|
assert normalize_irc_messaging_target("&channel") == ("group", "&channel")
|
|
|
|
def test_plain_nick(self):
|
|
assert normalize_irc_messaging_target("alice") == ("direct", "alice")
|
|
|
|
def test_nick_with_irc_prefix(self):
|
|
assert normalize_irc_messaging_target("irc:alice") == ("direct", "alice")
|
|
|
|
|
|
class TestResolveTargets:
|
|
def test_channel_target(self):
|
|
result = resolve_targets("#test")
|
|
assert result["kind"] == "group"
|
|
assert result["id"] == "#test"
|
|
|
|
def test_user_target(self):
|
|
result = resolve_targets("alice")
|
|
assert result["kind"] == "direct"
|
|
|
|
|
|
class TestFormatIrcSenderId:
|
|
def test_full(self):
|
|
assert format_irc_sender_id("nick", "user", "host") == "nick!user@host"
|
|
|
|
def test_user_only(self):
|
|
assert format_irc_sender_id("nick", "user") == "nick!user"
|
|
|
|
def test_host_only(self):
|
|
assert format_irc_sender_id("nick", host="host") == "nick@host"
|
|
|
|
def test_nick_only(self):
|
|
assert format_irc_sender_id("nick") == "nick"
|
|
|
|
def test_none_user_and_host(self):
|
|
assert format_irc_sender_id("nick", None, None) == "nick"
|
|
|
|
|
|
# ========================================================================
|
|
# accounts.py - 多账户/合并/解析
|
|
# ========================================================================
|
|
|
|
class TestAccountsResolveMergedConfig:
|
|
def test_basic_merge(self):
|
|
base = {"server": "irc.test.com", "nick": "basebot"}
|
|
account = {"nick": "accountbot", "port": 7000}
|
|
merged = resolve_merged_account_config(base, account)
|
|
assert merged["server"] == "irc.test.com"
|
|
assert merged["nick"] == "accountbot"
|
|
assert merged["port"] == 7000
|
|
|
|
def test_deep_merge(self):
|
|
base = {"commands": {"enabled": False, "custom": {}}}
|
|
account = {"commands": {"enabled": True}}
|
|
merged = resolve_merged_account_config(base, account)
|
|
assert merged["commands"]["enabled"] is True
|
|
assert "custom" in merged["commands"]
|
|
|
|
def test_original_unchanged(self):
|
|
base = {"server": "irc.test.com"}
|
|
account = {"port": 7000}
|
|
resolve_merged_account_config(base, account)
|
|
assert "port" not in base
|
|
|
|
def test_empty_override(self):
|
|
base = {"server": "irc.test.com", "nick": "bot"}
|
|
merged = resolve_merged_account_config(base, {})
|
|
assert merged == base
|
|
assert merged is not base
|
|
|
|
|
|
class TestAccountsListIds:
|
|
def test_normal_accounts(self):
|
|
config = {"accounts": {"default": {}, "alt": {}}}
|
|
assert list_irc_account_ids(config) == ["default", "alt"]
|
|
|
|
def test_no_accounts(self):
|
|
assert list_irc_account_ids({}) == []
|
|
|
|
def test_accounts_not_dict(self):
|
|
assert list_irc_account_ids({"accounts": "string"}) == []
|
|
|
|
|
|
class TestAccountsListEnabled:
|
|
def test_enabled_accounts(self):
|
|
config = {
|
|
"server": "irc.test.com",
|
|
"nick": "topbot",
|
|
"accounts": {
|
|
"default": {"enabled": True},
|
|
"disabled_one": {"enabled": False},
|
|
"implicit": {},
|
|
},
|
|
}
|
|
enabled = list_enabled_irc_accounts(config)
|
|
assert len(enabled) == 2
|
|
ids = {a["_account_id"] for a in enabled}
|
|
assert ids == {"default", "implicit"}
|
|
|
|
def test_merged_inherits_base(self):
|
|
config = {
|
|
"server": "irc.test.com",
|
|
"nick": "topbot",
|
|
"accounts": {"default": {}},
|
|
}
|
|
enabled = list_enabled_irc_accounts(config)
|
|
assert enabled[0]["server"] == "irc.test.com"
|
|
assert enabled[0]["nick"] == "topbot"
|
|
|
|
def test_no_accounts(self):
|
|
assert list_enabled_irc_accounts({}) == []
|
|
|
|
|
|
class TestAccountsGetConfig:
|
|
def test_existing_account(self):
|
|
config = {
|
|
"server": "irc.test.com",
|
|
"nick": "topbot",
|
|
"accounts": {"default": {"nick": "override"}},
|
|
}
|
|
account = get_account_config(config, "default")
|
|
assert account["nick"] == "override"
|
|
assert account["server"] == "irc.test.com"
|
|
|
|
def test_disabled_account(self):
|
|
config = {
|
|
"accounts": {"default": {"enabled": False}},
|
|
}
|
|
assert get_account_config(config, "default") is None
|
|
|
|
def test_nonexistent_account(self):
|
|
assert get_account_config({}, "nonexistent") is None
|
|
|
|
|
|
class TestAccountsIsConfigured:
|
|
def test_config_only(self):
|
|
assert is_configured({"server": "irc.test.com", "nick": "bot"}) is True
|
|
|
|
def test_missing_nick(self):
|
|
assert is_configured({"server": "irc.test.com"}) is False
|
|
|
|
def test_missing_server(self):
|
|
assert is_configured({"nick": "bot"}) is False
|
|
|
|
def test_env_fallback(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_HOST", "irc.test.com")
|
|
monkeypatch.setenv("IRC_NICK", "envbot")
|
|
assert is_configured({}) is True
|
|
|
|
def test_config_overrides_env(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_HOST", "wrong.example.com")
|
|
assert is_configured({"server": "correct.com", "nick": "bot"}) is True
|
|
|
|
|
|
class TestAccountsResolveDefaultId:
|
|
def test_default_exists(self):
|
|
config = {"accounts": {"default": {}, "alt": {}}}
|
|
assert resolve_default_irc_account_id(config) == "default"
|
|
|
|
def test_no_default_uses_first(self):
|
|
config = {"accounts": {"alt1": {}, "alt2": {}}}
|
|
assert resolve_default_irc_account_id(config) == "alt1"
|
|
|
|
def test_no_accounts(self):
|
|
assert resolve_default_irc_account_id({}) == "default"
|
|
|
|
def test_custom_default(self):
|
|
config = {"accounts": {"main": {}}}
|
|
assert resolve_default_irc_account_id(config, default="main") == "main"
|
|
|
|
|
|
# ========================================================================
|
|
# known_servers.py
|
|
# ========================================================================
|
|
|
|
class TestKnownServers:
|
|
def test_known_servers_size(self):
|
|
assert len(KNOWN_IRC_SERVERS) >= 8
|
|
|
|
def test_list_known_servers(self):
|
|
servers = list_known_servers()
|
|
assert len(servers) >= 8
|
|
for s in servers:
|
|
assert "name" in s
|
|
assert "host" in s
|
|
assert "port" in s
|
|
assert "tls" in s
|
|
|
|
def test_lookup_exact(self):
|
|
server = lookup_server("Libera.Chat")
|
|
assert server is not None
|
|
assert server["host"] == "irc.libera.chat"
|
|
assert server["port"] == 6697
|
|
|
|
def test_lookup_case_insensitive(self):
|
|
server = lookup_server("libera.chat")
|
|
assert server is not None
|
|
assert server["host"] == "irc.libera.chat"
|
|
|
|
def test_lookup_nonexistent(self):
|
|
assert lookup_server("nonexistent") is None
|
|
|
|
def test_get_default_server(self):
|
|
default = get_default_server()
|
|
assert default["name"] == "Libera.Chat"
|
|
assert default["host"] == "irc.libera.chat"
|
|
|
|
def test_twitch_server(self):
|
|
server = lookup_server("Twitch")
|
|
assert server is not None
|
|
assert server["host"] == "irc.chat.twitch.tv"
|
|
|
|
|
|
# ========================================================================
|
|
# secret_contract.py
|
|
# ========================================================================
|
|
|
|
class TestSecretTarget:
|
|
def test_dataclass_creation(self):
|
|
st = SecretTarget(
|
|
key="test_key",
|
|
label="Test Label",
|
|
description="A test secret",
|
|
env_var="TEST_ENV",
|
|
)
|
|
assert st.key == "test_key"
|
|
assert st.label == "Test Label"
|
|
assert st.env_var == "TEST_ENV"
|
|
|
|
|
|
class TestListSecretTargets:
|
|
def test_returns_all_targets(self):
|
|
targets = list_secret_targets()
|
|
assert len(targets) == len(IRC_SECRET_TARGETS)
|
|
keys = {t["key"] for t in targets}
|
|
assert "nickserv_password" in keys
|
|
assert "sasl_password" in keys
|
|
assert "server_password" in keys
|
|
|
|
def test_target_structure(self):
|
|
targets = list_secret_targets()
|
|
for t in targets:
|
|
assert "key" in t
|
|
assert "label" in t
|
|
assert "description" in t
|
|
assert "env_var" in t
|
|
assert "config_path" in t
|
|
|
|
|
|
class TestGetSecretTarget:
|
|
def test_existing_key(self):
|
|
target = get_secret_target("sasl_password")
|
|
assert target is not None
|
|
assert target["key"] == "sasl_password"
|
|
assert target["env_var"] == "IRC_SASL_PASSWORD"
|
|
|
|
def test_nonexistent_key(self):
|
|
assert get_secret_target("nonexistent") is None
|
|
|
|
|
|
class TestAuditSecrets:
|
|
def test_all_configured(self):
|
|
config = {
|
|
"sasl_password": "secret1",
|
|
"nickserv_password": "secret2",
|
|
"password": "serverpass",
|
|
"nickserv_password_file": "/path/to/file",
|
|
}
|
|
findings = audit_secrets(config)
|
|
for f in findings:
|
|
assert f["key"] in {t.key for t in IRC_SECRET_TARGETS}
|
|
assert "configured" in f
|
|
assert "sources" in f
|
|
|
|
def test_nothing_configured(self):
|
|
findings = audit_secrets({})
|
|
assert all(not f["configured"] for f in findings)
|
|
|
|
def test_env_var_detection(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_SASL_PASSWORD", "env_secret")
|
|
findings = audit_secrets({})
|
|
sasl = next(f for f in findings if f["key"] == "sasl_password")
|
|
assert sasl["configured"] is True
|
|
assert any("env.IRC_SASL_PASSWORD" in s for s in sasl["sources"])
|
|
|
|
|
|
# ========================================================================
|
|
# directory.py
|
|
# ========================================================================
|
|
|
|
class TestDirectoryDataclasses:
|
|
def test_directory_user(self):
|
|
user = DirectoryUser(id="alice", display_name="Alice")
|
|
assert user.id == "alice"
|
|
assert user.display_name == "Alice"
|
|
|
|
def test_directory_group(self):
|
|
group = DirectoryGroup(id="#test", display_name="#test", member_count=42)
|
|
assert group.id == "#test"
|
|
assert group.member_count == 42
|
|
|
|
|
|
class TestListUsers:
|
|
def test_from_allow_from(self):
|
|
config = {"allowFrom": ["alice!user@host", "bob@example.com"]}
|
|
users = list_users(config)
|
|
assert len(users) == 2
|
|
names = {u.id for u in users}
|
|
assert names == {"alice", "bob"}
|
|
|
|
def test_from_group_allow_from(self):
|
|
config = {"groupAllowFrom": ["charlie!user@host"]}
|
|
users = list_users(config)
|
|
assert len(users) == 1
|
|
assert users[0].id == "charlie"
|
|
|
|
def test_from_groups_allow_from(self):
|
|
config = {
|
|
"groups": {
|
|
"#test": {"allowFrom": ["dave!user@host"]},
|
|
}
|
|
}
|
|
users = list_users(config)
|
|
assert len(users) == 1
|
|
assert users[0].id == "dave"
|
|
|
|
def test_dedup_across_sources(self):
|
|
config = {
|
|
"allowFrom": ["alice!user@host"],
|
|
"groupAllowFrom": ["alice@example.com"],
|
|
}
|
|
users = list_users(config)
|
|
assert len(users) == 1
|
|
|
|
def test_ignore_star(self):
|
|
config = {"allowFrom": ["*"]}
|
|
users = list_users(config)
|
|
assert len(users) == 0
|
|
|
|
def test_empty_entry(self):
|
|
config = {"allowFrom": [""]}
|
|
users = list_users(config)
|
|
assert len(users) == 0
|
|
|
|
def test_sorted_alphabetically(self):
|
|
config = {"allowFrom": ["zoe!x@y", "alice!x@y"]}
|
|
users = list_users(config)
|
|
assert users[0].id == "alice"
|
|
assert users[1].id == "zoe"
|
|
|
|
|
|
class TestListGroups:
|
|
def test_from_auto_join_channels(self):
|
|
config = {"auto_join_channels": ["#general", "&support"]}
|
|
groups = list_groups(config)
|
|
assert len(groups) == 2
|
|
names = {g.id for g in groups}
|
|
assert names == {"#general", "&support"}
|
|
|
|
def test_with_group_config(self):
|
|
config = {
|
|
"auto_join_channels": ["#test"],
|
|
"groups": {"#test": {"skills": ["skill1"], "enabled": True}},
|
|
}
|
|
groups = list_groups(config)
|
|
assert len(groups) == 1
|
|
assert groups[0].metadata == {"skills": ["skill1"], "systemPrompt": None, "enabled": True}
|
|
|
|
def test_extra_groups(self):
|
|
config = {
|
|
"groups": {"extra_group": {"some": "config"}},
|
|
}
|
|
groups = list_groups(config)
|
|
ids = {g.id for g in groups}
|
|
assert "extra_group" in ids
|
|
|
|
def test_skip_channel_names_in_groups(self):
|
|
config = {
|
|
"groups": {"*": {}, "#already_in_channels": {}, "&also": {}},
|
|
}
|
|
groups = list_groups(config)
|
|
assert len(groups) == 0
|
|
|
|
def test_env_channels(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_CHANNELS", "#env1, #env2")
|
|
config = {}
|
|
groups = list_groups(config)
|
|
assert len(groups) == 2
|
|
|
|
|
|
# ========================================================================
|
|
# doctor.py
|
|
# ========================================================================
|
|
|
|
class TestDoctorDiagnosis:
|
|
def test_default_ok(self):
|
|
d = DoctorDiagnosis()
|
|
assert d.status == "ok"
|
|
assert d.warnings == []
|
|
assert d.errors == []
|
|
assert d.checks == []
|
|
|
|
|
|
class TestDiagnose:
|
|
def test_tls_enabled(self):
|
|
result = diagnose({}, use_tls=True)
|
|
tls_check = next(c for c in result.checks if c["name"] == "tls")
|
|
assert tls_check["status"] == "ok"
|
|
|
|
def test_tls_disabled(self):
|
|
result = diagnose({}, use_tls=False)
|
|
tls_check = next(c for c in result.checks if c["name"] == "tls")
|
|
assert tls_check["status"] == "warning"
|
|
assert len(result.warnings) >= 1
|
|
|
|
def test_nickserv_enabled(self):
|
|
result = diagnose({}, nickserv_enabled=True)
|
|
ns_check = next(c for c in result.checks if c["name"] == "nickserv")
|
|
assert ns_check["status"] == "ok"
|
|
|
|
def test_nickserv_disabled(self):
|
|
result = diagnose({}, nickserv_enabled=False)
|
|
ns_check = next(c for c in result.checks if c["name"] == "nickserv")
|
|
assert ns_check["status"] == "warning"
|
|
|
|
def test_nickserv_register_warning(self):
|
|
result = diagnose({}, nickserv_enabled=True, nickserv_register=True)
|
|
ns_check = next(c for c in result.checks if c["name"] == "nickserv")
|
|
assert ns_check["status"] == "warning"
|
|
|
|
def test_open_group_policy_warning(self):
|
|
sec_config = IRCSecurityConfig(group_policy="open")
|
|
result = diagnose({}, security_config=sec_config)
|
|
gp_check = next(c for c in result.checks if c["name"] == "group_policy")
|
|
assert gp_check["status"] == "warning"
|
|
|
|
def test_mutable_allowlist_warning(self):
|
|
sec_config = IRCSecurityConfig(allow_from=["alice"])
|
|
result = diagnose({}, security_config=sec_config)
|
|
assert any("Mutable" in w for w in result.warnings)
|
|
|
|
def test_no_security_config(self):
|
|
result = diagnose({})
|
|
assert result.checks
|
|
|
|
|
|
# ========================================================================
|
|
# config_schema.py
|
|
# ========================================================================
|
|
|
|
class TestGetConfigSchema:
|
|
def test_returns_dict(self):
|
|
schema = get_config_schema()
|
|
assert isinstance(schema, dict)
|
|
assert "server" in schema
|
|
assert "nick" in schema
|
|
assert "port" in schema
|
|
|
|
def test_get_schema_keys(self):
|
|
keys = get_schema_keys()
|
|
assert "server" in keys
|
|
assert "nick" in keys
|
|
assert len(keys) > 20
|
|
|
|
|
|
class TestValidateConfig:
|
|
def test_valid_minimal_config(self):
|
|
errors = validate_config({"server": "irc.test.com", "nick": "bot"})
|
|
assert errors == []
|
|
|
|
def test_missing_server(self):
|
|
errors = validate_config({"nick": "bot"})
|
|
assert any("server" in e for e in errors)
|
|
|
|
def test_missing_nick(self):
|
|
errors = validate_config({"server": "irc.test.com"})
|
|
assert any("nick" in e for e in errors)
|
|
|
|
def test_invalid_dm_policy(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"dm_policy": "invalid",
|
|
})
|
|
assert any("dm_policy" in e for e in errors)
|
|
|
|
def test_invalid_group_policy(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"group_policy": "invalid",
|
|
})
|
|
assert any("group_policy" in e for e in errors)
|
|
|
|
def test_invalid_port_type(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"port": "not_a_number",
|
|
})
|
|
assert any("port" in e for e in errors)
|
|
|
|
def test_port_too_low(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"port": 0,
|
|
})
|
|
assert any("port" in e for e in errors)
|
|
|
|
def test_port_too_high(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"port": 99999,
|
|
})
|
|
assert any("port" in e for e in errors)
|
|
|
|
def test_invalid_chunker_mode(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"chunker_mode": "invalid",
|
|
})
|
|
assert any("chunker_mode" in e for e in errors)
|
|
|
|
def test_nicserv_register_missing_email(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"nickserv_register": True,
|
|
})
|
|
assert any("nickserv_register_email" in e for e in errors)
|
|
|
|
def test_nicserv_register_with_email(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"nickserv_register": True,
|
|
"nickserv_register_email": "bot@test.com",
|
|
})
|
|
assert not any("nickserv_register_email" in e for e in errors)
|
|
|
|
def test_dm_policy_open_without_star(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"dm_policy": "open",
|
|
"allowFrom": [],
|
|
})
|
|
assert any("allowFrom" in e for e in errors)
|
|
|
|
def test_dotted_keys_in_config(self):
|
|
errors = validate_config({
|
|
"server": "irc.test.com",
|
|
"nick": "bot",
|
|
"commands": {"enabled": True},
|
|
})
|
|
assert len(errors) == 0
|
|
|
|
|
|
# ========================================================================
|
|
# setup_core.py
|
|
# ========================================================================
|
|
|
|
class TestUpdateIrcAccountConfig:
|
|
def test_update_simple(self):
|
|
existing = {"server": "old.server.com", "nick": "oldbot"}
|
|
result = update_irc_account_config(existing, {"server": "new.server.com"})
|
|
assert result["server"] == "new.server.com"
|
|
assert result["nick"] == "oldbot"
|
|
|
|
def test_does_not_mutate_original(self):
|
|
existing = {"server": "old.server.com"}
|
|
update_irc_account_config(existing, {"server": "new.server.com"})
|
|
assert existing["server"] == "old.server.com"
|
|
|
|
def test_deep_merge(self):
|
|
existing = {"commands": {"enabled": False, "custom": {"a": "b"}}}
|
|
result = update_irc_account_config(existing, {"commands": {"enabled": True}})
|
|
assert result["commands"]["enabled"] is True
|
|
assert result["commands"]["custom"] == {"a": "b"}
|
|
|
|
|
|
class TestRemoveIrcAccountConfig:
|
|
def test_remove_existing_keys(self):
|
|
existing = {"server": "irc.test.com", "nick": "bot", "port": 6667}
|
|
result = remove_irc_account_config(existing, ["port"])
|
|
assert "port" not in result
|
|
assert "server" in result
|
|
|
|
def test_remove_nonexistent_key(self):
|
|
existing = {"server": "irc.test.com"}
|
|
result = remove_irc_account_config(existing, ["nonexistent"])
|
|
assert result == {"server": "irc.test.com"}
|
|
|
|
def test_remove_multiple_keys(self):
|
|
existing = {"a": 1, "b": 2, "c": 3}
|
|
result = remove_irc_account_config(existing, ["a", "b"])
|
|
assert result == {"c": 3}
|
|
|
|
def test_does_not_mutate_original(self):
|
|
existing = {"server": "irc.test.com", "port": 6667}
|
|
remove_irc_account_config(existing, ["port"])
|
|
assert "port" in existing
|
|
|
|
|
|
class TestSetIrcServerConfig:
|
|
def test_basic(self):
|
|
config = {}
|
|
result = set_irc_server_config(config, "irc.test.com")
|
|
assert result["server"] == "irc.test.com"
|
|
assert result["port"] == 6697
|
|
assert result["use_tls"] is True
|
|
|
|
def test_custom_port(self):
|
|
result = set_irc_server_config({}, "irc.test.com", port=7000, use_tls=False)
|
|
assert result["port"] == 7000
|
|
assert result["use_tls"] is False
|
|
|
|
|
|
class TestSetIrcNickConfig:
|
|
def test_basic(self):
|
|
result = set_irc_nick_config({}, "mybot")
|
|
assert result["nick"] == "mybot"
|
|
assert "username" not in result
|
|
|
|
def test_with_username(self):
|
|
result = set_irc_nick_config({}, "mybot", username="myuser")
|
|
assert result["username"] == "myuser"
|
|
|
|
def test_with_realname(self):
|
|
result = set_irc_nick_config({}, "mybot", realname="My IRC Bot")
|
|
assert result["realname"] == "My IRC Bot"
|
|
|
|
|
|
class TestSetIrcAuthConfig:
|
|
def test_none_auth(self):
|
|
result = set_irc_auth_config({}, auth_type="none")
|
|
assert result.get("use_sasl") is False
|
|
assert "nickserv_enabled" not in result
|
|
|
|
def test_sasl_auth(self):
|
|
result = set_irc_auth_config(
|
|
{}, auth_type="sasl", sasl_username="user", sasl_password="pass"
|
|
)
|
|
assert result["use_sasl"] is True
|
|
assert result["sasl_username"] == "user"
|
|
assert result["sasl_password"] == "pass"
|
|
|
|
def test_nickserv_auth(self):
|
|
result = set_irc_auth_config(
|
|
{}, auth_type="nickserv", nickserv_password="ns_pass"
|
|
)
|
|
assert result["use_sasl"] is False
|
|
assert result["nickserv_enabled"] is True
|
|
assert result["nickserv_password"] == "ns_pass"
|
|
|
|
def test_with_server_password(self):
|
|
result = set_irc_auth_config({}, password="serverpass")
|
|
assert result["password"] == "serverpass"
|
|
|
|
|
|
# ========================================================================
|
|
# setup_entry.py
|
|
# ========================================================================
|
|
|
|
class TestSetupEntry:
|
|
def test_get_setup_entry(self):
|
|
entry = get_setup_entry()
|
|
assert entry["channel_id"] == "irc"
|
|
assert entry["label"] == "IRC"
|
|
assert len(entry["steps"]) >= 4
|
|
|
|
def test_register_bundled_channel_setup(self):
|
|
entry = register_bundled_channel_setup()
|
|
assert entry == get_setup_entry()
|
|
|
|
|
|
# ========================================================================
|
|
# commands.py
|
|
# ========================================================================
|
|
|
|
class TestShouldHandleTextCommands:
|
|
def test_enabled_with_command(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert should_handle_text_commands("!help", config) is True
|
|
|
|
def test_enabled_with_dot_command(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert should_handle_text_commands(".ping", config) is True
|
|
|
|
def test_disabled(self):
|
|
config = {"commands": {"enabled": False}}
|
|
assert should_handle_text_commands("!help", config) is False
|
|
|
|
def test_not_a_command(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert should_handle_text_commands("hello", config) is False
|
|
|
|
def test_whitespace_before_command(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert should_handle_text_commands(" !help", config) is True
|
|
|
|
|
|
class TestHasControlCommand:
|
|
def test_exclamation(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert has_control_command("!help", config) is True
|
|
|
|
def test_dot(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert has_control_command(".ping", config) is True
|
|
|
|
def test_plain_text(self):
|
|
config = {"commands": {"enabled": True}}
|
|
assert has_control_command("hello", config) is False
|
|
|
|
def test_disabled_commands(self):
|
|
config = {"commands": {"enabled": False}}
|
|
assert has_control_command("!help", config) is False
|
|
|
|
|
|
class TestParseCommand:
|
|
def test_simple(self):
|
|
cmd, args = parse_command("!help")
|
|
assert cmd == "help"
|
|
assert args == []
|
|
|
|
def test_with_args(self):
|
|
cmd, args = parse_command("!echo hello world")
|
|
assert cmd == "echo"
|
|
assert args == ["hello", "world"]
|
|
|
|
def test_dot_command(self):
|
|
cmd, args = parse_command(".ping")
|
|
assert cmd == "ping"
|
|
|
|
|
|
class TestCheckCommandAccess:
|
|
def test_no_access_groups(self):
|
|
config = {"commands": {"enabled": True, "useAccessGroups": False}}
|
|
assert check_command_access("alice", "ping", config) is True
|
|
|
|
def test_no_acl_for_command(self):
|
|
config = {"commands": {"enabled": True, "useAccessGroups": True}}
|
|
assert check_command_access("alice", "help", config) is True
|
|
|
|
def test_acl_allows_user(self):
|
|
config = {
|
|
"commands": {
|
|
"enabled": True,
|
|
"useAccessGroups": True,
|
|
"acl": {"ping": ["admins"]},
|
|
}
|
|
}
|
|
access_groups = {"admins": ["alice"]}
|
|
assert check_command_access("alice", "ping", config, access_groups) is True
|
|
|
|
def test_acl_denies_user(self):
|
|
config = {
|
|
"commands": {
|
|
"enabled": True,
|
|
"useAccessGroups": True,
|
|
"acl": {"ping": ["admins"]},
|
|
}
|
|
}
|
|
assert check_command_access("bob", "ping", config, {"admins": []}) is False
|
|
|
|
def test_wildcard_acl(self):
|
|
config = {
|
|
"commands": {
|
|
"enabled": True,
|
|
"useAccessGroups": True,
|
|
"acl": {"*": ["admins"]},
|
|
}
|
|
}
|
|
assert check_command_access("alice", "custom", config, {"admins": ["alice"]}) is True
|
|
|
|
|
|
class TestHandleCommand:
|
|
@pytest.mark.asyncio
|
|
async def test_help(self):
|
|
send_fn = MagicMock()
|
|
response = await handle_command("help", [], "user", "#test", {}, send_fn)
|
|
assert "Available commands" in response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping(self):
|
|
response = await handle_command("ping", [], "user", "#test", {}, MagicMock())
|
|
assert response == "Pong!"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status(self):
|
|
response = await handle_command("status", [], "user", "#test", {}, MagicMock())
|
|
assert "running" in response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_about(self):
|
|
response = await handle_command("about", [], "user", "#test", {}, MagicMock())
|
|
assert "ForcePilot" in response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_command(self):
|
|
response = await handle_command("nonexistent", [], "user", "#test", {}, MagicMock())
|
|
assert response is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_command_string(self):
|
|
config = {"commands": {"custom": {"hello": "Hello, world!"}}}
|
|
response = await handle_command("hello", [], "user", "#test", config, MagicMock())
|
|
assert response == "Hello, world!"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_command_dict(self):
|
|
config = {"commands": {"custom": {"info": {"response": "Bot Info v1.0"}}}}
|
|
response = await handle_command("info", [], "user", "#test", config, MagicMock())
|
|
assert response == "Bot Info v1.0"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_help_lists_custom_commands(self):
|
|
config = {"commands": {"custom": {"hello": "hi"}}}
|
|
response = await handle_command("help", [], "user", "#test", config, MagicMock())
|
|
assert "hello" in response
|
|
|
|
|
|
# ========================================================================
|
|
# security.py - IRCSecurityConfig
|
|
# ========================================================================
|
|
|
|
class TestSecurityCheckResult:
|
|
def test_default(self):
|
|
result = SecurityCheckResult(allowed=True, reason="test")
|
|
assert result.allowed is True
|
|
assert result.reason == "test"
|
|
assert result.challenge_id is None
|
|
|
|
|
|
class TestIRCSecurityConfigCheckDM:
|
|
def test_open_policy(self):
|
|
config = IRCSecurityConfig(dm_policy="open")
|
|
result = config.check_dm("prefix", "alice")
|
|
assert result.allowed is True
|
|
assert "open" in result.reason
|
|
|
|
def test_disabled_policy(self):
|
|
config = IRCSecurityConfig(dm_policy="disabled")
|
|
result = config.check_dm("prefix", "alice")
|
|
assert result.allowed is False
|
|
|
|
def test_pairing_no_allowlist(self):
|
|
config = IRCSecurityConfig(dm_policy="pairing")
|
|
result = config.check_dm("prefix", "alice")
|
|
assert result.allowed is False
|
|
assert result.challenge_id is not None
|
|
|
|
def test_pairing_with_match(self):
|
|
config = IRCSecurityConfig(
|
|
dm_policy="pairing", allow_from=["alice!user@host"]
|
|
)
|
|
result = config.check_dm("alice!user@host", "alice")
|
|
assert result.allowed is True
|
|
|
|
def test_open_policy_allows_anyone(self):
|
|
config = IRCSecurityConfig(dm_policy="open", allow_from=["alice"])
|
|
result = config.check_dm("prefix", "bob")
|
|
assert result.allowed is True
|
|
assert "open" in result.reason
|
|
|
|
def test_dangerously_allow_name_matching(self):
|
|
config = IRCSecurityConfig(
|
|
dm_policy="pairing",
|
|
allow_from=["alice"],
|
|
dangerously_allow_name_matching=True,
|
|
)
|
|
result = config.check_dm("alice!user@host", "alice")
|
|
assert result.allowed is True
|
|
|
|
|
|
class TestIRCSecurityConfigCheckGroup:
|
|
def test_open_policy(self):
|
|
config = IRCSecurityConfig(group_policy="open")
|
|
result, _resolved = config.check_group("#test", "anyone!x@y", "anyone")
|
|
assert result.allowed is True
|
|
|
|
def test_disabled_policy(self):
|
|
config = IRCSecurityConfig(group_policy="disabled")
|
|
result, _resolved = config.check_group("#test", "user!x@y", "user")
|
|
assert result.allowed is False
|
|
|
|
def test_allowlist_match(self):
|
|
config = IRCSecurityConfig(group_allow_from=["trusted!user@host"])
|
|
result, _resolved = config.check_group("#test", "trusted!user@host", "trusted")
|
|
assert result.allowed is True
|
|
|
|
def test_allowlist_no_match(self):
|
|
config = IRCSecurityConfig(group_allow_from=["trusted!user@host"])
|
|
result, _resolved = config.check_group("#test", "untrusted!x@y", "untrusted")
|
|
assert result.allowed is False
|
|
|
|
def test_channel_disabled(self):
|
|
config = IRCSecurityConfig(
|
|
groups={"#test": {"enabled": False}, "#other": {}}
|
|
)
|
|
result, _resolved = config.check_group("#test", "user!x@y", "user")
|
|
assert result.allowed is False
|
|
|
|
def test_channel_specific_allowlist(self):
|
|
config = IRCSecurityConfig(
|
|
groups={"#test": {"allowFrom": ["channel_user!x@y"]}}
|
|
)
|
|
result, _resolved = config.check_group("#test", "channel_user!x@y", "channel_user")
|
|
assert result.allowed is True
|
|
|
|
def test_fallback_to_allow_from(self):
|
|
config = IRCSecurityConfig(
|
|
allow_from=["global!user@host"],
|
|
group_allow_from_fallback_to_allow_from=True,
|
|
)
|
|
result, _resolved = config.check_group("#test", "global!user@host", "global")
|
|
assert result.allowed is True
|
|
|
|
|
|
class TestIRCSecurityConfigCheckMention:
|
|
def test_mentioned(self):
|
|
config = IRCSecurityConfig()
|
|
assert config.check_mention("hey mybot help", "mybot") is True
|
|
|
|
def test_not_mentioned(self):
|
|
config = IRCSecurityConfig()
|
|
assert config.check_mention("hello everyone", "mybot") is False
|
|
|
|
def test_require_mention_false(self):
|
|
config = IRCSecurityConfig()
|
|
resolved = {"requireMention": False}
|
|
assert config.check_mention("random text", "mybot", resolved_config=resolved) is True
|
|
|
|
|
|
class TestCollectSecurityWarnings:
|
|
def test_tls_warning(self):
|
|
warnings = collect_security_warnings(None, use_tls=False)
|
|
assert any("TLS" in w for w in warnings)
|
|
|
|
def test_nickserv_warning(self):
|
|
warnings = collect_security_warnings(None, nickserv_enabled=False)
|
|
assert any("NickServ" in w for w in warnings)
|
|
|
|
def test_open_group_policy(self):
|
|
config = IRCSecurityConfig(group_policy="open")
|
|
warnings = collect_security_warnings(config)
|
|
assert any("open" in w.lower() for w in warnings)
|
|
|
|
def test_mutable_allowlist_entry(self):
|
|
config = IRCSecurityConfig(allow_from=["plainnick"])
|
|
warnings = collect_security_warnings(config)
|
|
assert any("Mutable" in w for w in warnings)
|
|
|
|
|
|
# ========================================================================
|
|
# send_cache.py
|
|
# ========================================================================
|
|
|
|
class TestSentMessageEntry:
|
|
def test_creation(self):
|
|
entry = SentMessageEntry(
|
|
message_id="msg1", chat_id="#test", text="hello", sent_at=12345.0
|
|
)
|
|
assert entry.message_id == "msg1"
|
|
assert entry.status == "sent"
|
|
|
|
|
|
class TestSentMessageCache:
|
|
def test_put_and_get(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg1", "#test", "hello")
|
|
entry = cache.get("msg1")
|
|
assert entry is not None
|
|
assert entry.text == "hello"
|
|
|
|
def test_get_nonexistent(self):
|
|
cache = SentMessageCache()
|
|
assert cache.get("nonexistent") is None
|
|
|
|
def test_update_status(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg1", "#test", "hello")
|
|
assert cache.update_status("msg1", "delivered") is True
|
|
assert cache.get("msg1").status == "delivered"
|
|
|
|
def test_update_status_nonexistent(self):
|
|
cache = SentMessageCache()
|
|
assert cache.update_status("nonexistent", "delivered") is False
|
|
|
|
def test_remove(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg1", "#test", "hello")
|
|
assert cache.remove("msg1") is True
|
|
assert cache.get("msg1") is None
|
|
|
|
def test_remove_nonexistent(self):
|
|
cache = SentMessageCache()
|
|
assert cache.remove("nonexistent") is False
|
|
|
|
def test_size(self):
|
|
cache = SentMessageCache()
|
|
cache.put("a", "#t", "x")
|
|
cache.put("b", "#t", "y")
|
|
assert cache.size() == 2
|
|
|
|
def test_clear(self):
|
|
cache = SentMessageCache()
|
|
cache.put("a", "#t", "x")
|
|
cache.clear()
|
|
assert cache.size() == 0
|
|
|
|
def test_eviction(self):
|
|
cache = SentMessageCache(max_entries=3)
|
|
for i in range(5):
|
|
cache.put(f"msg{i}", "#test", f"text{i}")
|
|
assert cache.size() <= 3
|
|
|
|
def test_ttl_expired(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg1", "#test", "hello")
|
|
entry = cache._entries["msg1"]
|
|
entry.sent_at = time.monotonic() - cache.TTL_SECONDS - 10
|
|
assert cache.get("msg1") is None
|
|
|
|
def test_put_with_metadata(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg1", "#test", "hello", metadata={"key": "val"})
|
|
entry = cache.get("msg1")
|
|
assert entry.metadata == {"key": "val"}
|
|
|
|
|
|
# ========================================================================
|
|
# formatter.py
|
|
# ========================================================================
|
|
|
|
class TestFormatOutbound:
|
|
def test_basic(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="irc_test",
|
|
channel_type=ChannelType.IRC,
|
|
channel_user_id="alice",
|
|
channel_chat_id="#test",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello world")
|
|
result = format_outbound(response)
|
|
assert result["target"] == "#test"
|
|
assert result["text"] == "hello world"
|
|
|
|
|
|
class TestConvertMarkdownTables:
|
|
def test_off_mode_passes_through(self):
|
|
text = "| a | b |\n| - | - |\n| 1 | 2 |"
|
|
result = convert_markdown_tables(text, table_mode="off")
|
|
assert result == text
|
|
|
|
def test_plain_mode_renders(self):
|
|
text = "| Name | Age |\n|------|-----|\n| Alice| 30 |"
|
|
result = convert_markdown_tables(text, table_mode="plain")
|
|
assert "Name" in result
|
|
assert "Age" in result
|
|
|
|
def test_list_mode_renders(self):
|
|
text = "| Name | Age |\n|------|-----|\n| Alice| 30 |"
|
|
result = convert_markdown_tables(text, table_mode="list")
|
|
assert "Name:" in result
|
|
assert "Age:" in result
|
|
|
|
def test_no_tables(self):
|
|
result = convert_markdown_tables("plain text", table_mode="plain")
|
|
assert result == "plain text"
|
|
|
|
def test_text_with_table_and_plain(self):
|
|
text = "Before\n| a | b |\n| - | - |\n| 1 | 2 |\nAfter"
|
|
result = convert_markdown_tables(text, table_mode="plain")
|
|
assert "Before" in result
|
|
assert "After" in result
|
|
|
|
def test_only_header_no_data(self):
|
|
text = "Before\n| a | b |\nAfter"
|
|
result = convert_markdown_tables(text, table_mode="plain")
|
|
assert "a" in result
|
|
|
|
|
|
# ========================================================================
|
|
# nickserv.py
|
|
# ========================================================================
|
|
|
|
class TestNickservFunctions:
|
|
def test_identify(self):
|
|
result = nickserv_identify("mypass")
|
|
assert result == "PRIVMSG NickServ :IDENTIFY mypass"
|
|
|
|
def test_ghost(self):
|
|
result = nickserv_ghost("mybot", "mypass")
|
|
assert result == "PRIVMSG NickServ :GHOST mybot mypass"
|
|
|
|
def test_recover(self):
|
|
result = nickserv_recover("mybot", "mypass")
|
|
assert result == "PRIVMSG NickServ :RECOVER mybot mypass"
|
|
|
|
def test_release(self):
|
|
result = nickserv_release("mybot", "mypass")
|
|
assert result == "PRIVMSG NickServ :RELEASE mybot mypass"
|
|
|
|
def test_info(self):
|
|
result = nickserv_info("mybot")
|
|
assert result == "PRIVMSG NickServ :INFO mybot"
|
|
|
|
def test_status(self):
|
|
result = nickserv_status("mybot")
|
|
assert result == "PRIVMSG NickServ :STATUS mybot"
|
|
|
|
|
|
# ========================================================================
|
|
# media_vision.py
|
|
# ========================================================================
|
|
|
|
class TestExtractMediaUrls:
|
|
def test_png(self):
|
|
urls = extract_media_urls("check this https://example.com/img.png")
|
|
assert len(urls) == 1
|
|
assert urls[0] == "https://example.com/img.png"
|
|
|
|
def test_multiple_formats(self):
|
|
text = "https://a.com/1.jpg and https://b.com/2.gif"
|
|
urls = extract_media_urls(text)
|
|
assert len(urls) == 2
|
|
|
|
def test_query_params(self):
|
|
urls = extract_media_urls("https://example.com/img.png?w=100&h=200")
|
|
assert len(urls) == 1
|
|
|
|
def test_no_media_urls(self):
|
|
urls = extract_media_urls("hello world")
|
|
assert urls == []
|
|
|
|
def test_webp_and_svg(self):
|
|
urls = extract_media_urls("https://a.com/x.webp https://b.com/y.svg")
|
|
assert len(urls) == 2
|
|
|
|
def test_jpeg_extension(self):
|
|
urls = extract_media_urls("https://example.com/photo.jpeg")
|
|
assert len(urls) == 1
|
|
|
|
def test_ignore_non_media(self):
|
|
urls = extract_media_urls("https://example.com/page.html")
|
|
assert urls == []
|
|
|
|
|
|
class TestHasMediaUrl:
|
|
def test_true(self):
|
|
assert has_media_url("look at https://example.com/img.png") is True
|
|
|
|
def test_false(self):
|
|
assert has_media_url("no media here") is False
|
|
|
|
def test_false_for_html(self):
|
|
assert has_media_url("https://example.com/page.html") is False
|
|
|
|
|
|
class TestAnalyzeUrls:
|
|
def test_single_image(self):
|
|
urls = analyze_urls("https://example.com/photo.png")
|
|
assert len(urls) == 1
|
|
assert urls[0]["type"] == "image"
|
|
assert urls[0]["format"] == "png"
|
|
assert urls[0]["size_bytes"] is None
|
|
|
|
def test_extension_normalization(self):
|
|
urls = analyze_urls("https://example.com/img.JPG?q=80")
|
|
assert urls[0]["format"] == "jpg"
|
|
|
|
|
|
# ========================================================================
|
|
# protocol.py 补充
|
|
# ========================================================================
|
|
|
|
class TestParseIrcTagsDict:
|
|
def test_with_tags(self):
|
|
tags = parse_irc_tags_dict("@msgid=123;time=2024 PRIVMSG #chan :hi")
|
|
assert tags == {"msgid": "123", "time": "2024"}
|
|
|
|
def test_no_tags(self):
|
|
tags = parse_irc_tags_dict("PRIVMSG #chan :hi")
|
|
assert tags == {}
|
|
|
|
|
|
class TestExtractAccountFromTags:
|
|
def test_account_present(self):
|
|
assert extract_account_from_tags({"account": "alice"}) == "alice"
|
|
|
|
def test_account_missing(self):
|
|
assert extract_account_from_tags({"other": "val"}) is None
|
|
|
|
def test_empty_dict(self):
|
|
assert extract_account_from_tags({}) is None
|
|
|
|
|
|
class TestIsIdentified:
|
|
def test_identified(self):
|
|
assert is_identified({"account": "alice"}) is True
|
|
|
|
def test_not_identified_star(self):
|
|
assert is_identified({"account": "*"}) is False
|
|
|
|
def test_not_identified_missing(self):
|
|
assert is_identified({}) is False
|
|
|
|
|
|
# ========================================================================
|
|
# channel_runtime.py 补充
|
|
# ========================================================================
|
|
|
|
class TestMergeModes:
|
|
def test_empty_current(self):
|
|
assert _merge_modes("", "+im") == "+im"
|
|
|
|
def test_empty_incoming(self):
|
|
assert _merge_modes("+nt", "") == "+nt"
|
|
|
|
def test_add_modes(self):
|
|
result = _merge_modes("+nt", "+im")
|
|
assert "i" in result
|
|
assert "m" in result
|
|
|
|
def test_remove_modes(self):
|
|
result = _merge_modes("+nt", "-t")
|
|
assert "t" not in result
|
|
assert "n" in result
|
|
|
|
def test_add_and_remove(self):
|
|
result = _merge_modes("+nt", "+im-t")
|
|
assert "i" in result
|
|
assert "m" in result
|
|
assert "t" not in result
|
|
assert "n" in result
|
|
|
|
|
|
class TestChannelRuntimeExtended:
|
|
def test_handle_quit_removes_from_all_channels(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#a")
|
|
rt.add_channel("#b")
|
|
rt.handle_names("#a", "alice bob")
|
|
rt.handle_names("#b", "alice charlie")
|
|
rt.handle_quit("alice")
|
|
assert "alice" not in rt.get_members("#a")
|
|
assert "alice" not in rt.get_members("#b")
|
|
assert "bob" in rt.get_members("#a")
|
|
|
|
def test_handle_nick_change(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#test")
|
|
rt.handle_names("#test", "alice bob")
|
|
rt.handle_nick_change("alice", "alice2")
|
|
assert "alice" not in rt.get_members("#test")
|
|
assert "alice2" in rt.get_members("#test")
|
|
assert "bob" in rt.get_members("#test")
|
|
|
|
def test_handle_mode(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#test")
|
|
rt.handle_mode("#test", "+im")
|
|
assert "i" in rt.get_modes("#test")
|
|
|
|
def test_topic(self):
|
|
rt = ChannelRuntime()
|
|
rt.set_topic("#test", "Welcome!")
|
|
assert rt.get_topic("#test") == "Welcome!"
|
|
|
|
def test_get_topic_nonexistent(self):
|
|
rt = ChannelRuntime()
|
|
assert rt.get_topic("#nonexistent") is None
|
|
|
|
def test_mixed_prefix_names(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#test")
|
|
rt.handle_names("#test", "@op +voice ~owner normal")
|
|
members = rt.get_members("#test")
|
|
assert "op" in members
|
|
assert "voice" in members
|
|
assert "owner" in members
|
|
assert "normal" in members
|
|
|
|
def test_handle_names_empty_string(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#test")
|
|
rt.handle_names("#test", "")
|
|
assert rt.get_members("#test") == set()
|
|
|
|
def test_names_with_only_prefixes(self):
|
|
rt = ChannelRuntime()
|
|
rt.add_channel("#test")
|
|
rt.handle_names("#test", "@ +")
|
|
assert rt.get_members("#test") == set()
|
|
|
|
|
|
# ========================================================================
|
|
# splits.py 补充
|
|
# ========================================================================
|
|
|
|
class TestSplitMarkdown:
|
|
def test_short_paragraph(self):
|
|
chunks = split_markdown("hello", 500)
|
|
assert chunks == ["hello"]
|
|
|
|
def test_paragraphs_preserved(self):
|
|
text = "para1\n\npara2"
|
|
chunks = split_markdown(text, 500)
|
|
assert len(chunks) == 2
|
|
assert chunks[0] == "para1"
|
|
assert chunks[1] == "para2"
|
|
|
|
def test_long_word_splits(self):
|
|
long_word = "A" * 200
|
|
chunks = split_markdown(long_word, 50)
|
|
for chunk in chunks:
|
|
assert len(chunk.encode("utf-8")) <= 50
|
|
|
|
def test_empty_text(self):
|
|
assert split_markdown("", 500) == [""]
|
|
|
|
def test_zero_limit(self):
|
|
assert split_markdown("hello", 0) == ["hello"]
|
|
|
|
|
|
class TestSplitIrcTextExtended:
|
|
def test_custom_max_chars(self):
|
|
result = split_irc_text("A" * 500, max_chars=50)
|
|
assert len(result) > 1
|
|
|
|
def test_default_max_chars(self):
|
|
short = "hello"
|
|
result = split_irc_text(short)
|
|
assert result == ["hello"]
|
|
|
|
|
|
# ========================================================================
|
|
# auth.py 补充
|
|
# ========================================================================
|
|
|
|
class TestResolvePassword:
|
|
def test_direct_password(self):
|
|
result = resolve_password(password="directpass")
|
|
assert result == "directpass"
|
|
|
|
def test_password_file(self, tmp_path):
|
|
pw_file = tmp_path / "password.txt"
|
|
pw_file.write_text("filepass")
|
|
result = resolve_password(password_file=str(pw_file))
|
|
assert result == "filepass"
|
|
|
|
def test_password_file_priority(self, tmp_path):
|
|
pw_file = tmp_path / "password.txt"
|
|
pw_file.write_text("filepass")
|
|
result = resolve_password(password="inline", password_file=str(pw_file))
|
|
assert result == "filepass"
|
|
|
|
def test_env_var(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_TEST_PASS", "envpass")
|
|
result = resolve_password(env_var="IRC_TEST_PASS")
|
|
assert result == "envpass"
|
|
|
|
def test_env_var_fallback(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_TEST_PASS", "envpass")
|
|
result = resolve_password(password="", password_file="", env_var="IRC_TEST_PASS")
|
|
assert result == "envpass"
|
|
|
|
def test_empty_result(self):
|
|
result = resolve_password()
|
|
assert result == ""
|
|
|
|
def test_nonexistent_file(self):
|
|
result = resolve_password(password_file="/nonexistent/path")
|
|
assert result == ""
|
|
|
|
|
|
class TestGetServerPassword:
|
|
def test_from_config(self):
|
|
config = {"server_password": "spass"}
|
|
assert get_server_password(config) == "spass"
|
|
|
|
def test_from_file(self, tmp_path):
|
|
pw_file = tmp_path / "server_pass.txt"
|
|
pw_file.write_text("filepass")
|
|
config = {"server_password_file": str(pw_file)}
|
|
assert get_server_password(config) == "filepass"
|
|
|
|
def test_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("IRC_SERVER_PASSWORD", "envpass")
|
|
assert get_server_password({}) == "envpass"
|
|
|
|
|
|
# ========================================================================
|
|
# ctcp.py 补充
|
|
# ========================================================================
|
|
|
|
class TestHandleCtcp:
|
|
@pytest.mark.asyncio
|
|
async def test_ping_responds(self):
|
|
send_fn = MagicMock()
|
|
result = await handle_ctcp(send_fn, "alice", "\x01PING 12345\x01")
|
|
assert result is False
|
|
send_fn.assert_called_once()
|
|
assert "PING" in send_fn.call_args[0][0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_ctcp(self):
|
|
send_fn = MagicMock()
|
|
result = await handle_ctcp(send_fn, "alice", "plain text")
|
|
assert result is False
|
|
send_fn.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_returns_true(self):
|
|
send_fn = MagicMock()
|
|
result = await handle_ctcp(send_fn, "alice", "\x01ACTION waves\x01")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_version_responds(self):
|
|
send_fn = MagicMock()
|
|
await handle_ctcp(send_fn, "alice", "\x01VERSION\x01")
|
|
assert any("ForcePilot" in c[0][0] for c in send_fn.call_args_list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_time_responds(self):
|
|
send_fn = MagicMock()
|
|
await handle_ctcp(send_fn, "alice", "\x01TIME\x01")
|
|
assert any("TIME " in c[0][0] for c in send_fn.call_args_list)
|
|
|
|
|
|
class TestExtractCtcpAction:
|
|
def test_action_extracted(self):
|
|
action = extract_ctcp_action("\x01ACTION waves hello\x01")
|
|
assert action == "waves hello"
|
|
|
|
def test_not_action(self):
|
|
assert extract_ctcp_action("\x01PING 123\x01") is None
|
|
|
|
def test_not_ctcp(self):
|
|
assert extract_ctcp_action("plain text") is None
|
|
|
|
|
|
class TestSupportsCtcp:
|
|
def test_returns_true(self):
|
|
assert supports_ctcp() is True
|
|
|
|
|
|
# ========================================================================
|
|
# config_ui_hints.py
|
|
# ========================================================================
|
|
|
|
class TestGetConfigUiHints:
|
|
def test_returns_dict_with_expected_keys(self):
|
|
hints = get_config_ui_hints()
|
|
assert isinstance(hints, dict)
|
|
assert "server" in hints
|
|
assert "nick" in hints
|
|
assert "port" in hints
|
|
assert "use_tls" in hints
|
|
assert "dmPolicy" in hints
|
|
|
|
def test_server_hints_structure(self):
|
|
hints = get_config_ui_hints()
|
|
server = hints["server"]
|
|
assert server["type"] == "text"
|
|
assert server["required"] is True
|
|
assert server["placeholder"] == "irc.libera.chat"
|
|
|
|
def test_password_fields_are_sensitive(self):
|
|
hints = get_config_ui_hints()
|
|
for key in ["nickserv.password", "password", "sasl.password"]:
|
|
if key in hints:
|
|
assert hints[key]["sensitive"] is True, f"{key} should be sensitive"
|
|
|
|
def test_dm_policy_options(self):
|
|
hints = get_config_ui_hints()
|
|
dm = hints["dmPolicy"]
|
|
assert dm["type"] == "select"
|
|
options = [o["value"] for o in dm["options"]]
|
|
assert "pairing" in options
|
|
assert "open" in options
|
|
assert "disabled" in options
|
|
|
|
def test_chunker_mode_options(self):
|
|
hints = get_config_ui_hints()
|
|
chunker = hints["chunker_mode"]
|
|
options = [o["value"] for o in chunker["options"]]
|
|
assert "length" in options
|
|
assert "markdown" in options
|
|
|
|
|
|
# ========================================================================
|
|
# 边界条件与异常处理综合测试
|
|
# ========================================================================
|
|
|
|
class TestEdgeCases:
|
|
def test_parse_irc_tags_malformed(self):
|
|
tags = parse_irc_tags("@msgid PING")
|
|
assert tags == {"msgid": True}
|
|
|
|
def test_parse_irc_tags_edge_at_only(self):
|
|
tags = parse_irc_tags("@ PING")
|
|
assert tags is not None
|
|
|
|
def test_resolve_allowlist_match_partial_host(self):
|
|
result = resolve_allowlist_match(["nick@host.com"], ["*@host*"])
|
|
assert result == "*@host*"
|
|
|
|
def test_build_allowlist_candidates_unicode(self):
|
|
candidates = build_allowlist_candidates("昵称测试", "user", "host")
|
|
assert "昵称测试" in candidates
|
|
assert "昵称测试!user@host" in candidates
|
|
|
|
def test_directory_empty_config(self):
|
|
assert list_users({}) == []
|
|
assert list_groups({}) == []
|
|
|
|
def test_secret_contract_all_keys_unique(self):
|
|
keys = [t.key for t in IRC_SECRET_TARGETS]
|
|
assert len(keys) == len(set(keys))
|
|
|
|
def test_known_servers_all_have_tls(self):
|
|
for _name, details in KNOWN_IRC_SERVERS.items():
|
|
assert details["tls"] is True, f"{_name} should have TLS enabled"
|
|
|
|
def test_commands_parse_nested_args(self):
|
|
cmd, args = parse_command("!cmd a b c d e")
|
|
assert cmd == "cmd"
|
|
assert len(args) == 5
|
|
|
|
def test_send_cache_max_entries_custom(self):
|
|
cache = SentMessageCache(max_entries=2)
|
|
cache.put("a", "#t", "x")
|
|
cache.put("b", "#t", "y")
|
|
cache.put("c", "#t", "z")
|
|
assert cache.size() == 2
|
|
|
|
def test_convert_markdown_tables_no_separator_still_rendered(self):
|
|
text = "| Name |\n| Alice |"
|
|
result = convert_markdown_tables(text, table_mode="plain")
|
|
assert "Name" in result |