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:
teknium1
2026-05-29 13:06:21 -07:00
committed by Teknium
parent 9405cdc8dd
commit 8055d0f092
3 changed files with 82 additions and 1 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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()