Files
derp/tests/test_webhook.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

392 lines
13 KiB
Python

"""Tests for the webhook listener plugin."""
import asyncio
import hashlib
import hmac
import importlib.util
import json
import sys
import time
from pathlib import Path
from derp.irc import Message
# plugins/ is not a Python package -- load the module from file path
_spec = importlib.util.spec_from_file_location(
"plugins.webhook", Path(__file__).resolve().parent.parent / "plugins" / "webhook.py",
)
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = _mod
_spec.loader.exec_module(_mod)
from plugins.webhook import ( # noqa: E402
_MAX_BODY,
_handle_request,
_http_response,
_ps,
_verify_signature,
cmd_webhook,
on_connect,
)
# -- Helpers -----------------------------------------------------------------
class _FakeState:
"""In-memory stand-in for bot.state."""
def __init__(self):
self._store: dict[str, dict[str, str]] = {}
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
return self._store.get(plugin, {}).get(key, default)
def set(self, plugin: str, key: str, value: str) -> None:
self._store.setdefault(plugin, {})[key] = value
def delete(self, plugin: str, key: str) -> bool:
try:
del self._store[plugin][key]
return True
except KeyError:
return False
def keys(self, plugin: str) -> list[str]:
return sorted(self._store.get(plugin, {}).keys())
class _FakeBot:
"""Minimal bot stand-in that captures sent/action messages."""
def __init__(self, *, admin: bool = True, webhook_cfg: dict | None = None):
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.actions: list[tuple[str, str]] = []
self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin
self.prefix = "!"
self.config = {
"webhook": webhook_cfg or {"enabled": False},
}
async def send(self, target: str, text: str) -> None:
self.sent.append((target, text))
async def reply(self, message, text: str) -> None:
self.replied.append(text)
async def action(self, target: str, text: str) -> None:
self.actions.append((target, text))
def _is_admin(self, message) -> bool:
return self._admin
def _msg(text: str, nick: str = "admin", target: str = "#test") -> Message:
return Message(
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
command="PRIVMSG", params=[target, text], tags={},
)
def _sign(secret: str, body: bytes) -> str:
"""Generate HMAC-SHA256 signature."""
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={sig}"
class _FakeReader:
"""Mock asyncio.StreamReader from raw HTTP bytes."""
def __init__(self, data: bytes) -> None:
self._data = data
self._pos = 0
async def readline(self) -> bytes:
start = self._pos
idx = self._data.find(b"\n", start)
if idx == -1:
self._pos = len(self._data)
return self._data[start:]
self._pos = idx + 1
return self._data[start:self._pos]
async def readexactly(self, n: int) -> bytes:
chunk = self._data[self._pos:self._pos + n]
self._pos += n
return chunk
class _FakeWriter:
"""Mock asyncio.StreamWriter that captures output."""
def __init__(self) -> None:
self.data = b""
self._closed = False
def write(self, data: bytes) -> None:
self.data += data
def close(self) -> None:
self._closed = True
async def wait_closed(self) -> None:
pass
def _build_request(method: str, body: bytes, headers: dict[str, str] | None = None) -> bytes:
"""Build raw HTTP request bytes."""
hdrs = headers or {}
if "Content-Length" not in hdrs:
hdrs["Content-Length"] = str(len(body))
lines = [f"{method} / HTTP/1.1"]
for k, v in hdrs.items():
lines.append(f"{k}: {v}")
lines.append("")
lines.append("")
return "\r\n".join(lines).encode("utf-8") + body
# ---------------------------------------------------------------------------
# TestVerifySignature
# ---------------------------------------------------------------------------
class TestVerifySignature:
def test_valid_signature(self):
body = b'{"channel":"#test","text":"hello"}'
sig = _sign("secret", body)
assert _verify_signature("secret", body, sig) is True
def test_invalid_signature(self):
body = b'{"channel":"#test","text":"hello"}'
assert _verify_signature("secret", body, "sha256=bad") is False
def test_empty_secret_allows_all(self):
body = b'{"channel":"#test","text":"hello"}'
assert _verify_signature("", body, "") is True
def test_missing_prefix(self):
body = b'{"channel":"#test","text":"hello"}'
sig = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
assert _verify_signature("secret", body, sig) is False
# ---------------------------------------------------------------------------
# TestHttpResponse
# ---------------------------------------------------------------------------
class TestHttpResponse:
def test_200_response(self):
resp = _http_response(200, "OK", "sent")
assert b"200 OK" in resp
assert b"sent" in resp
def test_400_with_body(self):
resp = _http_response(400, "Bad Request", "invalid JSON")
assert b"400 Bad Request" in resp
assert b"invalid JSON" in resp
def test_405_response(self):
resp = _http_response(405, "Method Not Allowed", "POST only")
assert b"405 Method Not Allowed" in resp
# ---------------------------------------------------------------------------
# TestRequestHandler
# ---------------------------------------------------------------------------
class TestRequestHandler:
def test_valid_post(self):
bot = _FakeBot()
body = json.dumps({"channel": "#ops", "text": "deploy done"}).encode()
sig = _sign("secret", body)
raw = _build_request("POST", body, {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
"X-Signature": sig,
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, "secret"))
assert b"200 OK" in writer.data
assert ("#ops", "deploy done") in bot.sent
def test_action_post(self):
bot = _FakeBot()
body = json.dumps({
"channel": "#ops", "text": "deployed", "action": True,
}).encode()
sig = _sign("s", body)
raw = _build_request("POST", body, {
"Content-Length": str(len(body)),
"X-Signature": sig,
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, "s"))
assert b"200 OK" in writer.data
assert ("#ops", "deployed") in bot.actions
def test_get_405(self):
bot = _FakeBot()
raw = _build_request("GET", b"")
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"405" in writer.data
def test_bad_signature_401(self):
bot = _FakeBot()
body = json.dumps({"channel": "#test", "text": "x"}).encode()
raw = _build_request("POST", body, {
"Content-Length": str(len(body)),
"X-Signature": "sha256=wrong",
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, "real-secret"))
assert b"401" in writer.data
assert len(bot.sent) == 0
def test_bad_json_400(self):
bot = _FakeBot()
body = b"not json"
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"400" in writer.data
assert b"invalid JSON" in writer.data
def test_missing_channel_400(self):
bot = _FakeBot()
body = json.dumps({"text": "no channel"}).encode()
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"400" in writer.data
assert b"invalid channel" in writer.data
def test_invalid_channel_400(self):
bot = _FakeBot()
body = json.dumps({"channel": "nochanprefix", "text": "x"}).encode()
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"400" in writer.data
def test_empty_text_400(self):
bot = _FakeBot()
body = json.dumps({"channel": "#test", "text": ""}).encode()
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"400" in writer.data
assert b"empty text" in writer.data
def test_body_too_large_413(self):
bot = _FakeBot()
raw = _build_request("POST", b"", {
"Content-Length": str(_MAX_BODY + 1),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert b"413" in writer.data
def test_counter_increments(self):
bot = _FakeBot()
ps = _ps(bot)
ps["request_count"] = 0
body = json.dumps({"channel": "#test", "text": "hi"}).encode()
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, ""))
assert ps["request_count"] == 1
# ---------------------------------------------------------------------------
# TestServerLifecycle
# ---------------------------------------------------------------------------
class TestServerLifecycle:
def test_disabled_config(self):
"""Server does not start when webhook is disabled."""
bot = _FakeBot(webhook_cfg={"enabled": False})
msg = Message(raw="", prefix="", nick="", command="001",
params=["test", "Welcome"], tags={})
asyncio.run(on_connect(bot, msg))
assert _ps(bot)["server"] is None
def test_duplicate_guard(self):
"""Second on_connect does not create a second server."""
sentinel = object()
bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0})
_ps(bot)["server"] = sentinel
msg = Message(raw="", prefix="", nick="", command="001",
params=["test", "Welcome"], tags={})
asyncio.run(on_connect(bot, msg))
assert _ps(bot)["server"] is sentinel
def test_on_connect_starts(self):
"""on_connect starts the server when enabled."""
bot = _FakeBot(webhook_cfg={
"enabled": True, "host": "127.0.0.1", "port": 0, "secret": "",
})
msg = Message(raw="", prefix="", nick="", command="001",
params=["test", "Welcome"], tags={})
async def _run():
await on_connect(bot, msg)
ps = _ps(bot)
assert ps["server"] is not None
ps["server"].close()
await ps["server"].wait_closed()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# TestWebhookCommand
# ---------------------------------------------------------------------------
class TestWebhookCommand:
def test_not_running(self):
bot = _FakeBot()
asyncio.run(cmd_webhook(bot, _msg("!webhook")))
assert any("not running" in r for r in bot.replied)
def test_running_shows_status(self):
bot = _FakeBot()
ps = _ps(bot)
ps["request_count"] = 42
ps["started"] = time.monotonic() - 90 # 1m 30s ago
async def _run():
# Start a real server on port 0 to get a valid socket
srv = await asyncio.start_server(lambda r, w: None,
"127.0.0.1", 0)
ps["server"] = srv
try:
await cmd_webhook(bot, _msg("!webhook"))
finally:
srv.close()
await srv.wait_closed()
asyncio.run(_run())
assert len(bot.replied) == 1
reply = bot.replied[0]
assert "Webhook:" in reply
assert "42 requests" in reply
assert "127.0.0.1:" in reply