feat: add granular ACL tiers (trusted/oper/admin)
4-tier permission model: user < trusted < oper < admin. Commands specify a required tier via tier= parameter. Backward compatible: admin=True maps to tier="admin". - TIERS constant and Handler.tier field in plugin.py - _get_tier() method in bot.py with pattern matching - _is_admin() preserved as thin wrapper - operators/trusted config lists in config.py - whoami shows tier, admins shows all configured tiers - 32 test cases in test_acl.py
This commit is contained in:
409
tests/test_acl.py
Normal file
409
tests/test_acl.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""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(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
|
||||
@@ -274,7 +274,7 @@ class TestAdmin:
|
||||
|
||||
replies = h.sent_privmsgs("#test")
|
||||
assert not any("Permission denied" in r for r in replies)
|
||||
assert any("Patterns:" in r for r in replies)
|
||||
assert any("Admin:" in r for r in replies)
|
||||
|
||||
def test_oper_detection(self):
|
||||
"""IRC operator detected via WHO reply can use admin commands."""
|
||||
@@ -290,7 +290,7 @@ class TestAdmin:
|
||||
|
||||
replies = h.sent_privmsgs("#test")
|
||||
assert not any("Permission denied" in r for r in replies)
|
||||
assert any("Opers:" in r for r in replies)
|
||||
assert any("IRCOPs:" in r for r in replies)
|
||||
|
||||
def test_oper_detection_on_join(self):
|
||||
"""User joining a channel triggers debounced WHO for oper detection."""
|
||||
|
||||
Reference in New Issue
Block a user