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>
760 lines
24 KiB
Python
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
|