Files
hermes-agent/tests/gateway/test_webhook_cron_trigger.py

247 lines
9.4 KiB
Python

"""Tests for the webhook adapter's ``cron_job`` route mode.
``cron_job`` routes turn an existing cron job into an event-triggered task:
an inbound webhook event fires the referenced job through the same
claimed-run body a manual ``cronjob(action='run')`` uses, instead of
starting a fresh webhook agent session.
Covers:
- The referenced job is fired via ``execute_job_for_event`` with the
rendered prompt as transient per-run context
- The normal webhook agent session is NOT started (``handle_message``
never called)
- HTTP returns 202 Accepted immediately
- Startup validation rejects routes that set both ``cron_job`` and
``deliver_only``
- ``execute_job_for_event`` resolves refs and fails cleanly on unknowns
"""
import asyncio
import json
from unittest.mock import patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter(routes, **extra_kw) -> WebhookAdapter:
extra = {"host": "127.0.0.1", "port": 0, "routes": routes}
extra.update(extra_kw)
config = PlatformConfig(enabled=True, extra=extra)
return WebhookAdapter(config)
def _create_app(adapter: WebhookAdapter) -> web.Application:
app = web.Application()
app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook)
return app
async def _drain_background_tasks(adapter: WebhookAdapter) -> None:
tasks = list(adapter._background_tasks)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
# ===================================================================
# Core behaviour: event fires the cron job, not a webhook session
# ===================================================================
class TestCronJobTrigger:
@pytest.mark.asyncio
async def test_post_fires_job_with_event_context(self):
routes = {
"pr-feedback": {
"secret": _INSECURE_NO_AUTH,
"cron_job": "review-sweeper",
"prompt": "PR #{number} received feedback: {review.body}",
}
}
adapter = _make_adapter(routes)
handle_message_calls = []
async def _capture(event):
handle_message_calls.append(event)
adapter.handle_message = _capture
fired = []
def _fake_execute(job_ref, extra_prompt=None):
fired.append((job_ref, extra_prompt))
return {"claimed": True, "success": True, "error": None}
app = _create_app(adapter)
body = json.dumps(
{"number": 7, "review": {"body": "needs tests"}}
).encode()
with patch(
"tools.cronjob_tools.execute_job_for_event",
side_effect=_fake_execute,
):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/pr-feedback",
data=body,
headers={
"Content-Type": "application/json",
"X-GitHub-Delivery": "delivery-cron-1",
"X-GitHub-Event": "pull_request_review",
},
)
assert resp.status == 202
data = await resp.json()
assert data["status"] == "accepted"
assert data["cron_job"] == "review-sweeper"
await _drain_background_tasks(adapter)
# Job fired exactly once with the rendered prompt as run context
assert len(fired) == 1
job_ref, extra_prompt = fired[0]
assert job_ref == "review-sweeper"
assert "PR #7 received feedback: needs tests" in extra_prompt
assert "pull_request_review" in extra_prompt # event provenance
# No fresh webhook agent session was started
assert handle_message_calls == []
@pytest.mark.asyncio
async def test_job_failure_does_not_break_http_response(self):
routes = {
"flaky": {"secret": _INSECURE_NO_AUTH, "cron_job": "gone-job"}
}
adapter = _make_adapter(routes)
app = _create_app(adapter)
def _fail(job_ref, extra_prompt=None):
return {
"claimed": False,
"success": False,
"error": "Cron job 'gone-job' not found.",
}
with patch(
"tools.cronjob_tools.execute_job_for_event", side_effect=_fail
):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/webhooks/flaky",
data=b"{}",
headers={
"Content-Type": "application/json",
"X-GitHub-Delivery": "delivery-cron-2",
},
)
# Fire-and-forget: the POST is accepted even when the job
# later fails; the failure is logged, not surfaced.
assert resp.status == 202
await _drain_background_tasks(adapter)
@pytest.mark.asyncio
async def test_routed_profile_scope_reaches_the_job_run(self, tmp_path, monkeypatch):
"""/p/<profile>/ routes must fire the job from THAT profile's cron store, not the gateway's
default home (and route-level skills stay out of the per-run context — the job's own apply)."""
home = tmp_path / ".hermes"
(home / "profiles" / "sec").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("hermes_cli.profiles._get_default_hermes_home", lambda: home)
monkeypatch.setattr("hermes_cli.profiles._get_profiles_root", lambda: home / "profiles")
adapter = _make_adapter({"ev": {"secret": _INSECURE_NO_AUTH, "cron_job": "sweeper", "profile": "sec",
"skills": ["some-skill"], "prompt": "hello {n}"}})
monkeypatch.setattr(adapter, "_resolve_request_profile", lambda request: "sec")
monkeypatch.setattr(adapter, "_apply_skills", lambda prompt, skills: pytest.fail("skills applied"))
seen = []
def _fake_execute(job_ref, extra_prompt=None):
from hermes_constants import get_hermes_home
seen.append((get_hermes_home(), extra_prompt))
return {"claimed": True, "success": True, "error": None}
with patch("tools.cronjob_tools.execute_job_for_event", side_effect=_fake_execute):
async with TestClient(TestServer(_create_app(adapter))) as cli:
resp = await cli.post("/webhooks/ev", data=b'{"n": 1}',
headers={"Content-Type": "application/json", "X-GitHub-Delivery": "d-3"})
assert resp.status == 202
await _drain_background_tasks(adapter)
assert seen and seen[0][0] == (home / "profiles" / "sec").resolve()
assert "hello 1" in seen[0][1]
# ===================================================================
# Startup validation
# ===================================================================
class TestCronJobRouteValidation:
@pytest.mark.asyncio
async def test_cron_job_plus_deliver_only_rejected_at_connect(self):
routes = {
"bad": {
"secret": "s3cret",
"cron_job": "some-job",
"deliver_only": True,
"deliver": "telegram",
}
}
adapter = _make_adapter(routes)
with pytest.raises(ValueError, match="mutually exclusive"):
await adapter.connect()
# ===================================================================
# execute_job_for_event unit behaviour
# ===================================================================
class TestExecuteJobForEvent:
def test_unknown_job_returns_error(self):
from tools import cronjob_tools
with patch.object(cronjob_tools, "resolve_job_ref", return_value=None):
result = cronjob_tools.execute_job_for_event("nope")
assert result["claimed"] is False
assert result["success"] is False
assert "not found" in result["error"]
def test_ambiguous_ref_returns_error(self):
from cron.jobs import AmbiguousJobReference
from tools import cronjob_tools
with patch.object(
cronjob_tools,
"resolve_job_ref",
side_effect=AmbiguousJobReference(
"x", [{"id": "job-a"}, {"id": "job-b"}]
),
):
result = cronjob_tools.execute_job_for_event("x")
assert result["claimed"] is False
assert result["success"] is False
assert "ambiguous" in result["error"].lower()
def test_resolved_job_fires_with_extra_prompt(self):
from tools import cronjob_tools
job = {"id": "job-123", "name": "sweeper"}
with patch.object(
cronjob_tools, "resolve_job_ref", return_value=job
), patch.object(
cronjob_tools,
"_execute_job_now",
return_value={"claimed": True, "success": True, "error": None},
) as mock_exec:
result = cronjob_tools.execute_job_for_event(
"sweeper", extra_prompt="event context"
)
assert result["success"] is True
mock_exec.assert_called_once_with(job, extra_prompt="event context")