Files
hermes-webui/tests/test_issue4746_extension_gallery.py
2026-07-03 20:26:18 +00:00

617 lines
22 KiB
Python

"""Behavioral tests for the extension gallery install/uninstall/registry feature."""
import hashlib
import io
import json
import socket
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def _make_zip(files: dict) -> bytes:
"""Build an in-memory zip containing the given {name: content} mapping."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
return buf.getvalue()
def _setup_ext_env(monkeypatch, tmp_path):
"""Point extension root and state dir at tmp_path subdirectories."""
ext_dir = tmp_path / "extensions"
ext_dir.mkdir()
state_dir = tmp_path / "state"
state_dir.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(ext_dir))
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
import api.extensions as ext_mod
monkeypatch.setattr(ext_mod, "_extension_state_dir", lambda: state_dir)
return ext_dir, state_dir
def test_install_valid(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"manifest.json": json.dumps({"version": "1.2.3"}),
"index.js": "console.log('hello');",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
mock_resp = MagicMock()
mock_resp.read.return_value = zip_bytes
mock_resp.close = MagicMock()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
result = ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert result["installed"] is True
assert result["id"] == "my-ext"
assert result["version"] == "1.2.3"
assert (ext_dir / "my-ext" / "index.js").exists()
manifest = ext_mod._load_install_manifest()
assert "my-ext" in manifest["installed"]
assert "index.js" in manifest["installed"]["my-ext"]["files"]
def test_install_prefixed_zip(monkeypatch, tmp_path):
"""Zip members rooted under <id>/ are stripped so files land at ext_dir/<id>/."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"my-ext/manifest.json": json.dumps({"version": "2.0.0"}),
"my-ext/index.js": "console.log('prefixed');",
"my-ext/sub/style.css": "body{}",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
result = ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert result["installed"] is True
assert result["version"] == "2.0.0"
assert (ext_dir / "my-ext" / "manifest.json").exists()
assert (ext_dir / "my-ext" / "index.js").exists()
assert (ext_dir / "my-ext" / "sub" / "style.css").exists()
# Verify NO double-nested directory
assert not (ext_dir / "my-ext" / "my-ext").exists()
manifest = ext_mod._load_install_manifest()
assert "manifest.json" in manifest["installed"]["my-ext"]["files"]
def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_path):
"""Installed gallery extensions are injected without a separate manifest env var."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
import api.extensions as ext_mod
files = {
"my-ext/manifest.json": json.dumps(
{
"version": "1.0.0",
"extensions": [
{
"id": "my-ext",
"name": "My Extension",
"scripts": ["assets/app.js"],
"stylesheets": ["assets/app.css"],
}
],
}
),
"my-ext/assets/app.js": "console.log('gallery runtime');",
"my-ext/assets/app.css": "body{}",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert ext_mod.get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/my-ext/assets/app.js"],
"stylesheet_urls": ["/extensions/my-ext/assets/app.css"],
"extensions": [
{
"id": "my-ext",
"name": "My Extension",
"storage_owned": False,
"settings_schema": [],
}
],
}
status = ext_mod.get_extension_status()
assert status["manifest"]["status"] == "gallery_installed"
assert status["counts"]["manifest_extensions"] == 1
assert status["extensions"][0]["id"] == "my-ext"
def test_gallery_installed_settings_only_manifest_becomes_runtime_entry(monkeypatch, tmp_path):
"""Settings-only gallery installs still surface in status and runtime config."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
import api.extensions as ext_mod
files = {
"my-ext/manifest.json": json.dumps(
{
"name": "Settings Only",
"version": "1.0.0",
"permissions": {"storage": {"owned": True}},
"settings_schema": [
{"key": "flag", "type": "boolean", "default": True},
],
}
),
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
ext_mod.install_extension(
"my-ext",
"https://hermes-webui.github.io/exts/my-ext.zip",
sha,
)
assert ext_mod.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
"extensions": [
{
"id": "my-ext",
"name": "Settings Only",
"storage_owned": True,
"settings_schema": [
{"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True},
],
}
],
}
status = ext_mod.get_extension_status()
assert status["manifest"]["status"] == "gallery_installed"
assert status["counts"]["manifest_extensions"] == 1
assert status["extensions"][0]["id"] == "my-ext"
assert status["extensions"][0]["storage_owned"] is True
assert status["extensions"][0]["settings_schema"] == [
{"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True},
]
def test_install_bootstraps_managed_default_root_without_env(monkeypatch, tmp_path):
"""Plug-and-play (#4933): one-click install works with NO env configured.
With HERMES_WEBUI_EXTENSION_DIR unset and no extension dir on disk, the
first install must bootstrap the WebUI-managed default
(STATE_DIR/extensions), land the files there, and make the extension load
via get_extension_config() — all without any environment setup.
"""
state_dir = tmp_path / "state"
state_dir.mkdir()
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
import api.extensions as ext_mod
monkeypatch.setattr(ext_mod, "_extension_state_dir", lambda: state_dir)
default_root = state_dir / "extensions"
# Pre-install: nothing exists yet, gallery is "configured" but not valid.
assert not default_root.exists()
pre = ext_mod.get_extension_status()
assert pre["enabled"] is False
assert pre["extension_dir_configured"] is True
assert pre["extension_dir_valid"] is False
assert pre["warnings"] == []
files = {
"plug-ext/manifest.json": json.dumps(
{
"version": "1.0.0",
"extensions": [
{
"id": "plug-ext",
"name": "Plug And Play",
"scripts": ["app.js"],
}
],
}
),
"plug-ext/app.js": "console.log('plug and play');",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
result = ext_mod.install_extension(
"plug-ext",
"https://hermes-webui.github.io/exts/plug-ext.zip",
sha,
)
assert result["installed"] is True
# The managed default root was created on demand and holds the files.
assert default_root.is_dir()
assert (default_root / "plug-ext" / "app.js").exists()
# And the extension now loads with zero env configuration.
assert ext_mod.get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/plug-ext/app.js"],
"stylesheet_urls": [],
"extensions": [
{
"id": "plug-ext",
"name": "Plug And Play",
"storage_owned": False,
"settings_schema": [],
}
],
}
status = ext_mod.get_extension_status()
assert status["enabled"] is True
assert status["extension_dir_configured"] is True
assert status["extension_dir_valid"] is True
assert status["manifest"]["status"] == "gallery_installed"
assert status["extensions"][0]["id"] == "plug-ext"
def test_install_bad_hash(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
zip_bytes = _make_zip({"index.js": "code"})
wrong_sha = "a" * 64
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError, match="SHA-256"):
ext_mod.install_extension(
"bad-ext",
"https://hermes-webui.github.io/exts/bad-ext.zip",
wrong_sha,
)
assert not (ext_dir / "bad-ext").exists()
def test_install_zipslip(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../../evil.txt", "pwned")
zip_bytes = buf.getvalue()
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError):
ext_mod.install_extension(
"slip-ext",
"https://hermes-webui.github.io/exts/slip-ext.zip",
sha,
)
assert not (tmp_path / "evil.txt").exists()
def test_uninstall(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {"index.js": "code", "style.css": "body{}"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
ext_mod.install_extension(
"rm-ext",
"https://hermes-webui.github.io/exts/rm-ext.zip",
sha,
)
assert (ext_dir / "rm-ext" / "index.js").exists()
result = ext_mod.uninstall_extension("rm-ext")
assert result["uninstalled"] is True
assert not (ext_dir / "rm-ext" / "index.js").exists()
assert not (ext_dir / "rm-ext" / "style.css").exists()
assert not (ext_dir / "rm-ext").exists()
manifest = ext_mod._load_install_manifest()
assert "rm-ext" not in manifest["installed"]
def test_uninstall_cleans_nested_dirs(monkeypatch, tmp_path):
"""Uninstall removes empty subdirectories left by nested files."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {
"deep-ext/manifest.json": "{}",
"deep-ext/sub/a/b.js": "code",
"deep-ext/sub/c.css": "body{}",
}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
ext_mod.install_extension(
"deep-ext",
"https://hermes-webui.github.io/exts/deep-ext.zip",
sha,
)
assert (ext_dir / "deep-ext" / "sub" / "a" / "b.js").exists()
ext_mod.uninstall_extension("deep-ext")
assert not (ext_dir / "deep-ext").exists()
def test_install_rollback(monkeypatch, tmp_path):
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
files = {"first.js": "a", "second.js": "b"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
write_count = [0]
original_write_bytes = Path.write_bytes
def patched_write_bytes(self, data):
write_count[0] += 1
if write_count[0] >= 2:
raise OSError("simulated write failure")
return original_write_bytes(self, data)
monkeypatch.setattr(Path, "write_bytes", patched_write_bytes)
with pytest.raises(ext_mod.ExtensionInstallError, match="Extraction failed"):
ext_mod.install_extension(
"roll-ext",
"https://hermes-webui.github.io/exts/roll-ext.zip",
sha,
)
remaining = list((ext_dir / "roll-ext").glob("**/*")) if (ext_dir / "roll-ext").exists() else []
assert remaining == []
def test_gallery_registry_list_format(monkeypatch, tmp_path):
"""Registry response as a top-level list (original format)."""
import api.extensions as ext_mod
registry_data = [
{"id": "ext-one", "name": "Extension One", "version": "0.1.0", "description": "First"},
{"id": "ext-two", "name": "Extension Two", "version": "0.2.0", "description": "Second"},
]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry()
assert "entries" in result
assert len(result["entries"]) == 2
assert result["entries"][0]["id"] == "ext-one"
assert result["entries"][1]["id"] == "ext-two"
def test_gallery_registry_extensions_format(monkeypatch, tmp_path):
"""Registry response wrapping extensions under {"extensions": [...]} (live format)."""
import api.extensions as ext_mod
registry_data = {
"version": 1,
"generated_at": "2026-06-24T18:30:40.265Z",
"extensions": [
{"id": "desktop-companion", "name": "Desktop Companion", "version": "0.1.0"},
],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
assert result["entries"][0]["id"] == "desktop-companion"
def test_install_rejects_symlinked_ext_dir_outside_root(monkeypatch, tmp_path):
"""Installation rejects a symlinked extension directory pointing outside root."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
target_dir = tmp_path / "symlink_target"
target_dir.mkdir()
link = ext_dir / "linked-ext"
try:
link.symlink_to(target_dir)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported")
files = {"index.js": "code"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError):
ext_mod.install_extension(
"linked-ext",
"https://hermes-webui.github.io/exts/linked-ext.zip",
sha,
)
assert not (target_dir / "index.js").exists()
def test_install_rejects_symlinked_ext_dir_inside_root(monkeypatch, tmp_path):
"""Installation rejects a pre-existing symlink even when target is within root."""
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
import api.extensions as ext_mod
target_dir = ext_dir / "real-target"
target_dir.mkdir()
link = ext_dir / "linked-ext"
try:
link.symlink_to(target_dir)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported")
files = {"index.js": "code"}
zip_bytes = _make_zip(files)
sha = hashlib.sha256(zip_bytes).hexdigest()
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
with pytest.raises(ext_mod.ExtensionInstallError, match="symlink"):
ext_mod.install_extension(
"linked-ext",
"https://hermes-webui.github.io/exts/linked-ext.zip",
sha,
)
def test_install_rejects_redirect_to_disallowed_host(monkeypatch, tmp_path):
"""Download rejects redirects to disallowed hosts."""
import api.extensions as ext_mod
def fake_download(url, max_bytes, timeout=30):
raise ext_mod.ExtensionInstallError("Download redirected to disallowed host")
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
monkeypatch.setattr(ext_mod, "_safe_download", fake_download)
with pytest.raises(ext_mod.ExtensionInstallError, match="disallowed host"):
ext_mod.install_extension(
"redir-ext",
"https://hermes-webui.github.io/exts/redir-ext.zip",
"a" * 64,
)
def test_connect_ipv4_first_tries_inet_before_inet6(monkeypatch):
"""_connect_ipv4_first calls getaddrinfo with AF_INET before AF_INET6."""
import api.extensions as ext_mod
call_order = []
real_getaddrinfo = socket.getaddrinfo
def tracking_getaddrinfo(host, port, family, *args, **kwargs):
call_order.append(family)
return real_getaddrinfo(host, port, family, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", tracking_getaddrinfo)
monkeypatch.setattr(socket, "getdefaulttimeout", lambda: None)
# We don't actually need to connect; just verify family ordering.
try:
ext_mod._connect_ipv4_first(("localhost", 80), timeout=None)
except OSError:
pass # connection failure is fine — we only care about call order
assert socket.AF_INET in call_order
if socket.AF_INET6 in call_order:
assert call_order.index(socket.AF_INET) < call_order.index(socket.AF_INET6)
def test_connect_ipv4_first_handles_global_default_timeout():
"""The _GLOBAL_DEFAULT_TIMEOUT sentinel is resolved correctly."""
import api.extensions as ext_mod
# Should not raise TypeError when passed the stdlib sentinel.
sentinel = socket._GLOBAL_DEFAULT_TIMEOUT
# Force default timeout to None so no actual timeout is set on the socket.
with patch.object(socket, "getdefaulttimeout", return_value=None):
try:
ext_mod._connect_ipv4_first(("127.0.0.1", 1), timeout=sentinel)
except OSError:
pass # connection refused is fine
except TypeError:
pytest.fail("TypeError — sentinel not resolved")
def test_gallery_opener_includes_ipv4_first_handler():
"""_build_gallery_opener produces an opener wired to the IPv4-first HTTPS handler."""
import api.extensions as ext_mod
opener = ext_mod._build_gallery_opener()
# build_opener instantiates handler classes internally; find the one whose
# https_open uses our IPv4-first connection class.
handler_classes = [type(h) for h in opener.handlers]
assert ext_mod._IPv4FirstHTTPSHandler in handler_classes
def test_safe_download_uses_gallery_opener(monkeypatch):
"""_safe_download routes through _build_gallery_opener (which is IPv4-first)."""
import api.extensions as ext_mod
fake_response = MagicMock()
fake_response.read.return_value = b"file-content"
fake_response.close = MagicMock()
fake_opener = MagicMock(open=lambda url, timeout=None: fake_response)
called = []
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod._safe_download("https://example.com/x.zip", 1024)
assert result == b"file-content"
assert called == [True]
def test_registry_fetch_uses_gallery_opener(monkeypatch):
"""get_extension_registry routes through _build_gallery_opener (IPv4-first)."""
import api.extensions as ext_mod
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{"extensions": [{"id": "test-ext", "name": "Test"}]}
).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
called = []
fake_opener = MagicMock(open=lambda *a, **kw: mock_resp)
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
assert result["entries"][0]["id"] == "test-ext"
assert called == [True]