Files
hermes-agent/tests/gateway/test_webhook_dynamic_routes.py
Teknium dabe3c34cc feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.

CLI commands (require webhook platform to be enabled):
  hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
  hermes webhook list
  hermes webhook remove <name>
  hermes webhook test <name>

All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).

The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.

Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.

Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.

No new model tools. No toolset changes.

24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00

88 lines
3.1 KiB
Python

"""Tests for webhook adapter dynamic route loading."""
import json
import os
import pytest
from pathlib import Path
from gateway.config import PlatformConfig
from gateway.platforms.webhook import WebhookAdapter, _DYNAMIC_ROUTES_FILENAME
def _make_adapter(routes=None, extra=None):
_extra = extra or {}
if routes:
_extra["routes"] = routes
_extra.setdefault("secret", "test-global-secret")
config = PlatformConfig(enabled=True, extra=_extra)
return WebhookAdapter(config)
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
class TestDynamicRouteLoading:
def test_no_dynamic_file(self):
adapter = _make_adapter(routes={"static": {"secret": "s"}})
adapter._reload_dynamic_routes()
assert "static" in adapter._routes
assert len(adapter._dynamic_routes) == 0
def test_loads_dynamic_routes(self, tmp_path):
subs = {"my-hook": {"secret": "dynamic-secret", "prompt": "test", "events": []}}
(tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text(json.dumps(subs))
adapter = _make_adapter(routes={"static": {"secret": "s"}})
adapter._reload_dynamic_routes()
assert "my-hook" in adapter._routes
assert "static" in adapter._routes
def test_static_takes_precedence(self, tmp_path):
(tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text(
json.dumps({"conflict": {"secret": "dynamic", "prompt": "dyn"}})
)
adapter = _make_adapter(routes={"conflict": {"secret": "static", "prompt": "stat"}})
adapter._reload_dynamic_routes()
assert adapter._routes["conflict"]["secret"] == "static"
def test_mtime_gated(self, tmp_path):
import time
path = tmp_path / _DYNAMIC_ROUTES_FILENAME
path.write_text(json.dumps({"v1": {"secret": "s"}}))
adapter = _make_adapter()
adapter._reload_dynamic_routes()
assert "v1" in adapter._dynamic_routes
# Same mtime — no reload
adapter._dynamic_routes["injected"] = True
adapter._reload_dynamic_routes()
assert "injected" in adapter._dynamic_routes
# New write — reloads
time.sleep(0.05)
path.write_text(json.dumps({"v2": {"secret": "s"}}))
adapter._reload_dynamic_routes()
assert "v2" in adapter._dynamic_routes
assert "v1" not in adapter._dynamic_routes
def test_file_removal_clears(self, tmp_path):
path = tmp_path / _DYNAMIC_ROUTES_FILENAME
path.write_text(json.dumps({"temp": {"secret": "s"}}))
adapter = _make_adapter()
adapter._reload_dynamic_routes()
assert "temp" in adapter._dynamic_routes
path.unlink()
adapter._reload_dynamic_routes()
assert len(adapter._dynamic_routes) == 0
def test_corrupted_file(self, tmp_path):
(tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text("not json")
adapter = _make_adapter(routes={"static": {"secret": "s"}})
adapter._reload_dynamic_routes()
assert "static" in adapter._routes
assert len(adapter._dynamic_routes) == 0