Files
derp/tests/test_teams.py
user 9d4cb09069 feat: make SOCKS5 proxy configurable per adapter
Add `proxy` config option to server (IRC), teams, telegram, and mumble
sections. IRC defaults to false (preserving current direct-connect
behavior); all others default to true. The `derp.http` module now
accepts `proxy=True/False` on urlopen, create_connection,
open_connection, and build_opener -- when false, uses stdlib directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:19:22 +01:00

760 lines
24 KiB
Python

"""Tests for the Microsoft Teams adapter."""
import asyncio
import base64
import hashlib
import hmac
import json
from derp.plugin import PluginRegistry
from derp.teams import (
_MAX_BODY,
TeamsBot,
TeamsMessage,
_build_teams_message,
_http_response,
_json_response,
_parse_activity,
_strip_mention,
_verify_hmac,
)
# -- Helpers -----------------------------------------------------------------
def _make_bot(secret="", admins=None, operators=None, trusted=None,
incoming_url=""):
"""Create a TeamsBot with test config."""
config = {
"teams": {
"enabled": True,
"bot_name": "derp",
"bind": "127.0.0.1",
"port": 0,
"webhook_secret": secret,
"incoming_webhook_url": incoming_url,
"admins": admins or [],
"operators": operators or [],
"trusted": trusted or [],
},
"bot": {
"prefix": "!",
"paste_threshold": 4,
"plugins_dir": "plugins",
"rate_limit": 2.0,
"rate_burst": 5,
},
}
registry = PluginRegistry()
return TeamsBot("teams-test", config, registry)
def _activity(text="hello", nick="Alice", aad_id="aad-123",
conv_id="conv-456", msg_type="message"):
"""Build a minimal Teams Activity dict."""
return {
"type": msg_type,
"from": {"name": nick, "aadObjectId": aad_id},
"conversation": {"id": conv_id},
"text": text,
}
def _teams_msg(text="!ping", nick="Alice", aad_id="aad-123",
target="conv-456"):
"""Create a TeamsMessage for command testing."""
return TeamsMessage(
raw={}, nick=nick, prefix=aad_id, text=text, target=target,
params=[target, text],
)
def _sign_teams(secret: str, body: bytes) -> str:
"""Generate Teams HMAC-SHA256 Authorization header value."""
key = base64.b64decode(secret)
sig = base64.b64encode(
hmac.new(key, body, hashlib.sha256).digest(),
).decode("ascii")
return f"HMAC {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, path: 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} {path} 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
# -- Test helpers for registering commands -----------------------------------
async def _echo_handler(bot, msg):
"""Simple command handler that echoes text."""
args = msg.text.split(None, 1)
reply = args[1] if len(args) > 1 else "no args"
await bot.reply(msg, reply)
async def _admin_handler(bot, msg):
"""Admin-only command handler."""
await bot.reply(msg, "admin action done")
# ---------------------------------------------------------------------------
# TestTeamsMessage
# ---------------------------------------------------------------------------
class TestTeamsMessage:
def test_defaults(self):
msg = TeamsMessage(raw={}, nick=None, prefix=None, text=None,
target=None)
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
assert msg.params == []
assert msg.tags == {}
assert msg._replies == []
def test_custom_values(self):
msg = TeamsMessage(
raw={"type": "message"}, nick="Alice", prefix="aad-123",
text="hello", target="conv-456", is_channel=True,
command="PRIVMSG", params=["conv-456", "hello"],
tags={"key": "val"},
)
assert msg.nick == "Alice"
assert msg.prefix == "aad-123"
assert msg.text == "hello"
assert msg.target == "conv-456"
assert msg.tags == {"key": "val"}
def test_duck_type_compat(self):
"""TeamsMessage has the same attribute names as IRC Message."""
msg = _teams_msg()
attrs = ["raw", "nick", "prefix", "text", "target",
"is_channel", "command", "params", "tags"]
for attr in attrs:
assert hasattr(msg, attr), f"missing attribute: {attr}"
def test_replies_buffer(self):
msg = _teams_msg()
assert msg._replies == []
msg._replies.append("pong")
msg._replies.append("line2")
assert len(msg._replies) == 2
def test_raw_dict(self):
activity = {"type": "message", "id": "123"}
msg = TeamsMessage(raw=activity, nick=None, prefix=None,
text=None, target=None)
assert msg.raw is activity
def test_prefix_is_aad_id(self):
msg = _teams_msg(aad_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
assert msg.prefix == "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# ---------------------------------------------------------------------------
# TestVerifyHmac
# ---------------------------------------------------------------------------
class TestVerifyHmac:
def test_valid_signature(self):
# base64-encoded secret
secret = base64.b64encode(b"test-secret").decode()
body = b'{"type":"message","text":"hello"}'
auth = _sign_teams(secret, body)
assert _verify_hmac(secret, body, auth) is True
def test_invalid_signature(self):
secret = base64.b64encode(b"test-secret").decode()
body = b'{"type":"message","text":"hello"}'
assert _verify_hmac(secret, body, "HMAC badsignature") is False
def test_missing_hmac_prefix(self):
secret = base64.b64encode(b"test-secret").decode()
body = b'{"text":"hello"}'
# No "HMAC " prefix
key = base64.b64decode(secret)
sig = base64.b64encode(
hmac.new(key, body, hashlib.sha256).digest()
).decode()
assert _verify_hmac(secret, body, sig) is False
def test_empty_secret_allows_all(self):
assert _verify_hmac("", b"any body", "") is True
assert _verify_hmac("", b"any body", "HMAC whatever") is True
def test_invalid_base64_secret(self):
assert _verify_hmac("not-valid-b64!!!", b"body", "HMAC x") is False
# ---------------------------------------------------------------------------
# TestStripMention
# ---------------------------------------------------------------------------
class TestStripMention:
def test_strip_at_mention(self):
assert _strip_mention("<at>derp</at> !help", "derp") == "!help"
def test_strip_with_extra_spaces(self):
assert _strip_mention("<at>derp</at> !ping", "derp") == "!ping"
def test_no_mention(self):
assert _strip_mention("!help", "derp") == "!help"
def test_multiple_mentions(self):
text = "<at>derp</at> hello <at>other</at> world"
assert _strip_mention(text, "derp") == "hello world"
def test_empty_text(self):
assert _strip_mention("", "derp") == ""
def test_mention_only(self):
assert _strip_mention("<at>derp</at>", "derp") == ""
# ---------------------------------------------------------------------------
# TestParseActivity
# ---------------------------------------------------------------------------
class TestParseActivity:
def test_valid_activity(self):
body = json.dumps({"type": "message", "text": "hello"}).encode()
result = _parse_activity(body)
assert result == {"type": "message", "text": "hello"}
def test_invalid_json(self):
assert _parse_activity(b"not json") is None
def test_not_a_dict(self):
assert _parse_activity(b'["array"]') is None
def test_empty_body(self):
assert _parse_activity(b"") is None
def test_unicode_error(self):
assert _parse_activity(b"\xff\xfe") is None
# ---------------------------------------------------------------------------
# TestBuildTeamsMessage
# ---------------------------------------------------------------------------
class TestBuildTeamsMessage:
def test_basic_message(self):
activity = _activity(text="<at>derp</at> !ping")
msg = _build_teams_message(activity, "derp")
assert msg.nick == "Alice"
assert msg.prefix == "aad-123"
assert msg.text == "!ping"
assert msg.target == "conv-456"
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
def test_strips_mention(self):
activity = _activity(text="<at>Bot</at> !help commands")
msg = _build_teams_message(activity, "Bot")
assert msg.text == "!help commands"
def test_missing_from(self):
activity = {"type": "message", "text": "hello",
"conversation": {"id": "conv"}}
msg = _build_teams_message(activity, "derp")
assert msg.nick is None
assert msg.prefix is None
def test_missing_conversation(self):
activity = {"type": "message", "text": "hello",
"from": {"name": "Alice", "aadObjectId": "aad"}}
msg = _build_teams_message(activity, "derp")
assert msg.target is None
def test_raw_preserved(self):
activity = _activity()
msg = _build_teams_message(activity, "derp")
assert msg.raw is activity
def test_params_populated(self):
activity = _activity(text="<at>derp</at> !test arg")
msg = _build_teams_message(activity, "derp")
assert msg.params[0] == "conv-456"
assert msg.params[1] == "!test arg"
# ---------------------------------------------------------------------------
# TestTeamsBotReply
# ---------------------------------------------------------------------------
class TestTeamsBotReply:
def test_reply_appends(self):
bot = _make_bot()
msg = _teams_msg()
asyncio.run(bot.reply(msg, "pong"))
assert msg._replies == ["pong"]
def test_multi_reply(self):
bot = _make_bot()
msg = _teams_msg()
async def _run():
await bot.reply(msg, "line 1")
await bot.reply(msg, "line 2")
await bot.reply(msg, "line 3")
asyncio.run(_run())
assert msg._replies == ["line 1", "line 2", "line 3"]
def test_long_reply_under_threshold(self):
bot = _make_bot()
msg = _teams_msg()
lines = ["a", "b", "c"]
asyncio.run(bot.long_reply(msg, lines))
assert msg._replies == ["a", "b", "c"]
def test_long_reply_over_threshold_no_paste(self):
"""Over threshold with no FlaskPaste sends all lines."""
bot = _make_bot()
msg = _teams_msg()
lines = ["a", "b", "c", "d", "e", "f"] # 6 > threshold of 4
asyncio.run(bot.long_reply(msg, lines))
assert msg._replies == lines
def test_long_reply_empty(self):
bot = _make_bot()
msg = _teams_msg()
asyncio.run(bot.long_reply(msg, []))
assert msg._replies == []
def test_action_format(self):
"""action() maps to italic text via send()."""
bot = _make_bot(incoming_url="http://example.com/hook")
# action sends to incoming webhook; without actual URL it logs debug
bot._incoming_url = ""
asyncio.run(bot.action("conv", "does a thing"))
# No incoming URL, so send() is a no-op (debug log)
def test_send_no_incoming_url(self):
"""send() is a no-op when no incoming_webhook_url is configured."""
bot = _make_bot()
# Should not raise
asyncio.run(bot.send("target", "text"))
# ---------------------------------------------------------------------------
# TestTeamsBotTier
# ---------------------------------------------------------------------------
class TestTeamsBotTier:
def test_admin_tier(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id="aad-admin")
assert bot._get_tier(msg) == "admin"
def test_oper_tier(self):
bot = _make_bot(operators=["aad-oper"])
msg = _teams_msg(aad_id="aad-oper")
assert bot._get_tier(msg) == "oper"
def test_trusted_tier(self):
bot = _make_bot(trusted=["aad-trusted"])
msg = _teams_msg(aad_id="aad-trusted")
assert bot._get_tier(msg) == "trusted"
def test_user_tier_default(self):
bot = _make_bot()
msg = _teams_msg(aad_id="aad-unknown")
assert bot._get_tier(msg) == "user"
def test_no_prefix(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id=None)
msg.prefix = None
assert bot._get_tier(msg) == "user"
def test_is_admin_true(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id="aad-admin")
assert bot._is_admin(msg) is True
def test_is_admin_false(self):
bot = _make_bot()
msg = _teams_msg(aad_id="aad-nobody")
assert bot._is_admin(msg) is False
def test_priority_order(self):
"""Admin takes priority over oper and trusted."""
bot = _make_bot(admins=["aad-x"], operators=["aad-x"],
trusted=["aad-x"])
msg = _teams_msg(aad_id="aad-x")
assert bot._get_tier(msg) == "admin"
# ---------------------------------------------------------------------------
# TestTeamsBotDispatch
# ---------------------------------------------------------------------------
class TestTeamsBotDispatch:
def test_dispatch_known_command(self):
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, help="echo", plugin="test")
msg = _teams_msg(text="!echo world")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["world"]
def test_dispatch_unknown_command(self):
bot = _make_bot()
msg = _teams_msg(text="!nonexistent")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_no_prefix(self):
bot = _make_bot()
msg = _teams_msg(text="just a message")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_empty_text(self):
bot = _make_bot()
msg = _teams_msg(text="")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_none_text(self):
bot = _make_bot()
msg = _teams_msg(text=None)
msg.text = None
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_ambiguous(self):
bot = _make_bot()
bot.registry.register_command(
"ping", _echo_handler, plugin="test")
bot.registry.register_command(
"plugins", _echo_handler, plugin="test")
msg = _teams_msg(text="!p")
asyncio.run(bot._dispatch_command(msg))
assert len(msg._replies) == 1
assert "Ambiguous" in msg._replies[0]
def test_dispatch_tier_denied(self):
bot = _make_bot()
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _teams_msg(text="!secret", aad_id="aad-nobody")
asyncio.run(bot._dispatch_command(msg))
assert len(msg._replies) == 1
assert "Permission denied" in msg._replies[0]
def test_dispatch_tier_allowed(self):
bot = _make_bot(admins=["aad-admin"])
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _teams_msg(text="!secret", aad_id="aad-admin")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["admin action done"]
def test_dispatch_prefix_match(self):
"""Unambiguous prefix resolves to the full command."""
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, plugin="test")
msg = _teams_msg(text="!ec hello")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["hello"]
# ---------------------------------------------------------------------------
# TestTeamsBotNoOps
# ---------------------------------------------------------------------------
class TestTeamsBotNoOps:
def test_join_noop(self):
bot = _make_bot()
asyncio.run(bot.join("#channel"))
def test_part_noop(self):
bot = _make_bot()
asyncio.run(bot.part("#channel", "reason"))
def test_kick_noop(self):
bot = _make_bot()
asyncio.run(bot.kick("#channel", "nick", "reason"))
def test_mode_noop(self):
bot = _make_bot()
asyncio.run(bot.mode("#channel", "+o", "nick"))
def test_set_topic_noop(self):
bot = _make_bot()
asyncio.run(bot.set_topic("#channel", "new topic"))
def test_quit_stops(self):
bot = _make_bot()
bot._running = True
asyncio.run(bot.quit())
assert bot._running is False
# ---------------------------------------------------------------------------
# TestHTTPHandler
# ---------------------------------------------------------------------------
class TestHTTPHandler:
def _b64_secret(self):
return base64.b64encode(b"test-secret-key").decode()
def test_valid_post_with_reply(self):
secret = self._b64_secret()
bot = _make_bot(secret=secret)
bot.registry.register_command(
"ping", _echo_handler, plugin="test")
activity = _activity(text="<at>derp</at> !ping")
body = json.dumps(activity).encode()
auth = _sign_teams(secret, body)
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
"Authorization": auth,
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["type"] == "message"
def test_get_405(self):
bot = _make_bot()
raw = _build_request("GET", "/api/messages", b"")
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"405" in writer.data
def test_wrong_path_404(self):
bot = _make_bot()
raw = _build_request("POST", "/wrong/path", b"")
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"404" in writer.data
def test_bad_signature_401(self):
secret = self._b64_secret()
bot = _make_bot(secret=secret)
body = json.dumps(_activity()).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Authorization": "HMAC badsignature",
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"401" in writer.data
def test_bad_json_400(self):
bot = _make_bot()
body = b"not json at all"
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"400" in writer.data
assert b"invalid JSON" in writer.data
def test_non_message_activity(self):
bot = _make_bot()
body = json.dumps({"type": "conversationUpdate"}).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["text"] == ""
def test_body_too_large_413(self):
bot = _make_bot()
raw = _build_request("POST", "/api/messages", b"", {
"Content-Length": str(_MAX_BODY + 1),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"413" in writer.data
def test_command_dispatch_full_cycle(self):
"""Full request lifecycle: receive, dispatch, reply."""
bot = _make_bot()
async def _pong(b, m):
await b.reply(m, "pong")
bot.registry.register_command("ping", _pong, plugin="test")
activity = _activity(text="<at>derp</at> !ping")
body = json.dumps(activity).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["text"] == "pong"
# ---------------------------------------------------------------------------
# TestHttpResponse
# ---------------------------------------------------------------------------
class TestHttpResponse:
def test_plain_200(self):
resp = _http_response(200, "OK", "sent")
assert b"200 OK" in resp
assert b"sent" in resp
assert b"text/plain" in resp
def test_json_response(self):
resp = _json_response(200, "OK", {"type": "message", "text": "hi"})
assert b"200 OK" in resp
assert b"application/json" in resp
body = resp.split(b"\r\n\r\n", 1)[1]
data = json.loads(body)
assert data["text"] == "hi"
def test_404_response(self):
resp = _http_response(404, "Not Found")
assert b"404 Not Found" in resp
assert b"Content-Length: 0" in resp
# ---------------------------------------------------------------------------
# TestTeamsBotPluginManagement
# ---------------------------------------------------------------------------
class TestTeamsBotPluginManagement:
def test_load_plugin_not_found(self):
bot = _make_bot()
ok, msg = bot.load_plugin("nonexistent_xyz")
assert ok is False
assert "not found" in msg
def test_load_plugin_already_loaded(self):
bot = _make_bot()
bot.registry._modules["test"] = object()
ok, msg = bot.load_plugin("test")
assert ok is False
assert "already loaded" in msg
def test_unload_core_refused(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("core")
assert ok is False
assert "cannot unload core" in msg
def test_unload_not_loaded(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
def test_reload_delegates(self):
bot = _make_bot()
ok, msg = bot.reload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
class TestTeamsBotConfig:
def test_proxy_default_true(self):
bot = _make_bot()
assert bot._proxy is True
def test_proxy_disabled(self):
config = {
"teams": {
"enabled": True,
"bot_name": "derp",
"bind": "127.0.0.1",
"port": 8081,
"webhook_secret": "",
"incoming_webhook_url": "",
"proxy": False,
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TeamsBot("test", config, PluginRegistry())
assert bot._proxy is False