"""Tests for gateway/platforms/base.py — MessageEvent, media extraction, message truncation.""" import os import time from unittest.mock import patch import pytest from gateway.platforms.base import ( BasePlatformAdapter, GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE, MessageEvent, cache_audio_from_bytes, cache_image_from_bytes, cache_video_from_bytes, safe_url_for_log, utf16_len, validate_inbound_media_size, _log_safe_path, _prefix_within_utf16_limit, cache_audio_from_bytes, ) def test_media_delivery_denies_encrypted_bitwarden_cache(tmp_path, monkeypatch): """Encrypted Bitwarden cache is covered by the media credential guard.""" import gateway.platforms.base as base hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(base, "_HERMES_HOME", hermes_home) monkeypatch.setattr(base, "_HERMES_ROOT", hermes_home) path = hermes_home / "cache" / "bws_cache.enc.json" path.parent.mkdir() path.write_text("encrypted-secret-cache") assert path in base._media_delivery_denied_paths() assert base.validate_media_delivery_path(str(path)) is None class TestInboundMediaSizeCap: """gateway.max_inbound_media_bytes caps inbound media buffered into RAM (#13145).""" _PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 64 def test_default_cap_is_128_mib(self, monkeypatch): # No config override -> default. Patch loader to return empty config. import gateway.platforms.base as base monkeypatch.setattr(base, "get_inbound_media_max_bytes", lambda: base.DEFAULT_INBOUND_MEDIA_MAX_BYTES) assert base.DEFAULT_INBOUND_MEDIA_MAX_BYTES == 128 * 1024 * 1024 def test_image_bytes_rejected_when_oversized(self, monkeypatch): import gateway.platforms.base as base monkeypatch.setattr(base, "get_inbound_media_max_bytes", lambda: 16) with pytest.raises(ValueError, match="Inbound image payload is too large"): cache_image_from_bytes(self._PNG, ext=".png") class TestSecretCaptureGuidance: def test_gateway_secret_capture_message_points_to_local_setup(self): message = GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE assert "local cli" in message.lower() assert "~/.hermes/.env" in message class TestSafeUrlForLog: def test_strips_query_fragment_and_userinfo(self): url = ( "https://user:pass@example.com/private/path/image.png" "?X-Amz-Signature=supersecret&token=abc#frag" ) result = safe_url_for_log(url) assert result == "https://example.com/.../image.png" assert "supersecret" not in result assert "token=abc" not in result assert "user:pass@" not in result class TestCacheAudioFromBytes: def test_sniffs_mp4_quicktime_audio_even_when_ext_is_ogg(self, tmp_path): payload = b"\x00\x00\x00\x14ftypqt " + b"\x00" * 32 with patch("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path): result = cache_audio_from_bytes(payload, ext=".ogg") saved = tmp_path / os.path.basename(result) assert saved.suffix == ".m4a" assert saved.read_bytes() == payload # --------------------------------------------------------------------------- # MessageEvent — command parsing # --------------------------------------------------------------------------- class TestMessageEventIsCommand: def test_slash_command(self): event = MessageEvent(text="/new") assert event.is_command() is True class TestMessageEventGetCommand: def test_simple_command(self): event = MessageEvent(text="/new") assert event.get_command() == "new" def test_command_with_args(self): event = MessageEvent(text="/reset session") assert event.get_command() == "reset" def test_not_a_command(self): event = MessageEvent(text="hello") assert event.get_command() is None class TestMessageEventGetCommandArgs: def test_command_with_args(self): event = MessageEvent(text="/new session id 123") assert event.get_command_args() == "session id 123" # --------------------------------------------------------------------------- # extract_images # --------------------------------------------------------------------------- class TestExtractImages: def test_no_images(self): images, cleaned = BasePlatformAdapter.extract_images("Just regular text.") assert images == [] assert cleaned == "Just regular text." def test_markdown_image_with_image_ext(self): content = "Here is a photo: ![cat](https://example.com/cat.png)" images, cleaned = BasePlatformAdapter.extract_images(content) assert len(images) == 1 assert images[0][0] == "https://example.com/cat.png" assert images[0][1] == "cat" assert "![cat]" not in cleaned def test_fal_media_cdn(self): content = "![gen](https://fal.media/files/abc123/output.png)" images, _ = BasePlatformAdapter.extract_images(content) assert len(images) == 1 assert images[0][0] == "https://fal.media/files/abc123/output.png" assert images[0][1] == "gen" def test_replicate_delivery(self): content = "![](https://replicate.delivery/pbxt/abc/output)" images, _ = BasePlatformAdapter.extract_images(content) assert len(images) == 1 assert images[0][0] == "https://replicate.delivery/pbxt/abc/output" assert images[0][1] == "" def test_html_img_tag(self): content = 'Check this: ' images, cleaned = BasePlatformAdapter.extract_images(content) assert len(images) == 1 assert images[0][0] == "https://example.com/photo.png" assert images[0][1] == "" # HTML images have no alt text assert " /root/.hermes) emits profile-scoped paths (``/profiles//cache/images/x.png``) that resolve under ``/root``. ``$HOME`` is NOT that prefix, so the root-home exception doesn't fire, and the top-level cache allowlist doesn't cover the profile subdir — the file was silently dropped. Per-profile cache roots must be allowlisted so it delivers. """ self._patch_roots(monkeypatch) # strict on, zero top-level cache roots # Stand-in for the literal /root deny prefix in the deployment. denied_root = tmp_path / "root" hermes_root = denied_root / ".hermes" prof_cache = hermes_root / "profiles" / "myprof" / "cache" / "images" prof_cache.mkdir(parents=True) image = prof_cache / "gen.png" image.write_bytes(b"\x89PNG\r\n\x1a\n") # $HOME is NOT the denied prefix (mirrors HOME=/opt/data/home). fake_home = tmp_path / "opt" / "data" / "home" fake_home.mkdir(parents=True) monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setattr( "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", (str(denied_root),), ) monkeypatch.setattr( "gateway.platforms.base._HERMES_ROOT", hermes_root ) assert ( BasePlatformAdapter.validate_media_delivery_path(str(image)) == str(image.resolve()) ) def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeypatch): """A symlink in the workdir pointing at a credential is rejected on its resolved target, even under the $HOME exception. """ self._patch_roots(monkeypatch) fake_home = tmp_path / "root" ssh_dir = fake_home / ".ssh" ssh_dir.mkdir(parents=True) key = ssh_dir / "id_rsa" key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----") workdir = fake_home / "work" workdir.mkdir() link = workdir / "innocent.pdf" link.symlink_to(key) monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setattr( "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", (str(fake_home),), ) assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None # --------------------------------------------------------------------------- # should_send_media_as_audio # --------------------------------------------------------------------------- class TestShouldSendMediaAsAudio: """Audio-routing policy shared by gateway + scheduler + send_message.""" def test_unknown_extension_returns_false(self): from gateway.platforms.base import should_send_media_as_audio assert should_send_media_as_audio(None, ".png") is False assert should_send_media_as_audio("telegram", ".pdf") is False def test_telegram_ogg_opus_only_when_voice_flagged(self): from gateway.platforms.base import should_send_media_as_audio assert should_send_media_as_audio("telegram", ".ogg", is_voice=True) is True assert should_send_media_as_audio("telegram", ".opus", is_voice=True) is True assert should_send_media_as_audio("telegram", ".ogg") is False assert should_send_media_as_audio("telegram", ".opus") is False # --------------------------------------------------------------------------- # truncate_message # --------------------------------------------------------------------------- class TestTruncateMessage: def _adapter(self): """Create a minimal adapter instance for testing static/instance methods.""" class StubAdapter(BasePlatformAdapter): async def connect(self, *, is_reconnect: bool = False): return True async def disconnect(self): pass async def send(self, *a, **kw): pass async def get_chat_info(self, *a): return {} from gateway.config import Platform, PlatformConfig config = PlatformConfig(enabled=True, token="test") return StubAdapter(config=config, platform=Platform.TELEGRAM) def test_short_message_single_chunk(self): adapter = self._adapter() chunks = adapter.truncate_message("Hello world", max_length=100) assert chunks == ["Hello world"] def test_exact_length_single_chunk(self): adapter = self._adapter() msg = "x" * 100 chunks = adapter.truncate_message(msg, max_length=100) assert chunks == [msg] @staticmethod def _truncate_with_timeout(content, max_length, *, len_fn=None, timeout=3.0): """Run truncate_message on a worker thread; fail if it doesn't return. Guards against the regression where a pathologically small max_length made the split loop never consume any input and spin forever. """ import threading box: dict = {} def _run(): box["result"] = BasePlatformAdapter.truncate_message( content, max_length, len_fn=len_fn ) t = threading.Thread(target=_run, daemon=True) t.start() t.join(timeout=timeout) assert not t.is_alive(), ( f"truncate_message hung (infinite loop) for max_length={max_length}" ) return box["result"] def test_pathological_small_max_length_terminates(self): # max_length 0 and 1 previously drove the split loop into an unbounded # hang (headroom -> 0, split_at -> 0, remaining never shrinks). It must # terminate and preserve every character across the chunks. import re for max_length in (0, 1, 2): chunks = self._truncate_with_timeout("abcdefghij", max_length) assert chunks, f"no chunks for max_length={max_length}" reassembled = "".join( re.sub(r"\s*\(\d+/\d+\)$", "", c) for c in chunks ) for ch in "abcdefghij": assert ch in reassembled, f"char {ch!r} lost at max_length={max_length}" def test_code_block_language_tag_carried(self): adapter = self._adapter() msg = "Start\n```javascript\n" + "console.log('x');\n" * 80 + "```\nEnd" chunks = adapter.truncate_message(msg, max_length=300) if len(chunks) > 1: # At least one continuation chunk should reopen with ```javascript reopened_with_lang = any("```javascript" in chunk for chunk in chunks[1:]) assert reopened_with_lang, ( "No continuation chunk reopened with language tag" ) # --------------------------------------------------------------------------- # _get_human_delay # --------------------------------------------------------------------------- class TestGetHumanDelay: def test_natural_mode_ignores_malformed_custom_env_vars(self): env = { "HERMES_HUMAN_DELAY_MODE": "natural", "HERMES_HUMAN_DELAY_MIN_MS": "oops", "HERMES_HUMAN_DELAY_MAX_MS": "still-bad", } with patch.dict(os.environ, env): delay = BasePlatformAdapter._get_human_delay() assert 0.8 <= delay <= 2.5 def test_custom_mode_tolerates_malformed_env_vars(self): env = { "HERMES_HUMAN_DELAY_MODE": "custom", "HERMES_HUMAN_DELAY_MIN_MS": "oops", "HERMES_HUMAN_DELAY_MAX_MS": "still-bad", } with patch.dict(os.environ, env): # falls back to the custom-mode defaults instead of crashing delay = BasePlatformAdapter._get_human_delay() assert 0.8 <= delay <= 2.5 # --------------------------------------------------------------------------- # utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn # --------------------------------------------------------------------------- # Ported from nearai/ironclaw#2304 — Telegram counts message length in UTF-16 # code units, not Unicode code-points. Astral-plane characters (emoji, CJK # Extension B) are surrogate pairs: 1 Python char but 2 UTF-16 units. class TestUtf16Len: """Verify the UTF-16 length helper.""" def test_ascii(self): assert utf16_len("hello") == 5 def test_bmp_cjk(self): # CJK ideographs in the BMP are 1 code unit each assert utf16_len("你好") == 2 class TestPrefixWithinUtf16Limit: """Verify UTF-16-aware prefix truncation.""" def test_fits_entirely(self): assert _prefix_within_utf16_limit("hello", 10) == "hello" def test_all_emoji(self): msg = "😀" * 10 # 20 UTF-16 units result = _prefix_within_utf16_limit(msg, 6) assert result == "😀😀😀" assert utf16_len(result) == 6 class TestTruncateMessageUtf16: """Verify truncate_message respects UTF-16 lengths when len_fn=utf16_len.""" def test_short_emoji_message_no_split(self): """A short message under the UTF-16 limit should not be split.""" msg = "Hello 😀 world" chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len) assert len(chunks) == 1 assert chunks[0] == msg def test_emoji_near_limit_triggers_split(self): """A message at 4096 codepoints but >4096 UTF-16 units must split.""" # 2049 emoji = 2049 codepoints but 4098 UTF-16 units → exceeds 4096 msg = "😀" * 2049 assert len(msg) == 2049 # Python len sees 2049 chars assert utf16_len(msg) == 4098 # but it's 4098 UTF-16 units # Without UTF-16 awareness, this would NOT split (2049 < 4096) chunks_naive = BasePlatformAdapter.truncate_message(msg, 4096) assert len(chunks_naive) == 1, "Without len_fn, no split expected" # With UTF-16 awareness, it MUST split chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len) assert len(chunks) > 1, "With utf16_len, message should be split" # Each chunk must fit within the UTF-16 limit for i, chunk in enumerate(chunks): assert utf16_len(chunk) <= 4096, ( f"Chunk {i} exceeds 4096 UTF-16 units: {utf16_len(chunk)}" ) class TestProxyKwargsForAiohttp: """Verify proxy_kwargs_for_aiohttp routes all schemes through ProxyConnector.""" def test_http_proxy_uses_connector_when_aiohttp_socks_available(self): pytest.importorskip("aiohttp_socks") from unittest.mock import MagicMock from gateway.platforms.base import proxy_kwargs_for_aiohttp sentinel = MagicMock(name="ProxyConnector") with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel): sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080") assert sess_kw.get("connector") is sentinel, ( "HTTP proxy must use ProxyConnector so libraries that don't " "forward per-request proxy= kwargs still route through the proxy" ) assert req_kw == {} class TestMediaDeliveryDiagnosability: """Diagnosable rejection logging + crafted-path robustness (#33251).""" def test_rejected_path_appears_in_log(self, tmp_path, caplog): outside = tmp_path / "outside.ogg" outside.write_bytes(b"OggS") with patch.dict(os.environ, {"HERMES_MEDIA_DELIVERY_STRICT": "1", "HERMES_MEDIA_TRUST_RECENT_FILES": "0"}), \ patch("gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", ()): with caplog.at_level("WARNING"): out = BasePlatformAdapter.filter_media_delivery_paths([(str(outside), False)]) assert out == [] # The dropped path must be in the log so operators can diagnose it. assert str(outside) in caplog.text def test_crafted_null_path_does_not_abort_batch(self, tmp_path, monkeypatch): """One crafted ~\\x00 path must not drop every other attachment.""" good = tmp_path / "good.png" good.write_bytes(b"\x89PNG") monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "0") out = BasePlatformAdapter.filter_media_delivery_paths([ ("~\x00evil.png", False), (str(good), False), ]) assert out == [(str(good.resolve()), False)] # --------------------------------------------------------------------------- # Media-send fallback must not leak host filesystem paths into chat # --------------------------------------------------------------------------- class _CapturingAdapter(BasePlatformAdapter): """Minimal concrete BasePlatformAdapter that records what send() sees. The four media-send fallbacks (send_voice, send_video, send_document, send_image_file) historically forwarded their *_path argument into the chat text. That argument is a host filesystem path inside the Hermes cache, so any subclass that fell back to super() — like the Telegram adapter on a rejected video — would leak the host's directory layout into the user's chat. """ def __init__(self): from gateway.config import Platform, PlatformConfig super().__init__(PlatformConfig(enabled=True), Platform.TELEGRAM) self.sent: list[dict] = [] async def connect(self) -> bool: # pragma: no cover - not exercised return True async def disconnect(self) -> None: # pragma: no cover - not exercised return None async def get_chat_info(self, chat_id): # pragma: no cover - not exercised return {"name": chat_id, "type": "dm"} async def send(self, chat_id, content, reply_to=None, metadata=None): from gateway.platforms.base import SendResult self.sent.append({ "chat_id": chat_id, "content": content, "reply_to": reply_to, "metadata": metadata, }) return SendResult(success=True, message_id="m1") class TestMediaFallbackDoesNotLeakHostPath: """Regression: the four base-class media fallbacks must not echo *_path. Telegram, Discord, and Slack adapters all fall back to these base implementations on native-send failure. When they did, the user saw a chat message like ``🎬 Video: /home/.../hermes/cache/video/abc.mp4`` — a host filesystem path with no actionable information. """ SENSITIVE_PATH = "/home/jayne/.hermes/cache/media/sensitive_host_path_abc123.bin" @pytest.mark.asyncio async def test_send_document_fallback_includes_explicit_filename_only(self): """A caller-supplied file_name is user-facing and may be shown — but the host file_path argument must still be suppressed.""" adapter = _CapturingAdapter() result = await adapter.send_document( chat_id="123", file_path=self.SENSITIVE_PATH, file_name="report.pdf", ) assert result.success sent_text = adapter.sent[0]["content"] assert self.SENSITIVE_PATH not in sent_text assert "/home/" not in sent_text assert "report.pdf" in sent_text @pytest.mark.asyncio async def test_caption_is_preserved_in_fallback(self): """The user-supplied caption is still shown — only the path is suppressed.""" adapter = _CapturingAdapter() await adapter.send_video( chat_id="123", video_path=self.SENSITIVE_PATH, caption="Here's the daily summary.", ) sent_text = adapter.sent[0]["content"] assert "Here's the daily summary." in sent_text assert self.SENSITIVE_PATH not in sent_text