"""Tests for the granular ACL tier system.""" import asyncio from pathlib import Path from derp.bot import Bot from derp.config import DEFAULTS from derp.irc import Message, parse from derp.plugin import TIERS, PluginRegistry, command # -- Helpers ----------------------------------------------------------------- class _MockConnection: """Minimal mock IRC connection for dispatch tests.""" def __init__(self) -> None: self._queue: asyncio.Queue[str | None] = asyncio.Queue() self.sent: list[str] = [] async def connect(self) -> None: pass async def close(self) -> None: pass async def send(self, line: str) -> None: self.sent.append(line) async def readline(self) -> str | None: return await self._queue.get() def inject(self, line: str) -> None: self._queue.put_nowait(line) def disconnect(self) -> None: self._queue.put_nowait(None) class _Harness: """Test fixture: Bot with mock connection and configurable tiers.""" def __init__( self, admins: list[str] | None = None, operators: list[str] | None = None, trusted: list[str] | None = None, ) -> None: config: dict = { "server": { "host": "localhost", "port": 6667, "tls": False, "nick": "test", "user": "test", "realname": "test bot", }, "bot": { "prefix": "!", "channels": [], "plugins_dir": "plugins", "admins": admins or [], "operators": operators or [], "trusted": trusted or [], "rate_limit": 100.0, "rate_burst": 100, }, } self.registry = PluginRegistry() self.bot = Bot("test", config, self.registry) self.conn = _MockConnection() self.bot.conn = self.conn # type: ignore[assignment] self.registry.load_plugin(Path("plugins/core.py")) def inject_registration(self) -> None: self.conn.inject(":server CAP * LS :") self.conn.inject(":server 001 test :Welcome") def privmsg(self, nick: str, target: str, text: str, user: str = "user", host: str = "host") -> None: self.conn.inject(f":{nick}!{user}@{host} PRIVMSG {target} :{text}") async def run(self) -> None: self.conn.disconnect() self.bot._running = True await self.bot._connect_and_run() if self.bot._tasks: await asyncio.gather(*list(self.bot._tasks), return_exceptions=True) def sent_privmsgs(self, target: str) -> list[str]: results = [] for line in self.conn.sent: msg = parse(line) if msg.command == "PRIVMSG" and msg.target == target: results.append(msg.text) return results def _msg(text: str, prefix: str = "nick!user@host") -> Message: nick = prefix.split("!")[0] if "!" in prefix else prefix return Message( raw="", prefix=prefix, nick=nick, command="PRIVMSG", params=["#test", text], tags={}, ) # --------------------------------------------------------------------------- # TestTierConstants # --------------------------------------------------------------------------- class TestTierConstants: def test_tier_order(self): assert TIERS == ("user", "trusted", "oper", "sorcerer", "admin") def test_index_comparison(self): assert TIERS.index("user") < TIERS.index("trusted") assert TIERS.index("trusted") < TIERS.index("oper") assert TIERS.index("oper") < TIERS.index("admin") # --------------------------------------------------------------------------- # TestGetTier # --------------------------------------------------------------------------- class TestGetTier: def test_no_prefix(self): h = _Harness() msg = Message(raw="", prefix="", nick="", command="PRIVMSG", params=["#test", "hi"], tags={}) assert h.bot._get_tier(msg) == "user" def test_ircop(self): h = _Harness() h.bot._opers.add("op!root@server") msg = _msg("test", prefix="op!root@server") assert h.bot._get_tier(msg) == "admin" def test_admin_pattern(self): h = _Harness(admins=["*!*@admin.host"]) msg = _msg("test", prefix="alice!user@admin.host") assert h.bot._get_tier(msg) == "admin" def test_oper_pattern(self): h = _Harness(operators=["*!*@oper.host"]) msg = _msg("test", prefix="bob!user@oper.host") assert h.bot._get_tier(msg) == "oper" def test_trusted_pattern(self): h = _Harness(trusted=["*!*@trusted.host"]) msg = _msg("test", prefix="carol!user@trusted.host") assert h.bot._get_tier(msg) == "trusted" def test_no_match(self): h = _Harness(admins=["*!*@admin.host"]) msg = _msg("test", prefix="nobody!user@random.host") assert h.bot._get_tier(msg) == "user" def test_priority_admin_over_oper(self): """Admin patterns take priority over operator patterns.""" h = _Harness(admins=["*!*@dual.host"], operators=["*!*@dual.host"]) msg = _msg("test", prefix="x!y@dual.host") assert h.bot._get_tier(msg) == "admin" def test_priority_oper_over_trusted(self): """Operator patterns take priority over trusted patterns.""" h = _Harness(operators=["*!*@dual.host"], trusted=["*!*@dual.host"]) msg = _msg("test", prefix="x!y@dual.host") assert h.bot._get_tier(msg) == "oper" def test_ircop_over_admin_pattern(self): """IRC operator status takes priority over admin hostmask pattern.""" h = _Harness(admins=["*!*@server"]) h.bot._opers.add("op!root@server") msg = _msg("test", prefix="op!root@server") # Both match, but ircop check comes first assert h.bot._get_tier(msg) == "admin" # --------------------------------------------------------------------------- # TestIsAdminBackcompat # --------------------------------------------------------------------------- class TestIsAdminBackcompat: def test_admin_true(self): h = _Harness(admins=["*!*@admin.host"]) msg = _msg("test", prefix="a!b@admin.host") assert h.bot._is_admin(msg) is True def test_oper_false(self): h = _Harness(operators=["*!*@oper.host"]) msg = _msg("test", prefix="a!b@oper.host") assert h.bot._is_admin(msg) is False def test_trusted_false(self): h = _Harness(trusted=["*!*@trusted.host"]) msg = _msg("test", prefix="a!b@trusted.host") assert h.bot._is_admin(msg) is False # --------------------------------------------------------------------------- # TestCommandDecorator # --------------------------------------------------------------------------- class TestCommandDecorator: def test_admin_true_sets_tier(self): @command("x", admin=True) async def handler(bot, msg): pass assert handler._derp_tier == "admin" def test_explicit_tier(self): @command("x", tier="trusted") async def handler(bot, msg): pass assert handler._derp_tier == "trusted" def test_tier_overrides_admin(self): @command("x", admin=True, tier="oper") async def handler(bot, msg): pass assert handler._derp_tier == "oper" def test_default_tier(self): @command("x") async def handler(bot, msg): pass assert handler._derp_tier == "user" # --------------------------------------------------------------------------- # TestDispatchWithTiers # --------------------------------------------------------------------------- class TestDispatchWithTiers: def test_trusted_allowed_for_trusted_cmd(self): """Trusted user can run a trusted-tier command.""" h = _Harness(trusted=["*!user@host"]) @command("tcmd", tier="trusted") async def cmd_tcmd(bot, message): await bot.reply(message, "ok") h.registry.register_command( "tcmd", cmd_tcmd, tier="trusted", plugin="test", ) h.inject_registration() h.privmsg("nick", "#test", "!tcmd") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("ok" in m for m in msgs) def test_admin_allowed_for_trusted_cmd(self): """Admin can run trusted-tier commands (higher tier).""" h = _Harness(admins=["*!user@host"]) @command("tcmd", tier="trusted") async def cmd_tcmd(bot, message): await bot.reply(message, "ok") h.registry.register_command( "tcmd", cmd_tcmd, tier="trusted", plugin="test", ) h.inject_registration() h.privmsg("nick", "#test", "!tcmd") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("ok" in m for m in msgs) def test_user_denied_for_trusted_cmd(self): """Regular user is denied a trusted-tier command.""" h = _Harness() @command("tcmd", tier="trusted") async def cmd_tcmd(bot, message): await bot.reply(message, "ok") h.registry.register_command( "tcmd", cmd_tcmd, tier="trusted", plugin="test", ) h.inject_registration() h.privmsg("nick", "#test", "!tcmd") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("Permission denied" in m for m in msgs) assert any("requires trusted" in m for m in msgs) def test_backward_compat_admin_flag(self): """admin=True commands still work via tier='admin'.""" h = _Harness(admins=["*!user@host"]) h.inject_registration() # cmd_admins is already registered via core plugin with admin=True h.privmsg("nick", "#test", "!admins") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("Admin:" in m for m in msgs) def test_denial_message_shows_tier(self): """Permission denial message includes the required tier name.""" h = _Harness() @command("opcmd", tier="oper") async def cmd_opcmd(bot, message): await bot.reply(message, "ok") h.registry.register_command( "opcmd", cmd_opcmd, tier="oper", plugin="test", ) h.inject_registration() h.privmsg("nick", "#test", "!opcmd") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("requires oper" in m for m in msgs) # --------------------------------------------------------------------------- # TestConfigDefaults # --------------------------------------------------------------------------- class TestConfigDefaults: def test_operators_in_defaults(self): assert "operators" in DEFAULTS["bot"] assert DEFAULTS["bot"]["operators"] == [] def test_trusted_in_defaults(self): assert "trusted" in DEFAULTS["bot"] assert DEFAULTS["bot"]["trusted"] == [] def test_webhook_in_defaults(self): assert "webhook" in DEFAULTS assert DEFAULTS["webhook"]["enabled"] is False # --------------------------------------------------------------------------- # TestWhoami # --------------------------------------------------------------------------- class TestWhoami: def test_shows_admin(self): h = _Harness(admins=["*!user@host"]) h.inject_registration() h.privmsg("nick", "#test", "!whoami") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("admin" in m for m in msgs) def test_shows_trusted(self): h = _Harness(trusted=["*!user@host"]) h.inject_registration() h.privmsg("nick", "#test", "!whoami") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("trusted" in m for m in msgs) def test_shows_user(self): h = _Harness() h.inject_registration() h.privmsg("nick", "#test", "!whoami") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("user" in m for m in msgs) def test_shows_ircop_tag(self): h = _Harness() h.bot._opers.add("nick!user@host") h.inject_registration() h.privmsg("nick", "#test", "!whoami") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") assert any("IRCOP" in m for m in msgs) # --------------------------------------------------------------------------- # TestAdmins # --------------------------------------------------------------------------- class TestAdmins: def test_shows_all_tiers(self): h = _Harness( admins=["*!*@admin.host"], operators=["*!*@oper.host"], trusted=["*!*@trusted.host"], ) h.bot._opers.add("nick!user@host") h.inject_registration() # Must be admin to run !admins h.privmsg("nick", "#test", "!admins", user="x", host="admin.host") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") combined = " ".join(msgs) assert "Admin:" in combined assert "Oper:" in combined assert "Trusted:" in combined assert "IRCOPs:" in combined def test_omits_empty_tiers(self): h = _Harness(admins=["*!user@host"]) h.inject_registration() h.privmsg("nick", "#test", "!admins") asyncio.run(h.run()) msgs = h.sent_privmsgs("#test") combined = " ".join(msgs) assert "Admin:" in combined # No operators or trusted configured, so those sections shouldn't appear assert "Oper:" not in combined assert "Trusted:" not in combined