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
|