test(ntfy): cover echo-tag filter; tag standalone send path
Adds tests for the echo-loop fix (outgoing X-Tags header, inbound skip on tagged events, genuine tags pass through) and extends the tag to the out-of-process _standalone_send() path so cron / send_message deliveries to a self-subscribed topic are also skipped. Maps both contributors in release.py AUTHOR_MAP. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
@ -530,7 +530,7 @@ async def _standalone_send(
|
||||
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
|
||||
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
|
||||
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)}
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
|
||||
@ -129,6 +129,8 @@ AUTHOR_MAP = {
|
||||
"32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn",
|
||||
"32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
|
||||
"liuhao1024@users.noreply.github.com": "liuhao1024",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"285874597+annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"130975919+kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"seppe@fushia.be": "seppegadeyne",
|
||||
|
||||
@ -486,6 +486,22 @@ class TestSend:
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert "X-Markdown" not in call_headers
|
||||
|
||||
def test_send_emits_echo_tag_header(self):
|
||||
"""Outgoing messages carry the echo-prevention tag so the adapter
|
||||
can recognise and skip its own replies when subscribe topic ==
|
||||
publish topic (the default config that causes the loop)."""
|
||||
adapter = self._make_adapter(topic="hermes-in")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "abc123"}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
adapter._http_client = mock_client
|
||||
|
||||
_run(adapter.send("hermes-in", "Hello!"))
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert call_headers.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Inbound message processing (identity invariant — security-critical)
|
||||
@ -543,6 +559,47 @@ class TestOnMessage:
|
||||
_run(adapter._on_message(event))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_own_tagged_message_skipped(self):
|
||||
"""An incoming event carrying the adapter's echo tag is the agent's
|
||||
own reply echoed back by ntfy — it must not be dispatched, otherwise
|
||||
the agent replies to itself forever (issue #34447)."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "echo-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "my own reply",
|
||||
"tags": [_ntfy._ECHO_TAG],
|
||||
"time": None,
|
||||
}))
|
||||
assert calls == []
|
||||
|
||||
def test_message_with_other_tags_still_dispatched(self):
|
||||
"""Tags unrelated to the echo sentinel must not suppress genuine
|
||||
user messages."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "user-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "hello",
|
||||
"tags": ["warning", "skull"],
|
||||
"time": None,
|
||||
}))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_timestamp_parsed_from_event(self):
|
||||
from datetime import timezone
|
||||
adapter = self._make_adapter()
|
||||
@ -742,6 +799,28 @@ class TestStandaloneSend:
|
||||
posted_url = mock_client.post.call_args[0][0]
|
||||
assert posted_url == "https://ntfy.example.com/hermes-in"
|
||||
|
||||
def test_emits_echo_tag_header(self, monkeypatch):
|
||||
"""Out-of-process cron / send_message deliveries also carry the echo
|
||||
tag, so a gateway subscribed to the same topic skips them too."""
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "id-99"}
|
||||
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.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
def test_emits_bearer_token_when_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user