The dashboard create test now disables the route via the enabled endpoint and re-POSTs it; the stored record must stay disabled (the rule moved into _replace_subscription, and the pre-salvage ops.py dropped it). The profile-rebind test drops its reader-thread scaffolding, which could not fail under the lock-free atomic-rename store. Instead the rebind is a same-size rewrite with the original mtime restored, so only the new inode tells the gateway the file changed; a stat key without st_ino keeps serving the revoked secret.
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""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()
|