Files
derp/tests/test_acl.py
user ec55c2aef1 feat: auto-resume music on reconnect, sorcerer tier, cert auth
Auto-resume: save playback position on stream errors and cancellation,
restore automatically after reconnect or container restart once the
channel is silent. Plugin lifecycle hook (on_connected) ensures the
reconnect watcher starts without waiting for user commands.

Sorcerer tier: new permission level between oper and admin. Configured
via [mumble] sorcerers list in derp.toml.

Mumble cert auth: pass certfile/keyfile to pymumble for client
certificate authentication.

Fixes: stream_audio now re-raises CancelledError and Exception so
_play_loop detects failures correctly. Subprocess cleanup uses 3s
timeout. Graceful shutdown cancels background tasks before stopping
pymumble. Safe getattr for _opers in core plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:14:43 +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", "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