Files
hermes-agent/tests/gateway/test_webhook_integration.py
Nick 4f03b9a419 feat(webhook): add {__raw__} template token and thread_id passthrough for forum topics
- {__raw__} in webhook prompt templates dumps the full JSON payload (truncated at 4000 chars)
- _deliver_cross_platform now passes thread_id/message_thread_id from deliver_extra as metadata, enabling Telegram forum topic delivery
- Tests for both features
2026-04-06 16:42:52 -07:00

338 lines
12 KiB
Python

"""Integration tests for the generic webhook platform adapter.
These tests exercise end-to-end flows through the webhook adapter:
1. GitHub PR webhook → agent MessageEvent created
2. Skills config injects skill content into the prompt
3. Cross-platform delivery routes to a mock Telegram adapter
4. GitHub comment delivery invokes ``gh`` CLI (mocked subprocess)
"""
import asyncio
import hashlib
import hmac
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import (
GatewayConfig,
HomeChannel,
Platform,
PlatformConfig,
)
from gateway.platforms.base import MessageEvent, MessageType, SendResult
from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter(routes, **extra_kw) -> WebhookAdapter:
"""Create a WebhookAdapter with the given routes."""
extra = {"host": "0.0.0.0", "port": 0, "routes": routes}
extra.update(extra_kw)
config = PlatformConfig(enabled=True, extra=extra)
return WebhookAdapter(config)
def _create_app(adapter: WebhookAdapter) -> web.Application:
"""Build the aiohttp Application from the adapter."""
app = web.Application()
app.router.add_get("/health", adapter._handle_health)
app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook)
return app
def _github_signature(body: bytes, secret: str) -> str:
"""Compute X-Hub-Signature-256 for *body* using *secret*."""
return "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
# A realistic GitHub pull_request event payload (trimmed)
GITHUB_PR_PAYLOAD = {
"action": "opened",
"number": 42,
"pull_request": {
"title": "Add webhook adapter",
"body": "This PR adds a generic webhook platform adapter.",
"html_url": "https://github.com/org/repo/pull/42",
"user": {"login": "contributor"},
"head": {"ref": "feature/webhooks"},
"base": {"ref": "main"},
},
"repository": {
"full_name": "org/repo",
"html_url": "https://github.com/org/repo",
},
"sender": {"login": "contributor"},
}
# ===================================================================
# Test 1: GitHub PR webhook triggers agent
# ===================================================================
class TestGitHubPRWebhook:
@pytest.mark.asyncio
async def test_github_pr_webhook_triggers_agent(self):
"""POST with a realistic GitHub PR payload should:
1. Return 202 Accepted
2. Call handle_message with a MessageEvent
3. The event text contains the rendered prompt
4. The event source has chat_type 'webhook'
"""
secret = "gh-webhook-test-secret"
routes = {
"github-pr": {
"secret": secret,
"events": ["pull_request"],
"prompt": (
"Review PR #{number} by {sender.login}: "
"{pull_request.title}\n\n{pull_request.body}"
),
"deliver": "log",
}
}
adapter = _make_adapter(routes)
captured_events: list[MessageEvent] = []
async def _capture(event: MessageEvent):
captured_events.append(event)
adapter.handle_message = _capture
app = _create_app(adapter)
body = json.dumps(GITHUB_PR_PAYLOAD).encode()
sig = _github_signature(body, secret)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/github-pr",
data=body,
headers={
"Content-Type": "application/json",
"X-GitHub-Event": "pull_request",
"X-Hub-Signature-256": sig,
"X-GitHub-Delivery": "gh-delivery-001",
},
)
assert resp.status == 202
data = await resp.json()
assert data["status"] == "accepted"
assert data["route"] == "github-pr"
assert data["event"] == "pull_request"
assert data["delivery_id"] == "gh-delivery-001"
# Let the asyncio.create_task fire
await asyncio.sleep(0.05)
assert len(captured_events) == 1
event = captured_events[0]
assert "Review PR #42 by contributor" in event.text
assert "Add webhook adapter" in event.text
assert event.source.chat_type == "webhook"
assert event.source.platform == Platform.WEBHOOK
assert "github-pr" in event.source.chat_id
assert event.message_id == "gh-delivery-001"
# ===================================================================
# Test 2: Skills injected into prompt
# ===================================================================
class TestSkillsInjection:
@pytest.mark.asyncio
async def test_skills_injected_into_prompt(self):
"""When a route has skills: [code-review], the adapter should
call build_skill_invocation_message() and use its output as the
prompt instead of the raw template render."""
routes = {
"pr-review": {
"secret": _INSECURE_NO_AUTH,
"events": ["pull_request"],
"prompt": "Review this PR: {pull_request.title}",
"skills": ["code-review"],
}
}
adapter = _make_adapter(routes)
captured_events: list[MessageEvent] = []
async def _capture(event: MessageEvent):
captured_events.append(event)
adapter.handle_message = _capture
skill_content = (
"You are a code reviewer. Review the following:\n"
"Review this PR: Add webhook adapter"
)
# The imports are lazy (inside the handler), so patch the source module
with patch(
"agent.skill_commands.build_skill_invocation_message",
return_value=skill_content,
) as mock_build, patch(
"agent.skill_commands.get_skill_commands",
return_value={"/code-review": {"name": "code-review"}},
):
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/pr-review",
json=GITHUB_PR_PAYLOAD,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": "skill-test-001",
},
)
assert resp.status == 202
await asyncio.sleep(0.05)
assert len(captured_events) == 1
event = captured_events[0]
# The prompt should be the skill content, not the raw template
assert "You are a code reviewer" in event.text
mock_build.assert_called_once()
# ===================================================================
# Test 3: Cross-platform delivery (webhook → Telegram)
# ===================================================================
class TestCrossPlatformDelivery:
@pytest.mark.asyncio
async def test_cross_platform_delivery(self):
"""When deliver='telegram', the response is routed to the
Telegram adapter via gateway_runner.adapters."""
routes = {
"alerts": {
"secret": _INSECURE_NO_AUTH,
"prompt": "Alert: {message}",
"deliver": "telegram",
"deliver_extra": {"chat_id": "12345"},
}
}
adapter = _make_adapter(routes)
adapter.handle_message = AsyncMock()
# Set up a mock gateway runner with a mock Telegram adapter
mock_tg_adapter = AsyncMock()
mock_tg_adapter.send = AsyncMock(return_value=SendResult(success=True))
mock_runner = MagicMock()
mock_runner.adapters = {Platform.TELEGRAM: mock_tg_adapter}
mock_runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")}
)
adapter.gateway_runner = mock_runner
# First, simulate a webhook POST to set up delivery_info
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/alerts",
json={"message": "Server is on fire!"},
headers={"X-GitHub-Delivery": "alert-001"},
)
assert resp.status == 202
# The adapter should have stored delivery info
chat_id = "webhook:alerts:alert-001"
assert chat_id in adapter._delivery_info
# Now call send() as if the agent has finished
result = await adapter.send(chat_id, "I've acknowledged the alert.")
assert result.success is True
mock_tg_adapter.send.assert_awaited_once_with(
"12345", "I've acknowledged the alert.", metadata=None
)
# Delivery info should be cleaned up
assert chat_id not in adapter._delivery_info
# ===================================================================
# Test 4: GitHub comment delivery via gh CLI
# ===================================================================
class TestGitHubCommentDelivery:
@pytest.mark.asyncio
async def test_github_comment_delivery(self):
"""When deliver='github_comment', the adapter invokes
``gh pr comment`` via subprocess.run (mocked)."""
routes = {
"pr-bot": {
"secret": _INSECURE_NO_AUTH,
"prompt": "Review: {pull_request.title}",
"deliver": "github_comment",
"deliver_extra": {
"repo": "{repository.full_name}",
"pr_number": "{number}",
},
}
}
adapter = _make_adapter(routes)
adapter.handle_message = AsyncMock()
# POST a webhook to set up delivery info
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/pr-bot",
json=GITHUB_PR_PAYLOAD,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": "gh-comment-001",
},
)
assert resp.status == 202
chat_id = "webhook:pr-bot:gh-comment-001"
assert chat_id in adapter._delivery_info
# Verify deliver_extra was rendered with payload data
delivery = adapter._delivery_info[chat_id]
assert delivery["deliver_extra"]["repo"] == "org/repo"
assert delivery["deliver_extra"]["pr_number"] == "42"
# Mock subprocess.run and call send()
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Comment posted"
mock_result.stderr = ""
with patch(
"gateway.platforms.webhook.subprocess.run",
return_value=mock_result,
) as mock_run:
result = await adapter.send(
chat_id, "LGTM! The code looks great."
)
assert result.success is True
mock_run.assert_called_once_with(
[
"gh", "pr", "comment", "42",
"--repo", "org/repo",
"--body", "LGTM! The code looks great.",
],
capture_output=True,
text=True,
timeout=30,
)
# Delivery info cleaned up
assert chat_id not in adapter._delivery_info