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>
410 lines
14 KiB
Python
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
|