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