mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Plugin platforms (IRC, Teams, Google Chat) currently fail with `No live adapter for platform '<name>'` when a `deliver=<plugin>` cron job runs in a separate process from the gateway, even though the platforms are eligible cron targets via `cron_deliver_env_var` (added in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use direct REST helpers in `tools/send_message_tool.py` so cron can deliver without holding the gateway in the same process; plugin platforms historically depended on `_gateway_runner_ref()` which returns `None` out of process. This change adds an optional `standalone_sender_fn` field to `PlatformEntry` so plugins can register an ephemeral send path that opens its own connection, sends, and closes without needing the live adapter. The dispatch site in `_send_via_adapter` falls through to the hook when the gateway runner is unavailable, with a descriptive error when neither path applies. The hook is optional, so existing plugins are unaffected. Reference migrations land in the same change for IRC, Teams, and Google Chat, exercising the hook across stdlib (asyncio + IRC protocol), Bot Framework OAuth client_credentials, and Google service-account flows respectively. Security hardening on the new code paths: * IRC: control-character stripping on chat_id and message body to block CRLF command injection; bounded nick-collision retries; JOIN before PRIVMSG so channels with the default `+n` mode accept the delivery. * Teams: TEAMS_SERVICE_URL validated against an allowlist of known Bot Framework hosts (`smba.trafficmanager.net`, `smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and tenant_id constrained to the documented Bot Framework character set; per-request timeouts so a slow STS endpoint cannot starve the activity POST. * Google Chat: chat_id and thread_id validated against strict resource-name regexes; service-account refresh wrapped in `asyncio.wait_for` so a hung token endpoint cannot stall the scheduler. Test coverage: 20 new tests covering happy path, missing-config errors, network failure modes, and each defensive validation. Existing tests unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions. Documentation: new "Out-of-process cron delivery" section in website/docs/developer-guide/adding-platform-adapters.md and an entry in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
879 lines
33 KiB
Python
879 lines
33 KiB
Python
"""Tests for the Microsoft Teams platform adapter plugin."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig, HomeChannel
|
|
from plugins.teams_pipeline.models import TeamsMeetingRef, TeamsMeetingSummaryPayload
|
|
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SDK Mock — install in sys.modules before importing the adapter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ensure_teams_mock():
|
|
"""Install a teams SDK mock in sys.modules if the real package isn't present."""
|
|
if "microsoft_teams" in sys.modules and hasattr(sys.modules["microsoft_teams"], "__file__"):
|
|
return
|
|
|
|
# Build the module hierarchy
|
|
microsoft_teams = types.ModuleType("microsoft_teams")
|
|
microsoft_teams_apps = types.ModuleType("microsoft_teams.apps")
|
|
microsoft_teams_api = types.ModuleType("microsoft_teams.api")
|
|
microsoft_teams_api_activities = types.ModuleType("microsoft_teams.api.activities")
|
|
microsoft_teams_api_activities_typing = types.ModuleType("microsoft_teams.api.activities.typing")
|
|
microsoft_teams_api_activities_invoke = types.ModuleType("microsoft_teams.api.activities.invoke")
|
|
microsoft_teams_api_activities_invoke_adaptive_card = types.ModuleType(
|
|
"microsoft_teams.api.activities.invoke.adaptive_card"
|
|
)
|
|
microsoft_teams_common = types.ModuleType("microsoft_teams.common")
|
|
microsoft_teams_common_http = types.ModuleType("microsoft_teams.common.http")
|
|
microsoft_teams_common_http_client = types.ModuleType("microsoft_teams.common.http.client")
|
|
microsoft_teams_api_models = types.ModuleType("microsoft_teams.api.models")
|
|
microsoft_teams_api_models_adaptive_card = types.ModuleType("microsoft_teams.api.models.adaptive_card")
|
|
microsoft_teams_api_models_invoke_response = types.ModuleType("microsoft_teams.api.models.invoke_response")
|
|
microsoft_teams_cards = types.ModuleType("microsoft_teams.cards")
|
|
microsoft_teams_apps_http = types.ModuleType("microsoft_teams.apps.http")
|
|
microsoft_teams_apps_http_adapter = types.ModuleType("microsoft_teams.apps.http.adapter")
|
|
|
|
# App class mock
|
|
class MockApp:
|
|
def __init__(self, **kwargs):
|
|
self._client_id = kwargs.get("client_id")
|
|
self.server = MagicMock()
|
|
self.server.handle_request = AsyncMock(return_value={"status": 200, "body": None})
|
|
self.credentials = MagicMock()
|
|
self.credentials.client_id = self._client_id
|
|
|
|
@property
|
|
def id(self):
|
|
return self._client_id
|
|
|
|
def on_message(self, func):
|
|
self._message_handler = func
|
|
return func
|
|
|
|
def on_card_action(self, func):
|
|
self._card_action_handler = func
|
|
return func
|
|
|
|
async def initialize(self):
|
|
pass
|
|
|
|
async def send(self, conversation_id, activity):
|
|
result = MagicMock()
|
|
result.id = "sent-activity-id"
|
|
return result
|
|
|
|
async def start(self, port=3978):
|
|
pass
|
|
|
|
async def stop(self):
|
|
pass
|
|
|
|
microsoft_teams_apps.App = MockApp
|
|
microsoft_teams_apps.ActivityContext = MagicMock
|
|
microsoft_teams_common_http_client.ClientOptions = MagicMock
|
|
|
|
# MessageActivity mock
|
|
microsoft_teams_api.MessageActivity = MagicMock
|
|
microsoft_teams_api.ConversationReference = MagicMock
|
|
microsoft_teams_api.MessageActivityInput = MagicMock
|
|
|
|
# TypingActivityInput mock
|
|
class MockTypingActivityInput:
|
|
pass
|
|
|
|
microsoft_teams_api_activities_typing.TypingActivityInput = MockTypingActivityInput
|
|
|
|
# Adaptive card invoke activity mock
|
|
microsoft_teams_api_activities_invoke_adaptive_card.AdaptiveCardInvokeActivity = MagicMock
|
|
|
|
# Adaptive card response mocks
|
|
microsoft_teams_api_models_adaptive_card.AdaptiveCardActionCardResponse = MagicMock
|
|
microsoft_teams_api_models_adaptive_card.AdaptiveCardActionMessageResponse = MagicMock
|
|
|
|
# Invoke response mocks
|
|
class MockInvokeResponse:
|
|
def __init__(self, status=200, body=None):
|
|
self.status = status
|
|
self.body = body
|
|
|
|
microsoft_teams_api_models_invoke_response.InvokeResponse = MockInvokeResponse
|
|
microsoft_teams_api_models_invoke_response.AdaptiveCardInvokeResponse = MagicMock
|
|
|
|
# Cards mocks
|
|
class MockAdaptiveCard:
|
|
def with_version(self, v):
|
|
return self
|
|
|
|
def with_body(self, body):
|
|
return self
|
|
|
|
def with_actions(self, actions):
|
|
return self
|
|
|
|
microsoft_teams_cards.AdaptiveCard = MockAdaptiveCard
|
|
microsoft_teams_cards.ExecuteAction = MagicMock
|
|
microsoft_teams_cards.TextBlock = MagicMock
|
|
|
|
# HttpRequest TypedDict mock
|
|
def HttpRequest(body=None, headers=None):
|
|
return {"body": body, "headers": headers}
|
|
|
|
# HttpResponse TypedDict mock
|
|
HttpResponse = dict
|
|
HttpMethod = str
|
|
from typing import Callable
|
|
HttpRouteHandler = Callable
|
|
|
|
microsoft_teams_apps_http_adapter.HttpRequest = HttpRequest
|
|
microsoft_teams_apps_http_adapter.HttpResponse = HttpResponse
|
|
microsoft_teams_apps_http_adapter.HttpMethod = HttpMethod
|
|
microsoft_teams_apps_http_adapter.HttpRouteHandler = HttpRouteHandler
|
|
|
|
# Wire the hierarchy
|
|
for name, mod in {
|
|
"microsoft_teams": microsoft_teams,
|
|
"microsoft_teams.apps": microsoft_teams_apps,
|
|
"microsoft_teams.api": microsoft_teams_api,
|
|
"microsoft_teams.api.activities": microsoft_teams_api_activities,
|
|
"microsoft_teams.api.activities.typing": microsoft_teams_api_activities_typing,
|
|
"microsoft_teams.api.activities.invoke": microsoft_teams_api_activities_invoke,
|
|
"microsoft_teams.api.activities.invoke.adaptive_card": microsoft_teams_api_activities_invoke_adaptive_card,
|
|
"microsoft_teams.common": microsoft_teams_common,
|
|
"microsoft_teams.common.http": microsoft_teams_common_http,
|
|
"microsoft_teams.common.http.client": microsoft_teams_common_http_client,
|
|
"microsoft_teams.api.models": microsoft_teams_api_models,
|
|
"microsoft_teams.api.models.adaptive_card": microsoft_teams_api_models_adaptive_card,
|
|
"microsoft_teams.api.models.invoke_response": microsoft_teams_api_models_invoke_response,
|
|
"microsoft_teams.cards": microsoft_teams_cards,
|
|
"microsoft_teams.apps.http": microsoft_teams_apps_http,
|
|
"microsoft_teams.apps.http.adapter": microsoft_teams_apps_http_adapter,
|
|
}.items():
|
|
sys.modules.setdefault(name, mod)
|
|
|
|
|
|
_ensure_teams_mock()
|
|
|
|
# Load plugins/platforms/teams/adapter.py under a unique module name
|
|
# (plugin_adapter_teams) so it cannot collide with sibling plugin adapters.
|
|
_teams_mod = load_plugin_adapter("teams")
|
|
|
|
_teams_mod.TEAMS_SDK_AVAILABLE = True
|
|
_teams_mod.AIOHTTP_AVAILABLE = True
|
|
|
|
# Ensure SDK symbols that were None (import failed on Python <3.12) are
|
|
# replaced with the mocked versions so runtime calls don't silently no-op.
|
|
import sys as _sys
|
|
_mt = _sys.modules.get("microsoft_teams.api.activities.typing")
|
|
if _mt and _teams_mod.TypingActivityInput is None:
|
|
_teams_mod.TypingActivityInput = _mt.TypingActivityInput
|
|
|
|
TeamsAdapter = _teams_mod.TeamsAdapter
|
|
TeamsSummaryWriter = _teams_mod.TeamsSummaryWriter
|
|
check_requirements = _teams_mod.check_requirements
|
|
check_teams_requirements = _teams_mod.check_teams_requirements
|
|
validate_config = _teams_mod.validate_config
|
|
register = _teams_mod.register
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_config(**extra):
|
|
return PlatformConfig(enabled=True, extra=extra)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Requirements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsRequirements:
|
|
def test_returns_false_when_sdk_missing(self, monkeypatch):
|
|
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
|
|
assert check_requirements() is False
|
|
|
|
def test_returns_false_when_aiohttp_missing(self, monkeypatch):
|
|
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False)
|
|
assert check_requirements() is False
|
|
|
|
def test_returns_true_when_deps_available(self, monkeypatch):
|
|
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True)
|
|
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
|
|
assert check_requirements() is True
|
|
|
|
def test_alias_matches(self, monkeypatch):
|
|
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True)
|
|
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
|
|
assert check_teams_requirements() is True
|
|
|
|
def test_validate_config_with_env(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "test-tenant")
|
|
assert validate_config(_make_config()) is True
|
|
|
|
def test_validate_config_from_extra(self, monkeypatch):
|
|
monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False)
|
|
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
|
|
cfg = _make_config(client_id="id", client_secret="secret", tenant_id="tenant")
|
|
assert validate_config(cfg) is True
|
|
|
|
def test_validate_config_missing(self, monkeypatch):
|
|
monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False)
|
|
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
|
|
assert validate_config(_make_config()) is False
|
|
|
|
def test_validate_config_missing_tenant(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret")
|
|
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
|
|
assert validate_config(_make_config()) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Adapter Init
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsAdapterInit:
|
|
def test_reads_config_from_extra(self):
|
|
config = _make_config(
|
|
client_id="cfg-id",
|
|
client_secret="cfg-secret",
|
|
tenant_id="cfg-tenant",
|
|
)
|
|
adapter = TeamsAdapter(config)
|
|
assert adapter._client_id == "cfg-id"
|
|
assert adapter._client_secret == "cfg-secret"
|
|
assert adapter._tenant_id == "cfg-tenant"
|
|
|
|
def test_falls_back_to_env_vars(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "env-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "env-secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "env-tenant")
|
|
adapter = TeamsAdapter(_make_config())
|
|
assert adapter._client_id == "env-id"
|
|
assert adapter._client_secret == "env-secret"
|
|
assert adapter._tenant_id == "env-tenant"
|
|
|
|
def test_default_port(self):
|
|
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
|
assert adapter._port == 3978
|
|
|
|
def test_custom_port_from_extra(self):
|
|
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port=4000))
|
|
assert adapter._port == 4000
|
|
|
|
def test_custom_port_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_PORT", "5000")
|
|
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
|
assert adapter._port == 5000
|
|
|
|
def test_platform_value(self):
|
|
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
|
assert adapter.platform.value == "teams"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Plugin registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsPluginRegistration:
|
|
|
|
def test_register_calls_ctx(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
ctx.register_platform.assert_called_once()
|
|
|
|
def test_register_name(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
kwargs = ctx.register_platform.call_args[1]
|
|
assert kwargs["name"] == "teams"
|
|
|
|
def test_register_auth_env_vars(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
kwargs = ctx.register_platform.call_args[1]
|
|
assert kwargs["allowed_users_env"] == "TEAMS_ALLOWED_USERS"
|
|
assert kwargs["allow_all_env"] == "TEAMS_ALLOW_ALL_USERS"
|
|
|
|
def test_register_max_message_length(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
kwargs = ctx.register_platform.call_args[1]
|
|
assert kwargs["max_message_length"] == 28000
|
|
|
|
def test_register_has_setup_fn(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
kwargs = ctx.register_platform.call_args[1]
|
|
assert callable(kwargs.get("setup_fn"))
|
|
|
|
def test_register_has_platform_hint(self):
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
kwargs = ctx.register_platform.call_args[1]
|
|
assert kwargs.get("platform_hint")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Interactive setup (import fix regression — #18325 / #19173)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsInteractiveSetup:
|
|
def test_interactive_setup_persists_credentials(self, tmp_path, monkeypatch):
|
|
"""Regression for #19173: interactive_setup must import prompt helpers
|
|
from hermes_cli.cli_output (not hermes_cli.config) and persist
|
|
credentials to .env without crashing.
|
|
"""
|
|
hermes_home = tmp_path / "hermes"
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
import hermes_cli.cli_output as cli_output_mod
|
|
|
|
answers = iter(["client-id", "client-secret", "tenant-id", "aad-1, aad-2"])
|
|
monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(answers))
|
|
monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: True)
|
|
monkeypatch.setattr(cli_output_mod, "print_info", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(cli_output_mod, "print_success", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(cli_output_mod, "print_warning", lambda *_a, **_kw: None)
|
|
|
|
_teams_mod.interactive_setup()
|
|
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "TEAMS_CLIENT_ID=client-id" in env_text
|
|
assert "TEAMS_TENANT_ID=tenant-id" in env_text
|
|
|
|
class TestTeamsConnect:
|
|
@pytest.mark.anyio
|
|
async def test_connect_fails_without_sdk(self, monkeypatch):
|
|
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
result = await adapter.connect()
|
|
assert result is False
|
|
|
|
@pytest.mark.anyio
|
|
async def test_connect_fails_without_credentials(self):
|
|
adapter = TeamsAdapter(_make_config())
|
|
adapter._client_id = ""
|
|
adapter._client_secret = ""
|
|
adapter._tenant_id = ""
|
|
result = await adapter.connect()
|
|
assert result is False
|
|
|
|
@pytest.mark.anyio
|
|
async def test_disconnect_cleans_up(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._running = True
|
|
mock_runner = AsyncMock()
|
|
adapter._runner = mock_runner
|
|
adapter._app = MagicMock()
|
|
|
|
await adapter.disconnect()
|
|
assert adapter._running is False
|
|
assert adapter._app is None
|
|
assert adapter._runner is None
|
|
mock_runner.cleanup.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Send
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsSend:
|
|
@pytest.mark.anyio
|
|
async def test_send_returns_error_without_app(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = None
|
|
result = await adapter.send("conv-id", "Hello")
|
|
assert result.success is False
|
|
assert "not initialized" in result.error
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_calls_app_send(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
mock_result = MagicMock()
|
|
mock_result.id = "msg-123"
|
|
mock_app = MagicMock()
|
|
mock_app.send = AsyncMock(return_value=mock_result)
|
|
adapter._app = mock_app
|
|
|
|
result = await adapter.send("conv-id", "Hello")
|
|
assert result.success is True
|
|
assert result.message_id == "msg-123"
|
|
mock_app.send.assert_awaited_once_with("conv-id", "Hello")
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_handles_error(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
mock_app = MagicMock()
|
|
mock_app.send = AsyncMock(side_effect=Exception("Network error"))
|
|
adapter._app = mock_app
|
|
|
|
result = await adapter.send("conv-id", "Hello")
|
|
assert result.success is False
|
|
assert "Network error" in result.error
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_typing(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
mock_app = MagicMock()
|
|
mock_app.send = AsyncMock()
|
|
adapter._app = mock_app
|
|
|
|
await adapter.send_typing("conv-id")
|
|
mock_app.send.assert_awaited_once()
|
|
call_args = mock_app.send.call_args
|
|
assert call_args[0][0] == "conv-id"
|
|
|
|
|
|
def _make_summary_payload():
|
|
return TeamsMeetingSummaryPayload(
|
|
meeting_ref=TeamsMeetingRef(meeting_id="meeting-123"),
|
|
title="Weekly Sync",
|
|
summary="Discussed launch readiness.",
|
|
key_decisions=["Proceed with staged rollout."],
|
|
action_items=["Send launch checklist."],
|
|
risks=["QA sign-off still pending."],
|
|
)
|
|
|
|
|
|
class TestTeamsSummaryWriter:
|
|
@pytest.mark.anyio
|
|
async def test_incoming_webhook_posts_summary_text(self):
|
|
seen = {}
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
writer = TeamsSummaryWriter(transport=httpx.MockTransport(_handler))
|
|
payload = _make_summary_payload()
|
|
|
|
result = await writer.write_summary(
|
|
payload,
|
|
{
|
|
"delivery_mode": "incoming_webhook",
|
|
"incoming_webhook_url": "https://example.test/teams-webhook",
|
|
},
|
|
)
|
|
|
|
assert result["delivery_mode"] == "incoming_webhook"
|
|
assert seen["url"] == "https://example.test/teams-webhook"
|
|
assert "Weekly Sync" in seen["body"]["text"]
|
|
assert "Proceed with staged rollout." in seen["body"]["text"]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_graph_delivery_posts_to_channel(self):
|
|
graph_client = SimpleNamespace(
|
|
post_json=AsyncMock(return_value={"id": "msg-123", "webUrl": "https://teams.example/messages/123"})
|
|
)
|
|
writer = TeamsSummaryWriter(graph_client=graph_client)
|
|
payload = _make_summary_payload()
|
|
|
|
result = await writer.write_summary(
|
|
payload,
|
|
{
|
|
"delivery_mode": "graph",
|
|
"team_id": "team-1",
|
|
"channel_id": "channel-1",
|
|
},
|
|
)
|
|
|
|
assert result["target_type"] == "channel"
|
|
assert result["message_id"] == "msg-123"
|
|
graph_client.post_json.assert_awaited_once()
|
|
path = graph_client.post_json.await_args.args[0]
|
|
body = graph_client.post_json.await_args.kwargs["json_body"]
|
|
assert path == "/teams/team-1/channels/channel-1/messages"
|
|
assert body["body"]["contentType"] == "html"
|
|
assert "Weekly Sync" in body["body"]["content"]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_graph_delivery_falls_back_to_platform_home_channel(self):
|
|
graph_client = SimpleNamespace(post_json=AsyncMock(return_value={"id": "msg-home"}))
|
|
platform_config = PlatformConfig(
|
|
enabled=True,
|
|
extra={"team_id": "team-home", "delivery_mode": "graph"},
|
|
home_channel=HomeChannel(
|
|
platform=Platform("teams"),
|
|
chat_id="channel-home",
|
|
name="Teams Home",
|
|
),
|
|
)
|
|
writer = TeamsSummaryWriter(platform_config=platform_config, graph_client=graph_client)
|
|
|
|
await writer.write_summary(_make_summary_payload(), {})
|
|
|
|
graph_client.post_json.assert_awaited_once()
|
|
assert graph_client.post_json.await_args.args[0] == "/teams/team-home/channels/channel-home/messages"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_existing_record_is_reused_without_force_resend(self):
|
|
graph_client = SimpleNamespace(post_json=AsyncMock())
|
|
writer = TeamsSummaryWriter(graph_client=graph_client)
|
|
existing = {"delivery_mode": "graph", "message_id": "msg-existing"}
|
|
|
|
result = await writer.write_summary(
|
|
_make_summary_payload(),
|
|
{
|
|
"delivery_mode": "graph",
|
|
"team_id": "team-1",
|
|
"channel_id": "channel-1",
|
|
},
|
|
existing_record=existing,
|
|
)
|
|
|
|
assert result == existing
|
|
graph_client.post_json.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Message Handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTeamsMessageHandling:
|
|
def _make_activity(
|
|
self,
|
|
*,
|
|
text="Hello",
|
|
from_id="user-123",
|
|
from_aad_id="aad-456",
|
|
from_name="Test User",
|
|
conversation_id="19:abc@thread.v2",
|
|
conversation_type="personal",
|
|
tenant_id="tenant-789",
|
|
activity_id="activity-001",
|
|
attachments=None,
|
|
):
|
|
activity = MagicMock()
|
|
activity.text = text
|
|
activity.id = activity_id
|
|
activity.from_ = MagicMock()
|
|
activity.from_.id = from_id
|
|
activity.from_.aad_object_id = from_aad_id
|
|
activity.from_.name = from_name
|
|
activity.conversation = MagicMock()
|
|
activity.conversation.id = conversation_id
|
|
activity.conversation.conversation_type = conversation_type
|
|
activity.conversation.name = "Test Chat"
|
|
activity.conversation.tenant_id = tenant_id
|
|
activity.attachments = attachments or []
|
|
return activity
|
|
|
|
def _make_ctx(self, activity):
|
|
ctx = MagicMock()
|
|
ctx.activity = activity
|
|
return ctx
|
|
|
|
@pytest.mark.anyio
|
|
async def test_personal_message_creates_dm_event(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(conversation_type="personal")
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
event = adapter.handle_message.call_args[0][0]
|
|
assert event.source.chat_type == "dm"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_group_message_creates_group_event(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(conversation_type="groupChat")
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
event = adapter.handle_message.call_args[0][0]
|
|
assert event.source.chat_type == "group"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_channel_message_creates_channel_event(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(conversation_type="channel")
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
event = adapter.handle_message.call_args[0][0]
|
|
assert event.source.chat_type == "channel"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_user_id_uses_aad_object_id(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(from_aad_id="aad-stable-id", from_id="teams-id")
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
event = adapter.handle_message.call_args[0][0]
|
|
assert event.source.user_id == "aad-stable-id"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_self_message_filtered(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(from_id="bot-id")
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
adapter.handle_message.assert_not_awaited()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_bot_mention_stripped_from_text(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(
|
|
text="<at>Hermes</at> what is the weather?",
|
|
from_id="user-id",
|
|
)
|
|
await adapter._on_message(self._make_ctx(activity))
|
|
|
|
event = adapter.handle_message.call_args[0][0]
|
|
assert event.text == "what is the weather?"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deduplication(self):
|
|
adapter = TeamsAdapter(_make_config(
|
|
client_id="bot-id", client_secret="secret", tenant_id="tenant",
|
|
))
|
|
adapter._app = MagicMock()
|
|
adapter._app.id = "bot-id"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
activity = self._make_activity(activity_id="msg-dup-001", from_id="user-id")
|
|
ctx = self._make_ctx(activity)
|
|
|
|
await adapter._on_message(ctx)
|
|
await adapter._on_message(ctx)
|
|
|
|
assert adapter.handle_message.await_count == 1
|
|
|
|
|
|
# ── _standalone_send (out-of-process cron delivery) ──────────────────────
|
|
|
|
|
|
class _FakeAiohttpResponse:
|
|
def __init__(self, status: int, payload, text_body: str = ""):
|
|
self.status = status
|
|
self._payload = payload
|
|
self._text = text_body or (str(payload) if payload is not None else "")
|
|
|
|
async def json(self):
|
|
return self._payload
|
|
|
|
async def text(self):
|
|
return self._text
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return None
|
|
|
|
|
|
class _FakeAiohttpSession:
|
|
"""Scripted aiohttp.ClientSession with a queue of responses so tests
|
|
can assert calls in order."""
|
|
|
|
def __init__(self, scripts):
|
|
self._scripts = list(scripts)
|
|
self.calls: list[tuple[str, dict]] = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return None
|
|
|
|
def post(self, url, **kwargs):
|
|
self.calls.append((url, kwargs))
|
|
if not self._scripts:
|
|
raise AssertionError(f"No scripted response for POST {url}")
|
|
return self._scripts.pop(0)
|
|
|
|
|
|
def _install_fake_aiohttp(monkeypatch, session):
|
|
"""Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp``
|
|
inside ``_standalone_send`` picks up our fake."""
|
|
fake_aiohttp = types.SimpleNamespace(
|
|
ClientSession=lambda timeout=None: session,
|
|
ClientTimeout=lambda total=None: None,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
|
|
|
|
|
|
class TestTeamsStandaloneSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_acquires_token_and_posts_activity(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
|
|
monkeypatch.delenv("TEAMS_SERVICE_URL", raising=False)
|
|
|
|
token_resp = _FakeAiohttpResponse(200, {"access_token": "the-token"})
|
|
activity_resp = _FakeAiohttpResponse(200, {"id": "msg-99"})
|
|
session = _FakeAiohttpSession([token_resp, activity_resp])
|
|
_install_fake_aiohttp(monkeypatch, session)
|
|
|
|
result = await _teams_mod._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
"19:abc@thread.skype",
|
|
"hello cron",
|
|
)
|
|
|
|
assert result == {"success": True, "message_id": "msg-99"}
|
|
assert len(session.calls) == 2
|
|
|
|
token_url, token_kwargs = session.calls[0]
|
|
assert "login.microsoftonline.com/tenant/oauth2/v2.0/token" in token_url
|
|
assert token_kwargs["data"]["client_id"] == "client-id"
|
|
assert token_kwargs["data"]["client_secret"] == "secret"
|
|
assert token_kwargs["data"]["scope"] == "https://api.botframework.com/.default"
|
|
|
|
activity_url, activity_kwargs = session.calls[1]
|
|
# Default service URL when TEAMS_SERVICE_URL is unset
|
|
assert "smba.trafficmanager.net" in activity_url
|
|
assert "/v3/conversations/19:abc@thread.skype/activities" in activity_url
|
|
assert activity_kwargs["headers"]["Authorization"] == "Bearer the-token"
|
|
assert activity_kwargs["json"]["text"] == "hello cron"
|
|
assert activity_kwargs["json"]["type"] == "message"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_returns_error_when_unconfigured(self, monkeypatch):
|
|
for var in ("TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
result = await _teams_mod._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
"19:abc@thread.skype",
|
|
"hi",
|
|
)
|
|
|
|
assert "error" in result
|
|
assert "TEAMS_CLIENT_ID" in result["error"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_propagates_token_failure(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
|
|
|
|
token_resp = _FakeAiohttpResponse(
|
|
401,
|
|
{"error": "unauthorized_client"},
|
|
text_body='{"error":"unauthorized_client"}',
|
|
)
|
|
session = _FakeAiohttpSession([token_resp])
|
|
_install_fake_aiohttp(monkeypatch, session)
|
|
|
|
result = await _teams_mod._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
"19:abc@thread.skype",
|
|
"hi",
|
|
)
|
|
|
|
assert "error" in result
|
|
assert "401" in result["error"]
|
|
assert "token" in result["error"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_rejects_off_allowlist_service_url(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
|
|
# SSRF attempt: point us at an attacker-controlled host
|
|
monkeypatch.setenv("TEAMS_SERVICE_URL", "https://attacker.example.com/teams/")
|
|
|
|
# If the allowlist check fails to fire, the fake session will assert
|
|
# because no scripts are queued; a passing test means we returned
|
|
# before any HTTP call.
|
|
session = _FakeAiohttpSession([])
|
|
_install_fake_aiohttp(monkeypatch, session)
|
|
|
|
result = await _teams_mod._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
"19:abc@thread.skype",
|
|
"hi",
|
|
)
|
|
|
|
assert "error" in result
|
|
assert "allowlist" in result["error"].lower()
|
|
assert len(session.calls) == 0, "must not call any HTTP endpoint with a tampered service URL"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_rejects_chat_id_with_path_traversal(self, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
|
|
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
|
|
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
|
|
monkeypatch.delenv("TEAMS_SERVICE_URL", raising=False)
|
|
|
|
session = _FakeAiohttpSession([])
|
|
_install_fake_aiohttp(monkeypatch, session)
|
|
|
|
# Attempt to break out of /v3/conversations/<id>/activities via a `/`
|
|
result = await _teams_mod._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
"19:abc/activities/19:other@thread.skype",
|
|
"hi",
|
|
)
|
|
|
|
assert "error" in result
|
|
assert "Bot Framework conversation ID" in result["error"]
|
|
assert len(session.calls) == 0
|