feat: add admin/owner permission system
Hostmask-based admin controls with automatic IRCOP detection via WHO. Permission enforcement in the central dispatch path denies restricted commands to non-admins. Includes !whoami and !admins commands, marks load/reload/unload as admin-only. Also lands previously-implemented SASL PLAIN auth, token-bucket rate limiting, and CTCP VERSION/TIME/PING responses that were staged but uncommitted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from derp.bot import _AMBIGUOUS, Bot
|
||||
from derp.irc import Message
|
||||
from derp.plugin import PluginRegistry, command, event
|
||||
|
||||
|
||||
@@ -33,6 +34,22 @@ class TestDecorators:
|
||||
assert handler._derp_event == "PRIVMSG"
|
||||
|
||||
|
||||
def test_command_decorator_admin(self):
|
||||
@command("secret", help="admin only", admin=True)
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_command == "secret"
|
||||
assert handler._derp_admin is True
|
||||
|
||||
def test_command_decorator_admin_default(self):
|
||||
@command("public", help="everyone")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert getattr(handler, "_derp_admin", False) is False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Test the plugin registry."""
|
||||
|
||||
@@ -46,6 +63,24 @@ class TestRegistry:
|
||||
assert "test" in registry.commands
|
||||
assert registry.commands["test"].help == "test help"
|
||||
|
||||
def test_register_command_admin(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
registry.register_command("secret", handler, help="admin", admin=True)
|
||||
assert registry.commands["secret"].admin is True
|
||||
|
||||
def test_register_command_admin_default(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
registry.register_command("public", handler, help="public")
|
||||
assert registry.commands["public"].admin is False
|
||||
|
||||
def test_register_event(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
@@ -150,6 +185,27 @@ class TestRegistry:
|
||||
count = registry.load_plugin(plugin_file)
|
||||
assert count == 3
|
||||
|
||||
def test_load_plugin_admin_flag(self, tmp_path: Path):
|
||||
plugin_code = textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("secret", help="Admin only", admin=True)
|
||||
async def cmd_secret(bot, msg):
|
||||
pass
|
||||
|
||||
@command("public", help="Everyone")
|
||||
async def cmd_public(bot, msg):
|
||||
pass
|
||||
""")
|
||||
plugin_file = tmp_path / "mixed.py"
|
||||
plugin_file.write_text(plugin_code)
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_plugin(plugin_file)
|
||||
|
||||
assert registry.commands["secret"].admin is True
|
||||
assert registry.commands["public"].admin is False
|
||||
|
||||
def test_load_plugin_stores_path(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "pathed.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
@@ -366,3 +422,57 @@ class TestPrefixMatch:
|
||||
handler = bot._resolve_command("v")
|
||||
assert handler is not None
|
||||
assert handler.name == "version"
|
||||
|
||||
|
||||
class TestIsAdmin:
|
||||
"""Test admin permission checks."""
|
||||
|
||||
@staticmethod
|
||||
def _make_bot(admins: list[str] | None = None, opers: set[str] | None = None) -> Bot:
|
||||
"""Create a Bot with optional admin patterns and oper set."""
|
||||
config = {
|
||||
"server": {"host": "localhost", "port": 6667, "tls": False,
|
||||
"nick": "test", "user": "test", "realname": "test"},
|
||||
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins",
|
||||
"admins": admins or []},
|
||||
}
|
||||
bot = Bot(config, PluginRegistry())
|
||||
if opers:
|
||||
bot._opers = opers
|
||||
return bot
|
||||
|
||||
@staticmethod
|
||||
def _msg(prefix: str) -> Message:
|
||||
"""Create a minimal Message with a given prefix."""
|
||||
return Message(raw="", prefix=prefix, nick=prefix.split("!")[0],
|
||||
command="PRIVMSG", params=["#test", "!test"])
|
||||
|
||||
def test_no_prefix_not_admin(self):
|
||||
bot = self._make_bot()
|
||||
msg = Message(raw="", prefix=None, nick=None, command="PRIVMSG", params=[])
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
def test_oper_is_admin(self):
|
||||
bot = self._make_bot(opers={"alice!~alice@host"})
|
||||
msg = self._msg("alice!~alice@host")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_hostmask_pattern_match(self):
|
||||
bot = self._make_bot(admins=["*!~user@trusted.host"])
|
||||
msg = self._msg("bob!~user@trusted.host")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_hostmask_pattern_no_match(self):
|
||||
bot = self._make_bot(admins=["*!~user@trusted.host"])
|
||||
msg = self._msg("bob!~other@untrusted.host")
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
def test_wildcard_pattern(self):
|
||||
bot = self._make_bot(admins=["ops!*@*.ops.net"])
|
||||
msg = self._msg("ops!~ident@server.ops.net")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_no_patterns_no_opers(self):
|
||||
bot = self._make_bot()
|
||||
msg = self._msg("nobody!~user@host")
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
Reference in New Issue
Block a user