Files
hermes-agent/tests/hermes_cli/test_webhook_cli.py
ethernet 59624b7ace tests: replace every bare host skipif with platforms()
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.

Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.

Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
  its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
  skip-on-Windows moves onto the 11 methods that had no host mark; the
  11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
  platforms("linux") in favour of the module's "posix" (mkfifo exists on
  macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
  or wider marks removed.
2026-09-21 19:18:15 -04:00

226 lines
7.5 KiB
Python

"""Tests for hermes_cli/webhook.py — webhook subscription CLI."""
import json
import os
import pytest
import stat
from argparse import Namespace
from hermes_cli.webhook import (
webhook_command,
_get_webhook_base_url,
_load_subscriptions,
_save_subscriptions,
_subscriptions_path,
)
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Default: webhooks enabled (most tests need this)
monkeypatch.setattr(
"hermes_cli.webhook._is_webhook_enabled", lambda: True
)
def _make_args(**kwargs):
defaults = {
"webhook_action": None,
"name": "",
"prompt": "",
"events": "",
"description": "",
"skills": "",
"deliver": "log",
"deliver_chat_id": "",
"secret": "",
"route_profile": None,
"payload": "",
"script": "",
}
defaults.update(kwargs)
return Namespace(**defaults)
@pytest.mark.parametrize("host", [None, "", "0.0.0.0", "::"])
def test_webhook_base_url_maps_wildcard_hosts_to_localhost(monkeypatch, host):
monkeypatch.setattr(
"hermes_cli.webhook._get_webhook_config",
lambda: {"extra": {"host": host, "port": 9123}},
)
assert _get_webhook_base_url() == "http://localhost:9123"
class TestSubscribe:
def test_custom_secret(self):
webhook_command(_make_args(
webhook_action="subscribe", name="s", secret="my-secret"
))
assert _load_subscriptions()["s"]["secret"] == "my-secret"
def test_auto_secret(self):
webhook_command(_make_args(webhook_action="subscribe", name="s"))
secret = _load_subscriptions()["s"]["secret"]
assert len(secret) > 20
def test_profile_binding_and_secret_survive_update(self, tmp_path, capsys):
profile_dir = tmp_path / "profiles" / "compta"
profile_dir.mkdir(parents=True)
(profile_dir / "config.yaml").write_text("{}\n") # identity marker
webhook_command(_make_args(
webhook_action="subscribe", name="notifier", route_profile="compta"
))
created = _load_subscriptions()["notifier"]
first_secret = created["secret"]
assert created["profile"] == "compta"
assert "/p/compta/webhooks/notifier" in capsys.readouterr().out
webhook_command(_make_args(
webhook_action="subscribe", name="notifier", description="updated"
))
updated = _load_subscriptions()["notifier"]
assert updated["profile"] == "compta"
assert updated["secret"] == first_secret
def test_rejects_unknown_profile_without_replacing_subscription(self, capsys):
webhook_command(_make_args(
webhook_action="subscribe", name="notifier", secret="original"
))
webhook_command(_make_args(
webhook_action="subscribe", name="notifier", route_profile="missing"
))
assert "does not exist" in capsys.readouterr().out
assert _load_subscriptions()["notifier"]["secret"] == "original"
class TestCronJobSubscribe:
"""--cron-job: event-triggered cron jobs."""
def test_valid_job_ref_stored_as_id(self, monkeypatch):
# resolve_job_ref is imported inside _cmd_subscribe from cron.jobs
import cron.jobs as jobs_mod
monkeypatch.setattr(
jobs_mod, "resolve_job_ref",
lambda ref: {"id": "job-abc123", "name": ref},
)
webhook_command(_make_args(
webhook_action="subscribe", name="ev", cron_job="sweeper"
))
assert _load_subscriptions()["ev"]["cron_job"] == "job-abc123"
def test_unknown_job_rejected(self, monkeypatch, capsys):
import cron.jobs as jobs_mod
monkeypatch.setattr(jobs_mod, "resolve_job_ref", lambda ref: None)
webhook_command(_make_args(
webhook_action="subscribe", name="ev", cron_job="nope"
))
assert "no cron job matches" in capsys.readouterr().out
assert "ev" not in _load_subscriptions()
def test_cron_job_plus_deliver_only_rejected(self, capsys):
webhook_command(_make_args(
webhook_action="subscribe",
name="ev",
cron_job="sweeper",
deliver_only=True,
deliver="telegram",
))
assert "mutually exclusive" in capsys.readouterr().out
assert "ev" not in _load_subscriptions()
class TestList:
def test_with_entries(self, capsys):
webhook_command(_make_args(webhook_action="subscribe", name="a"))
webhook_command(_make_args(webhook_action="subscribe", name="b"))
capsys.readouterr() # clear
webhook_command(_make_args(webhook_action="list"))
out = capsys.readouterr().out
assert "2 webhook" in out
assert "a" in out
assert "b" in out
class TestRemove:
def test_selective_remove(self):
webhook_command(_make_args(webhook_action="subscribe", name="keep"))
webhook_command(_make_args(webhook_action="subscribe", name="drop"))
webhook_command(_make_args(webhook_action="remove", name="drop"))
subs = _load_subscriptions()
assert "keep" in subs
assert "drop" not in subs
class TestPersistence:
def test_corrupted_file(self):
path = _subscriptions_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("broken{{{")
assert _load_subscriptions() == {}
@pytest.mark.platforms("posix") # POSIX mode bits are platform-specific
def test_save_creates_secret_file_owner_only_under_permissive_umask(self):
old_umask = os.umask(0o022)
try:
_save_subscriptions({"demo": {"secret": "TOPSECRET", "prompt": "x"}})
finally:
os.umask(old_umask)
path = _subscriptions_path()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert "TOPSECRET" in path.read_text(encoding="utf-8")
@pytest.mark.platforms("posix") # POSIX mode bits are platform-specific
def test_save_narrows_existing_broad_secret_file_mode(self):
# Simulate a pre-existing 0o644 file from before this hardening landed.
path = _subscriptions_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"old": {"secret": "stale", "prompt": "x"}}))
path.chmod(0o644)
_save_subscriptions({"demo": {"secret": "FRESH", "prompt": "x"}})
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert "FRESH" in path.read_text(encoding="utf-8")
class TestWebhookEnabledGate:
def test_blocks_list_when_disabled(self, capsys, monkeypatch):
monkeypatch.setattr("hermes_cli.webhook._is_webhook_enabled", lambda: False)
webhook_command(_make_args(webhook_action="list"))
out = capsys.readouterr().out
assert "not enabled" in out.lower()
def test_allows_when_enabled(self, capsys):
# _is_webhook_enabled already patched to True by autouse fixture
webhook_command(_make_args(webhook_action="subscribe", name="allowed"))
out = capsys.readouterr().out
assert "Created" in out
assert "allowed" in _load_subscriptions()
def test_real_check_disabled(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.webhook._get_webhook_config",
lambda: {},
)
monkeypatch.setattr(
"hermes_cli.webhook._is_webhook_enabled",
lambda: bool({}.get("enabled")),
)
import hermes_cli.webhook as wh_mod
assert wh_mod._is_webhook_enabled() is False