""" Tests for feat #450: MEDIA: token inline rendering in web UI chat. Covers: 1. /api/media endpoint: serves local image files by absolute path 2. /api/media endpoint: rejects paths outside allowed roots (path traversal) 3. /api/media endpoint: 404 for non-existent files 4. /api/media endpoint: auth gate when auth is enabled 5. renderMd() MEDIA: stash/restore logic (static JS analysis) 6. /api/media endpoint: integration test via live server (requires 8788) """ from __future__ import annotations import json import os import pathlib import tempfile import unittest from types import SimpleNamespace from unittest import mock import urllib.error import urllib.parse import urllib.request from tests._pytest_port import BASE, TEST_STATE_DIR REPO_ROOT = pathlib.Path(__file__).parent.parent UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8") # ── Static analysis: renderMd MEDIA stash ──────────────────────────────────── class TestMediaRenderMdStash(unittest.TestCase): """Verify the MEDIA: stash/restore logic exists in ui.js.""" def test_media_stash_defined(self): self.assertIn("media_stash", UI_JS, "media_stash array must be defined in renderMd()") def test_media_token_regex(self): self.assertIn("MEDIA:", UI_JS, "MEDIA: token regex must be present in renderMd()") def test_bare_file_urls_are_stashed_as_media_artifacts(self): self.assertIn("file:// links for local artifacts", UI_JS) self.assertIn("file:\\/\\/[^\\s<>", UI_JS) def test_file_urls_are_rewritten_through_media_endpoint(self): self.assertIn("new URL(ref)", UI_JS) self.assertIn("u.pathname", UI_JS) self.assertIn("api/media?path=", UI_JS) def test_media_restore_produces_img_tag(self): self.assertIn("msg-media-img", UI_JS, "restore pass must produce ") def test_media_restore_produces_download_link(self): self.assertIn("msg-media-link", UI_JS, "restore pass must produce download link for non-image files") def test_media_api_url_pattern(self): self.assertIn("api/media?path=", UI_JS, "renderMd must build api/media?path=... URL for local files") def test_local_media_api_url_carries_session_id_when_available(self): self.assertIn("session_id='+encodeURIComponent(mediaSessionId)", UI_JS, "local MEDIA: image URLs must include session_id so the server can authorize session-referenced artifacts") def test_local_audio_video_media_tokens_request_inline_streaming(self): self.assertIn("apiUrl+'&inline=1'", UI_JS, "MEDIA: audio/video local paths must request inline streaming") def test_media_stash_uses_null_byte_token(self): self.assertIn("\\x00D", UI_JS, "MEDIA stash must use null-byte token (\\x00D) to avoid conflicts") def test_media_stash_runs_before_fence_stash(self): media_pos = UI_JS.find("media_stash") fence_pos = UI_JS.find("fence_stash") self.assertGreater(fence_pos, media_pos, "media_stash must be defined before fence_stash in renderMd()") def test_image_extension_regex_covers_common_types(self): # The JS source has these extensions in a regex like /\.png|jpg|.../i # Check for the extension strings (without the dot, which may be escaped as \.) for ext in ["png", "jpg", "jpeg", "gif", "webp"]: self.assertIn(ext, UI_JS, f"Image extension {ext} must be in the MEDIA img-check regex") def test_http_url_media_rendered_as_img(self): # renderMd should treat MEDIA:https://... as an # In the JS source, the regex is /^https?:\/\//i (escaped) self.assertTrue( "https?:" in UI_JS or "http" in UI_JS, "MEDIA: restore must handle HTTPS URLs", ) def test_zoom_toggle_on_click(self): # PR #1135: CSS class toggle replaced by proper lightbox overlay self.assertIn("_openImgLightbox", UI_JS, "Clicking the image must open lightbox overlay (_openImgLightbox)") # ── Static analysis: CSS ────────────────────────────────────────────────────── class TestMediaCSS(unittest.TestCase): CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") def test_msg_media_img_class_defined(self): self.assertIn(".msg-media-img", self.CSS) def test_msg_media_img_max_width(self): # PR #1135: resting thumbnail is 120x90px (fixed size); no max-width needed. # Lightbox shows full-size. Check width is set instead. idx = self.CSS.find(".msg-media-img{") self.assertGreater(idx, 0) rule = self.CSS[idx:idx+200] self.assertIn("width:120px", rule, "Thumbnail must have fixed 120px width") def test_msg_media_img_full_class_defined(self): # PR #1135: .msg-media-img--full removed; lightbox replaces inline zoom. self.assertIn(".img-lightbox", self.CSS, "Full-size toggle class must exist for zoom-on-click") def test_msg_media_link_class_defined(self): self.assertIn(".msg-media-link", self.CSS, "Download link style must be defined for non-image media") class TestInlineAudioVideoEditor(unittest.TestCase): """Static checks for inline audio/video preview controls in chat and workspace.""" CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8") INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") def test_audio_and_video_extension_detection_exists(self): self.assertIn("_AUDIO_EXTS", UI_JS) self.assertIn("_VIDEO_EXTS", UI_JS) for ext in ["mp3", "wav", "m4a", "mp4", "mov", "webm"]: self.assertIn(ext, UI_JS) def test_media_player_markup_has_native_controls(self): self.assertIn("_mediaPlayerHtml", UI_JS) self.assertIn("Hermes" with tempfile.NamedTemporaryFile( suffix=".html", prefix="hermes_test_", dir="/tmp", delete=False ) as f: f.write(html_bytes) tmp_path = f.name try: encoded = urllib.request.quote(tmp_path) body, status, headers = self._get(f"/api/media?path={encoded}") self.assertEqual(status, 200) self.assertIn("text/html", headers.get("Content-Type", "")) self.assertIn("attachment", headers.get("Content-Disposition", "")) self.assertIn("DENY", headers.get_all("X-Frame-Options", [])) self.assertFalse( any("sandbox allow-scripts" == h for h in headers.get_all("Content-Security-Policy", [])) ) self.assertEqual(body, html_bytes) body, status, headers = self._get(f"/api/media?path={encoded}&inline=1") self.assertEqual(status, 200) self.assertIn("text/html", headers.get("Content-Type", "")) self.assertIn("inline", headers.get("Content-Disposition", "")) self.assertEqual(headers.get_all("X-Frame-Options", []), []) self.assertTrue( any("sandbox allow-scripts" == h for h in headers.get_all("Content-Security-Policy", [])) ) self.assertEqual(body, html_bytes) finally: pathlib.Path(tmp_path).unlink(missing_ok=True) def test_path_traversal_rejected(self): _, status, _ = self._get( "/api/media?path=" + urllib.request.quote("/tmp/../../etc/passwd") ) self.assertIn(status, {403, 404}) def test_webui_state_secret_files_denied(self): """#3234: /api/media must hard-deny WebUI state/secret files even though they live under an allowed root (the whole Hermes home is allowed). An authenticated session rendering attacker-influenced agent output that emits a file:// or MEDIA: link to settings.json / state.db / auth.json must NOT be able to fetch it through /api/media. """ state_dir = pathlib.Path(TEST_STATE_DIR) state_dir.mkdir(parents=True, exist_ok=True) # settings.json by name (deny-by-filename) settings = state_dir / "settings.json" settings.write_text('{"secret":"value"}', encoding="utf-8") try: _, status, _ = self._get( "/api/media?path=" + urllib.request.quote(str(settings.resolve())) ) self.assertEqual( status, 403, f"settings.json under the state dir must be denied, got {status}", ) finally: settings.unlink(missing_ok=True) # a file inside the sessions/ state subdir (deny-by-dir) sess_dir = state_dir / "sessions" sess_dir.mkdir(parents=True, exist_ok=True) sess_file = sess_dir / "abc123.json" sess_file.write_text('{"messages":[]}', encoding="utf-8") try: _, status, _ = self._get( "/api/media?path=" + urllib.request.quote(str(sess_file.resolve())) ) self.assertEqual( status, 403, f"files under the sessions/ state subdir must be denied, got {status}", ) finally: sess_file.unlink(missing_ok=True) def test_deny_list_does_not_overblock_legitimate_media(self): """#3234 follow-up: the state/secret deny-list must NOT block ordinary media that merely shares a sensitive basename but lives OUTSIDE any Hermes state root (e.g. a user artifact in /tmp named settings.json). The deny is scoped to files under a Hermes root; a /tmp PNG named settings.png — or even settings.json — is the user's own content and must still be served (200), not 403. """ png_bytes = ( b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00' b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' ) # A /tmp artifact whose stem collides with a denied basename — must serve # because /tmp is not a Hermes state root. with tempfile.NamedTemporaryFile( suffix=".png", prefix="settings_artifact_", dir="/tmp", delete=False ) as f: f.write(png_bytes) tmp_path = f.name try: body, status, headers = self._get( f"/api/media?path={urllib.request.quote(tmp_path)}" ) self.assertEqual( status, 200, f"a /tmp PNG outside any Hermes root must serve, got {status}", ) self.assertIn("image/png", headers.get("Content-Type", "")) finally: pathlib.Path(tmp_path).unlink(missing_ok=True) def test_health_check_still_works(self): """Sanity: server is up and /health works.""" body, status, _ = self._get("/health") self.assertEqual(status, 200) d = json.loads(body) self.assertEqual(d["status"], "ok")