"""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("derp !help", "derp") == "!help" def test_strip_with_extra_spaces(self): assert _strip_mention("derp !ping", "derp") == "!ping" def test_no_mention(self): assert _strip_mention("!help", "derp") == "!help" def test_multiple_mentions(self): text = "derp hello other 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("derp", "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="derp !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="Bot !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="derp !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="derp !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="derp !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