Files
derp/tests/test_acl.py
user 073659607e feat: add multi-server support
Connect to multiple IRC servers concurrently from a single config file.
Plugins are loaded once and shared; per-server state is isolated via
separate SQLite databases and per-bot runtime state (bot._pstate).

- Add build_server_configs() for [servers.*] config layout
- Bot.__init__ gains name parameter, _pstate dict for plugin isolation
- cli.py runs multiple bots via asyncio.gather
- 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern
- Backward compatible: legacy [server] config works unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:04:20 +01:00

410 lines
14 KiB
Python

"""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", "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