refactor(ntfy): convert built-in adapter to platform plugin
ntfy now ships as a self-contained plugin under plugins/platforms/ntfy/ instead of editing 8 core files (gateway/config.py Platform enum, gateway/run.py factory + auth maps, cron/scheduler.py, toolsets.py, hermes_cli/status.py, agent/prompt_builder.py, gateway/channel_directory.py, tools/send_message_tool.py). All routing goes through gateway/platform_registry via register_platform(): - adapter_factory, check_fn, validate_config, is_connected - env_enablement_fn seeds PlatformConfig.extra from NTFY_* env vars so gateway status reflects env-only setups without instantiating httpx - standalone_sender_fn handles deliver=ntfy cron jobs when cron runs out-of-process from the gateway - allowed_users_env / allow_all_env hook into _is_user_authorized - cron_deliver_env_var=NTFY_HOME_CHANNEL for cron home routing - platform_hint surfaces in the system prompt - pii_safe=True (topic names are the only identifier; no PII to redact) Tests moved to tests/gateway/test_ntfy_plugin.py using _plugin_adapter_loader so the module lives under plugin_adapter_ntfy in sys.modules and cannot collide with sibling plugin-adapter tests on the same xdist worker. The core-file grep tests (Platform.NTFY in source, hermes-ntfy in toolsets, etc.) are replaced with plugin-shape tests covering register() metadata, env_enablement_fn output, and standalone_sender_fn behavior. 68 tests pass under scripts/run_tests.sh.
This commit is contained in:
@@ -1,4 +1,18 @@
|
||||
"""Tests for ntfy platform adapter and integration points."""
|
||||
"""Tests for the ntfy platform-plugin adapter.
|
||||
|
||||
Loaded via the ``_plugin_adapter_loader`` helper so this lives under
|
||||
``plugin_adapter_ntfy`` in ``sys.modules`` and cannot collide with
|
||||
sibling platform-plugin tests on the same xdist worker.
|
||||
|
||||
Most tests target the adapter class directly. The plugin-shape tests
|
||||
(``register()``, ``_env_enablement``, ``_standalone_send``, registry
|
||||
presence) replace the core-file grep tests from the original PR — the
|
||||
ntfy adapter no longer modifies ``gateway/config.py``, ``gateway/run.py``,
|
||||
``cron/scheduler.py``, ``toolsets.py``, etc. Everything routes through
|
||||
the ``platform_registry``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -6,7 +20,22 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.config import PlatformConfig
|
||||
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
||||
|
||||
_ntfy = load_plugin_adapter("ntfy")
|
||||
|
||||
NtfyAdapter = _ntfy.NtfyAdapter
|
||||
check_requirements = _ntfy.check_requirements
|
||||
validate_config = _ntfy.validate_config
|
||||
is_connected = _ntfy.is_connected
|
||||
register = _ntfy.register
|
||||
_env_enablement = _ntfy._env_enablement
|
||||
_standalone_send = _ntfy._standalone_send
|
||||
DEFAULT_SERVER = _ntfy.DEFAULT_SERVER
|
||||
DEDUP_WINDOW_SECONDS = _ntfy.DEDUP_WINDOW_SECONDS
|
||||
DEDUP_MAX_SIZE = _ntfy.DEDUP_MAX_SIZE
|
||||
MAX_MESSAGE_LENGTH = _ntfy.MAX_MESSAGE_LENGTH
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -15,22 +44,21 @@ def _run(coro):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform enum
|
||||
# 1. Platform enum (plugin-discovered, not bundled)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformEnum:
|
||||
|
||||
def test_ntfy_value(self):
|
||||
assert Platform.NTFY.value == "ntfy"
|
||||
|
||||
def test_ntfy_in_all_platforms(self):
|
||||
values = [p.value for p in Platform]
|
||||
assert "ntfy" in values
|
||||
def test_platform_enum_resolves_via_plugin_scan():
|
||||
"""The plugin filesystem scan should expose Platform("ntfy")."""
|
||||
from gateway.config import Platform
|
||||
p = Platform("ntfy")
|
||||
assert p.value == "ntfy"
|
||||
# Identity stability — repeated lookups return the same pseudo-member
|
||||
assert Platform("ntfy") is p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Requirements check
|
||||
# 2. check_requirements / validate_config / is_connected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -38,157 +66,67 @@ class TestNtfyRequirements:
|
||||
|
||||
def test_returns_false_when_httpx_unavailable(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-test")
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False)
|
||||
from gateway.platforms.ntfy import check_ntfy_requirements
|
||||
assert check_ntfy_requirements() is False
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False)
|
||||
assert check_requirements() is False
|
||||
|
||||
def test_returns_false_when_topic_not_set(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
from gateway.platforms.ntfy import check_ntfy_requirements
|
||||
with patch("gateway.config.load_gateway_config") as mock_load:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {}
|
||||
mock_load.return_value = mock_cfg
|
||||
assert check_ntfy_requirements() is False
|
||||
assert check_requirements() is False
|
||||
|
||||
def test_returns_true_when_topic_set_via_env(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-test")
|
||||
from gateway.platforms.ntfy import check_ntfy_requirements
|
||||
assert check_ntfy_requirements() is True
|
||||
|
||||
def test_returns_true_when_topic_set_via_env(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-cfg")
|
||||
from gateway.platforms.ntfy import check_ntfy_requirements
|
||||
assert check_ntfy_requirements() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading from env vars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNtfyConfigLoading:
|
||||
|
||||
def test_ntfy_topic_enables_platform(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
config = load_gateway_config()
|
||||
assert Platform.NTFY in config.platforms
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.enabled is True
|
||||
assert pc.extra["topic"] == "hermes-in"
|
||||
|
||||
def test_ntfy_server_url_stored_in_extra(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_SERVER_URL", "https://ntfy.example.com")
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.extra.get("server") == "https://ntfy.example.com"
|
||||
|
||||
def test_ntfy_token_stored_in_extra(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_TOKEN", "tk_secret")
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.extra.get("token") == "tk_secret"
|
||||
|
||||
def test_ntfy_publish_topic_stored_in_extra(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_PUBLISH_TOPIC", "hermes-out")
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.extra.get("publish_topic") == "hermes-out"
|
||||
|
||||
def test_ntfy_home_channel_set(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_HOME_CHANNEL", "hermes-home")
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.home_channel is not None
|
||||
assert pc.home_channel.chat_id == "hermes-home"
|
||||
assert pc.home_channel.platform == Platform.NTFY
|
||||
|
||||
def test_ntfy_home_channel_name_default(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_HOME_CHANNEL", "hermes-home")
|
||||
monkeypatch.delenv("NTFY_HOME_CHANNEL_NAME", raising=False)
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms[Platform.NTFY]
|
||||
assert pc.home_channel.name == "Home"
|
||||
|
||||
def test_ntfy_not_enabled_when_topic_absent(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
assert check_requirements() is True
|
||||
|
||||
def test_validate_config_requires_topic(self, monkeypatch):
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
config = load_gateway_config()
|
||||
pc = config.platforms.get(Platform.NTFY)
|
||||
if pc is not None:
|
||||
assert not pc.enabled or pc.extra.get("topic", "") == ""
|
||||
assert validate_config(PlatformConfig(enabled=True, extra={})) is False
|
||||
assert validate_config(
|
||||
PlatformConfig(enabled=True, extra={"topic": "t"})
|
||||
) is True
|
||||
|
||||
def test_ntfy_in_connected_platforms_when_topic_set(self, monkeypatch):
|
||||
from gateway.config import load_gateway_config
|
||||
def test_is_connected_from_extra(self, monkeypatch):
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
assert is_connected(PlatformConfig(enabled=True, extra={"topic": "t"})) is True
|
||||
assert is_connected(PlatformConfig(enabled=True, extra={})) is False
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
config = load_gateway_config()
|
||||
connected = config.get_connected_platforms()
|
||||
assert Platform.NTFY in connected
|
||||
def test_is_connected_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "env-topic")
|
||||
assert is_connected(PlatformConfig(enabled=True, extra={})) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter construction
|
||||
# 3. Adapter init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNtfyAdapterInit:
|
||||
|
||||
def test_default_server_url(self, monkeypatch):
|
||||
from gateway.platforms.ntfy import NtfyAdapter, DEFAULT_SERVER
|
||||
|
||||
monkeypatch.delenv("NTFY_SERVER_URL", raising=False)
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._server == DEFAULT_SERVER.rstrip("/")
|
||||
|
||||
def test_topic_read_from_extra(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "my-topic"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._topic == "my-topic"
|
||||
|
||||
def test_topic_read_from_env(self, monkeypatch):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
monkeypatch.setenv("NTFY_TOPIC", "env-topic")
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._topic == "env-topic"
|
||||
|
||||
def test_publish_topic_falls_back_to_topic(self, monkeypatch):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False)
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._publish_topic == "hermes-in"
|
||||
|
||||
def test_publish_topic_uses_extra_value(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"topic": "hermes-in", "publish_topic": "hermes-out"},
|
||||
@@ -197,23 +135,17 @@ class TestNtfyAdapterInit:
|
||||
assert adapter._publish_topic == "hermes-out"
|
||||
|
||||
def test_token_read_from_extra(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "t", "token": "tok-123"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._token == "tok-123"
|
||||
|
||||
def test_token_read_from_env(self, monkeypatch):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
monkeypatch.setenv("NTFY_TOKEN", "env-token")
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "t"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._token == "env-token"
|
||||
|
||||
def test_server_trailing_slash_stripped(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"topic": "t", "server": "https://ntfy.example.com/"},
|
||||
@@ -221,16 +153,7 @@ class TestNtfyAdapterInit:
|
||||
adapter = NtfyAdapter(config)
|
||||
assert not adapter._server.endswith("/")
|
||||
|
||||
def test_name_is_ntfy(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "t"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter.name == "Ntfy"
|
||||
|
||||
def test_initial_state(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "t"})
|
||||
adapter = NtfyAdapter(config)
|
||||
assert adapter._stream_task is None
|
||||
@@ -239,15 +162,13 @@ class TestNtfyAdapterInit:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth headers
|
||||
# 4. Auth headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
|
||||
def _make_adapter(self, token=""):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "t", "token": token})
|
||||
return NtfyAdapter(config)
|
||||
|
||||
@@ -258,15 +179,14 @@ class TestAuthHeaders:
|
||||
def test_bearer_token_for_plain_token(self):
|
||||
adapter = self._make_adapter(token="myapitoken")
|
||||
headers = adapter._auth_headers()
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"] == "Bearer myapitoken"
|
||||
|
||||
def test_basic_auth_for_user_colon_password(self):
|
||||
adapter = self._make_adapter(token="user:pass")
|
||||
headers = adapter._auth_headers()
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"].startswith("Basic ")
|
||||
expected = "Basic " + __import__("base64").b64encode(b"user:pass").decode()
|
||||
import base64
|
||||
expected = "Basic " + base64.b64encode(b"user:pass").decode()
|
||||
assert headers["Authorization"] == expected
|
||||
|
||||
def test_bearer_token_used_when_no_colon(self):
|
||||
@@ -281,15 +201,13 @@ class TestAuthHeaders:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication
|
||||
# 5. Deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeduplication:
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
|
||||
def test_first_message_not_duplicate(self):
|
||||
@@ -313,18 +231,14 @@ class TestDeduplication:
|
||||
assert len(adapter._seen_messages) == 50
|
||||
|
||||
def test_cache_pruned_on_overflow(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter, DEDUP_MAX_SIZE
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
adapter = self._make_adapter()
|
||||
for i in range(DEDUP_MAX_SIZE + 20):
|
||||
adapter._is_duplicate(f"msg-{i}")
|
||||
assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 20
|
||||
|
||||
def test_expired_id_can_be_seen_again(self):
|
||||
import time
|
||||
from gateway.platforms.ntfy import NtfyAdapter, DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
adapter = self._make_adapter()
|
||||
adapter._seen_messages["old-msg"] = time.time() - DEDUP_WINDOW_SECONDS - 1
|
||||
for i in range(DEDUP_MAX_SIZE + 1):
|
||||
adapter._is_duplicate(f"fill-{i}")
|
||||
@@ -332,39 +246,33 @@ class TestDeduplication:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect() / disconnect()
|
||||
# 6. connect() / disconnect()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnect:
|
||||
|
||||
def test_connect_fails_when_httpx_unavailable(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False)
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False)
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
result = _run(adapter.connect())
|
||||
assert result is False
|
||||
|
||||
def test_connect_fails_when_no_topic(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
adapter = NtfyAdapter(config)
|
||||
result = _run(adapter.connect())
|
||||
assert result is False
|
||||
|
||||
def test_connect_starts_stream_task(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True)
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True)
|
||||
config = PlatformConfig(enabled=True, extra={"topic": "hermes-test"})
|
||||
adapter = NtfyAdapter(config)
|
||||
|
||||
with patch.object(adapter, "_run_stream", new_callable=AsyncMock):
|
||||
with patch("gateway.platforms.ntfy.httpx") as mock_httpx:
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = MagicMock()
|
||||
result = _run(adapter.connect())
|
||||
|
||||
@@ -377,8 +285,6 @@ class TestConnect:
|
||||
pass
|
||||
|
||||
def test_disconnect_clears_state(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
adapter._seen_messages["x"] = 1.0
|
||||
adapter._http_client = AsyncMock()
|
||||
@@ -392,8 +298,6 @@ class TestConnect:
|
||||
assert adapter._running is False
|
||||
|
||||
def test_disconnect_cancels_stream_task(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
|
||||
async def _hang():
|
||||
@@ -409,18 +313,18 @@ class TestConnect:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send()
|
||||
# 7. send()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSend:
|
||||
|
||||
def _make_adapter(self, topic="hermes-in", publish_topic="", token=""):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
extra = {"topic": topic, "token": token}
|
||||
def _make_adapter(self, topic="hermes-in", publish_topic="", token="", markdown=False):
|
||||
extra: dict = {"topic": topic, "token": token}
|
||||
if publish_topic:
|
||||
extra["publish_topic"] = publish_topic
|
||||
if markdown:
|
||||
extra["markdown"] = True
|
||||
return NtfyAdapter(PlatformConfig(enabled=True, extra=extra))
|
||||
|
||||
def test_send_fails_without_http_client(self):
|
||||
@@ -444,8 +348,7 @@ class TestSend:
|
||||
assert result.success is True
|
||||
assert result.message_id == "abc123"
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
posted_url = call_args[0][0]
|
||||
posted_url = mock_client.post.call_args[0][0]
|
||||
assert posted_url.endswith("/hermes-out")
|
||||
|
||||
def test_send_falls_back_to_subscribe_topic(self):
|
||||
@@ -498,8 +401,6 @@ class TestSend:
|
||||
assert "403" in result.error
|
||||
|
||||
def test_send_handles_timeout(self):
|
||||
import gateway.platforms.ntfy as ntfy_mod
|
||||
|
||||
adapter = self._make_adapter(topic="hermes-in")
|
||||
|
||||
class _FakeTimeout(Exception):
|
||||
@@ -512,15 +413,13 @@ class TestSend:
|
||||
mock_client.post = AsyncMock(side_effect=_FakeTimeout("timed out"))
|
||||
adapter._http_client = mock_client
|
||||
|
||||
with patch.object(ntfy_mod, "httpx", fake_httpx):
|
||||
with patch.object(_ntfy, "httpx", fake_httpx):
|
||||
result = _run(adapter.send("hermes-in", "Hello!"))
|
||||
|
||||
assert result.success is False
|
||||
assert "timeout" in result.error.lower()
|
||||
|
||||
def test_send_truncates_to_max_length(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter, MAX_MESSAGE_LENGTH
|
||||
|
||||
adapter = self._make_adapter(topic="t")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
@@ -537,15 +436,10 @@ class TestSend:
|
||||
assert len(posted_body.decode()) <= MAX_MESSAGE_LENGTH
|
||||
|
||||
def test_send_typing_is_noop(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
# Should not raise
|
||||
_run(adapter.send_typing("t"))
|
||||
_run(adapter.send_typing("t")) # must not raise
|
||||
|
||||
def test_get_chat_info_returns_dict(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
|
||||
info = _run(adapter.get_chat_info("hermes-in"))
|
||||
assert info["name"] == "hermes-in"
|
||||
@@ -567,19 +461,42 @@ class TestSend:
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert call_headers.get("Authorization") == "Bearer mytoken"
|
||||
|
||||
def test_send_emits_markdown_header_when_enabled(self):
|
||||
adapter = self._make_adapter(topic="hermes-in", markdown=True)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
adapter._http_client = mock_client
|
||||
|
||||
_run(adapter.send("hermes-in", "**bold**"))
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert call_headers.get("X-Markdown") == "true"
|
||||
|
||||
def test_send_omits_markdown_header_when_disabled(self):
|
||||
adapter = self._make_adapter(topic="hermes-in", markdown=False)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
adapter._http_client = mock_client
|
||||
|
||||
_run(adapter.send("hermes-in", "plain"))
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert "X-Markdown" not in call_headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound message processing
|
||||
# 8. Inbound message processing (identity invariant — security-critical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnMessage:
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.platforms.ntfy import NtfyAdapter
|
||||
|
||||
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"}))
|
||||
return adapter
|
||||
return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"}))
|
||||
|
||||
def test_message_dispatched_to_handler(self):
|
||||
adapter = self._make_adapter()
|
||||
@@ -622,7 +539,6 @@ class TestOnMessage:
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
|
||||
event = {"id": "dup-1", "event": "message", "topic": "hermes-in", "message": "hi", "time": None}
|
||||
_run(adapter._on_message(event))
|
||||
_run(adapter._on_message(event))
|
||||
@@ -630,7 +546,6 @@ class TestOnMessage:
|
||||
|
||||
def test_timestamp_parsed_from_event(self):
|
||||
from datetime import timezone
|
||||
|
||||
adapter = self._make_adapter()
|
||||
captured = []
|
||||
|
||||
@@ -638,7 +553,6 @@ class TestOnMessage:
|
||||
captured.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
|
||||
_run(adapter._on_message({
|
||||
"id": "ts-1",
|
||||
"event": "message",
|
||||
@@ -667,8 +581,7 @@ class TestOnMessage:
|
||||
assert captured[0].message_id == "ntfy-id-42"
|
||||
|
||||
def test_title_not_used_as_user_id(self):
|
||||
"""title field must not be used for identity — it is publisher-controlled
|
||||
and cannot be trusted as an authentication signal."""
|
||||
"""title field must not be used for identity — it is publisher-controlled."""
|
||||
adapter = self._make_adapter()
|
||||
captured = []
|
||||
|
||||
@@ -684,13 +597,11 @@ class TestOnMessage:
|
||||
"title": "Alice",
|
||||
"time": None,
|
||||
}))
|
||||
# user_id must be the topic, never the spoofable title field
|
||||
assert captured[0].source.user_id == "hermes-in"
|
||||
assert captured[0].source.user_name == "hermes-in"
|
||||
|
||||
def test_unknown_publisher_cannot_impersonate_allowed_user(self):
|
||||
"""An unknown publisher setting title to an allowed username must not
|
||||
gain the identity of that user — identity is always the topic name."""
|
||||
"""An unknown publisher setting title=admin must not gain admin identity."""
|
||||
adapter = self._make_adapter()
|
||||
captured = []
|
||||
|
||||
@@ -728,166 +639,203 @@ class TestOnMessage:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: send_message_tool platform_map (source-level checks)
|
||||
# 9. _env_enablement() — env-only auto-config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendMessageToolIntegration:
|
||||
class TestEnvEnablement:
|
||||
|
||||
def test_ntfy_in_platform_enum(self):
|
||||
assert hasattr(Platform, "NTFY")
|
||||
assert Platform.NTFY.value == "ntfy"
|
||||
def test_returns_none_without_topic(self, monkeypatch):
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
assert _env_enablement() is None
|
||||
|
||||
def test_ntfy_in_platform_map_source(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert "Platform.NTFY" in src
|
||||
def test_seeds_topic_and_server(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.delenv("NTFY_SERVER_URL", raising=False)
|
||||
seed = _env_enablement()
|
||||
assert seed is not None
|
||||
assert seed["topic"] == "hermes-in"
|
||||
assert seed["server"] == DEFAULT_SERVER
|
||||
|
||||
def test_send_ntfy_function_in_source(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert "async def _send_ntfy" in src
|
||||
def test_custom_server_url(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_SERVER_URL", "https://ntfy.example.com/")
|
||||
seed = _env_enablement()
|
||||
assert seed["server"] == "https://ntfy.example.com" # trailing slash stripped
|
||||
|
||||
def test_ntfy_branch_in_send_to_platform_source(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert "Platform.NTFY" in src
|
||||
assert "_send_ntfy" in src
|
||||
def test_publish_topic_seeded(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_PUBLISH_TOPIC", "hermes-out")
|
||||
seed = _env_enablement()
|
||||
assert seed["publish_topic"] == "hermes-out"
|
||||
|
||||
def test_send_ntfy_reads_server_from_extra(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert 'extra.get("server")' in src
|
||||
assert "NTFY_SERVER_URL" in src
|
||||
def test_token_seeded(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_TOKEN", "tk_abc")
|
||||
seed = _env_enablement()
|
||||
assert seed["token"] == "tk_abc"
|
||||
|
||||
def test_send_ntfy_reads_topic_from_extra(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert 'extra.get("topic")' in src
|
||||
assert "NTFY_TOPIC" in src
|
||||
def test_markdown_truthy_values(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
for val in ("true", "1", "yes", "TRUE"):
|
||||
monkeypatch.setenv("NTFY_MARKDOWN", val)
|
||||
assert _env_enablement()["markdown"] is True
|
||||
|
||||
def test_send_ntfy_reads_token_from_extra(self):
|
||||
src = open("tools/send_message_tool.py").read()
|
||||
assert 'extra.get("token")' in src
|
||||
assert "NTFY_TOKEN" in src
|
||||
def test_markdown_falsy_values(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
for val in ("false", "0", "no", "anything"):
|
||||
monkeypatch.setenv("NTFY_MARKDOWN", val)
|
||||
assert _env_enablement()["markdown"] is False
|
||||
|
||||
def test_home_channel_defaults_to_topic(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.delenv("NTFY_HOME_CHANNEL", raising=False)
|
||||
seed = _env_enablement()
|
||||
assert seed["home_channel"]["chat_id"] == "hermes-in"
|
||||
assert seed["home_channel"]["name"] == "hermes-in"
|
||||
|
||||
def test_home_channel_override(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
monkeypatch.setenv("NTFY_HOME_CHANNEL", "alerts")
|
||||
monkeypatch.setenv("NTFY_HOME_CHANNEL_NAME", "Alerts Channel")
|
||||
seed = _env_enablement()
|
||||
assert seed["home_channel"]["chat_id"] == "alerts"
|
||||
assert seed["home_channel"]["name"] == "Alerts Channel"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: cron scheduler platform_map
|
||||
# 10. _standalone_send() — out-of-process cron delivery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCronSchedulerIntegration:
|
||||
class TestStandaloneSend:
|
||||
|
||||
def test_ntfy_in_scheduler_platform_map_source(self):
|
||||
src = open("cron/scheduler.py").read()
|
||||
# ntfy routing handled via Platform._missing_() dynamic dispatch
|
||||
assert '"ntfy"' in src or "Platform._missing_" in src or "_missing_" in src
|
||||
def test_errors_without_topic(self, monkeypatch):
|
||||
monkeypatch.delenv("NTFY_TOPIC", raising=False)
|
||||
monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False)
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {}
|
||||
result = _run(_standalone_send(pconfig, "", "hello"))
|
||||
assert "error" in result
|
||||
assert "NTFY_TOPIC" in result["error"]
|
||||
|
||||
def test_ntfy_in_cronjob_deliver_description(self):
|
||||
src = open("cron/scheduler.py").read()
|
||||
assert "ntfy" in src.lower()
|
||||
def test_posts_to_server(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"server": "https://ntfy.example.com", "topic": "hermes-in"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "id-42"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
result = _run(_standalone_send(pconfig, "hermes-in", "hello"))
|
||||
|
||||
assert result.get("success") is True
|
||||
assert result["platform"] == "ntfy"
|
||||
assert result["message_id"] == "id-42"
|
||||
posted_url = mock_client.post.call_args[0][0]
|
||||
assert posted_url == "https://ntfy.example.com/hermes-in"
|
||||
|
||||
def test_emits_bearer_token_when_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in", "token": "tk_xyz"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
_run(_standalone_send(pconfig, "hermes-in", "hi"))
|
||||
|
||||
headers = mock_client.post.call_args[1]["headers"]
|
||||
assert headers["Authorization"] == "Bearer tk_xyz"
|
||||
|
||||
def test_basic_auth_when_token_has_colon(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in", "token": "user:pass"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
_run(_standalone_send(pconfig, "hermes-in", "hi"))
|
||||
|
||||
headers = mock_client.post.call_args[1]["headers"]
|
||||
assert headers["Authorization"].startswith("Basic ")
|
||||
|
||||
def test_returns_error_on_http_failure(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "Forbidden"
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
result = _run(_standalone_send(pconfig, "hermes-in", "hi"))
|
||||
|
||||
assert "error" in result
|
||||
assert "403" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: gateway/run.py authorization maps
|
||||
# 11. register() — plugin-side metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunAuthorizationMaps:
|
||||
|
||||
def test_ntfy_allowed_users_in_allowlist_check(self):
|
||||
src = open("gateway/run.py").read()
|
||||
assert "NTFY_ALLOWED_USERS" in src
|
||||
|
||||
def test_ntfy_allow_all_users_in_allowlist_check(self):
|
||||
src = open("gateway/run.py").read()
|
||||
assert "NTFY_ALLOW_ALL_USERS" in src
|
||||
|
||||
def test_ntfy_in_platform_env_map(self):
|
||||
src = open("gateway/run.py").read()
|
||||
assert 'Platform.NTFY: "NTFY_ALLOWED_USERS"' in src
|
||||
|
||||
def test_ntfy_in_allow_all_map(self):
|
||||
src = open("gateway/run.py").read()
|
||||
assert 'Platform.NTFY: "NTFY_ALLOW_ALL_USERS"' in src
|
||||
|
||||
def test_ntfy_create_adapter_branch(self):
|
||||
src = open("gateway/run.py").read()
|
||||
assert "Platform.NTFY" in src
|
||||
assert "NtfyAdapter" in src
|
||||
|
||||
def test_ntfy_startup_allowlist_includes_ntfy_allowed_users(self):
|
||||
src = open("gateway/run.py").read()
|
||||
# Verify both env vars appear in the startup check tuples
|
||||
assert '"NTFY_ALLOWED_USERS"' in src
|
||||
assert '"NTFY_ALLOW_ALL_USERS"' in src
|
||||
def test_register_calls_register_platform():
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
ctx.register_platform.assert_called_once()
|
||||
kwargs = ctx.register_platform.call_args.kwargs
|
||||
assert kwargs["name"] == "ntfy"
|
||||
assert kwargs["label"] == "ntfy"
|
||||
assert kwargs["required_env"] == ["NTFY_TOPIC"]
|
||||
assert kwargs["allowed_users_env"] == "NTFY_ALLOWED_USERS"
|
||||
assert kwargs["allow_all_env"] == "NTFY_ALLOW_ALL_USERS"
|
||||
assert kwargs["cron_deliver_env_var"] == "NTFY_HOME_CHANNEL"
|
||||
assert kwargs["max_message_length"] == MAX_MESSAGE_LENGTH
|
||||
assert callable(kwargs["check_fn"])
|
||||
assert callable(kwargs["validate_config"])
|
||||
assert callable(kwargs["is_connected"])
|
||||
assert callable(kwargs["env_enablement_fn"])
|
||||
assert callable(kwargs["standalone_sender_fn"])
|
||||
assert callable(kwargs["adapter_factory"])
|
||||
# ntfy has no user-identifying PII (only topic names)
|
||||
assert kwargs["pii_safe"] is True
|
||||
assert "ntfy" in kwargs["platform_hint"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: toolsets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolsets:
|
||||
|
||||
def test_hermes_ntfy_toolset_exists(self):
|
||||
from toolsets import get_toolset
|
||||
|
||||
ts = get_toolset("hermes-ntfy")
|
||||
assert ts is not None
|
||||
assert "tools" in ts
|
||||
|
||||
def test_hermes_ntfy_in_gateway_includes(self):
|
||||
from toolsets import get_toolset
|
||||
|
||||
gw = get_toolset("hermes-gateway")
|
||||
assert "hermes-ntfy" in gw["includes"]
|
||||
|
||||
def test_hermes_ntfy_resolves_tools(self):
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
tools = resolve_toolset("hermes-ntfy")
|
||||
assert len(tools) > 0
|
||||
|
||||
def test_hermes_ntfy_description_mentions_ntfy(self):
|
||||
from toolsets import get_toolset
|
||||
|
||||
ts = get_toolset("hermes-ntfy")
|
||||
assert "ntfy" in ts["description"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: prompt_builder platform hints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPromptBuilderHints:
|
||||
|
||||
def test_ntfy_hint_exists(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
|
||||
assert "ntfy" in PLATFORM_HINTS
|
||||
|
||||
def test_ntfy_hint_mentions_plain_text(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
|
||||
hint = PLATFORM_HINTS["ntfy"].lower()
|
||||
assert "plain text" in hint
|
||||
|
||||
def test_ntfy_hint_mentions_push_or_notifications(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
|
||||
hint = PLATFORM_HINTS["ntfy"].lower()
|
||||
assert "push" in hint or "notification" in hint
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: channel_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannelDirectory:
|
||||
|
||||
def test_ntfy_in_session_based_platforms_source(self):
|
||||
src = open("gateway/channel_directory.py").read()
|
||||
assert '"ntfy"' in src
|
||||
|
||||
def test_build_channel_directory_includes_ntfy_key(self):
|
||||
src = open("gateway/channel_directory.py").read()
|
||||
assert "ntfy" in src
|
||||
def test_adapter_factory_returns_ntfy_adapter():
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
factory = ctx.register_platform.call_args.kwargs["adapter_factory"]
|
||||
cfg = PlatformConfig(enabled=True, extra={"topic": "t"})
|
||||
adapter = factory(cfg)
|
||||
assert isinstance(adapter, NtfyAdapter)
|
||||
Reference in New Issue
Block a user