"""Tests for hermes_cli/webhook.py — webhook subscription CLI.""" import json import os import pytest import stat import threading from argparse import Namespace from hermes_cli.webhook import ( webhook_command, _get_webhook_base_url, _load_subscriptions, _mutate_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 _load_subscriptions()["notifier"]["secret"] == "original" def test_stale_update_cannot_restore_concurrently_removed_or_disabled_route(self, monkeypatch): import hermes_cli.webhook as webhook_module webhook_command(_make_args( webhook_action="subscribe", name="notifier", secret="original" )) original_load = _load_subscriptions pause = {"loaded": threading.Event(), "allow": threading.Event()} errors = [] def load_with_paused_update(): subscriptions = original_load() if threading.current_thread().name == "stale-webhook-update": pause["loaded"].set() if not pause["allow"].wait(timeout=5): raise AssertionError("timed out waiting to resume stale update") return subscriptions monkeypatch.setattr("hermes_cli.webhook._load_subscriptions", load_with_paused_update) def update_route(): try: webhook_command(_make_args( webhook_action="subscribe", name="notifier", description="stale update" )) except BaseException as exc: # surfaced in the test thread below errors.append(exc) def start_stale_update(): worker = threading.Thread(target=update_route, name="stale-webhook-update") worker.start() assert pause["loaded"].wait(timeout=5) return worker worker = start_stale_update() webhook_command(_make_args(webhook_action="remove", name="notifier")) pause["allow"].set() worker.join(timeout=5) assert not worker.is_alive() assert errors == [] assert "notifier" not in original_load() webhook_command(_make_args( webhook_action="subscribe", name="notifier", secret="replacement" )) pause = {"loaded": threading.Event(), "allow": threading.Event()} worker = start_stale_update() def disable(subscriptions): subscriptions["notifier"]["enabled"] = False webhook_module._mutate_subscriptions(disable) pause["allow"].set() worker.join(timeout=5) assert not worker.is_alive() assert errors == [] assert original_load()["notifier"]["enabled"] is False def test_profile_rebind_rotates_secret_without_exposing_mixed_record( self, tmp_path, monkeypatch ): from gateway.config import PlatformConfig from gateway.platforms.webhook import WebhookAdapter profile_dir = tmp_path / "profiles" / "compta" profile_dir.mkdir(parents=True) (profile_dir / "config.yaml").write_text("{}\n") # identity marker monkeypatch.setattr("hermes_cli.webhook.time.strftime", lambda *_a: "2026-01-01T00:00:00Z") # One char shorter than the rotated secret: "default" -> "compta" then keeps the file size equal. webhook_command(_make_args( webhook_action="subscribe", name="notifier", secret="old-profile-secret-ab" )) old_record = _load_subscriptions()["notifier"] adapter = WebhookAdapter(PlatformConfig(enabled=True, extra={"secret": "global"})) adapter._reload_dynamic_routes() initial_stat = _subscriptions_path().stat() monkeypatch.setattr("hermes_cli.webhook.secrets.token_urlsafe", lambda _n: "rotated-profile-secret") webhook_command(_make_args( webhook_action="subscribe", name="notifier", route_profile="compta" )) new_record = _load_subscriptions()["notifier"] new_state = (new_record["profile"], new_record["secret"]) # Same size + restored mtime: only the rename's new inode reveals the change to the gateway. os.utime( _subscriptions_path(), ns=(initial_stat.st_atime_ns, initial_stat.st_mtime_ns), ) assert _subscriptions_path().stat().st_size == initial_stat.st_size adapter._reload_dynamic_routes() _subscriptions_path().write_text("{torn") # unreadable file must keep the last good routes adapter._reload_dynamic_routes() assert new_state == ("compta", "rotated-profile-secret") assert new_record["secret"] != old_record["secret"] assert ( adapter._routes["notifier"]["profile"], adapter._routes["notifier"]["secret"], ) == new_state 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 "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 "ev" not in _load_subscriptions() 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: _mutate_subscriptions(lambda s: s.update(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) _mutate_subscriptions(lambda s: s.update(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()