Files
hermes-webui/tests/test_extension_status_endpoint.py
T
nesquena-hermes 1b7c1fe619 rebase #4569 onto master (v0.51.547) for ship-gate — extension status diagnostics (#4561 follow-up)
santastabber: authenticated /api/extensions/status endpoint reporting WHY an extension didn't load
(stable codes + coarse sources only, no path/env/URL leak). Co-authored-by: santastabber <santastabber@users.noreply.github.com>
2026-06-21 04:08:44 +00:00

361 lines
12 KiB
Python

"""Tests for sanitized WebUI extension diagnostics."""
from types import SimpleNamespace
import pytest
class FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.sent_headers = []
self.body = bytearray()
self.wfile = self
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
@pytest.fixture(autouse=True)
def _clear_extension_env(monkeypatch):
from api import auth as auth_mod
for name in (
"HERMES_WEBUI_EXTENSION_DIR",
"HERMES_WEBUI_EXTENSION_MANIFEST",
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
"HERMES_WEBUI_PASSWORD",
):
monkeypatch.delenv(name, raising=False)
auth_mod._invalidate_password_hash_cache()
yield
auth_mod._invalidate_password_hash_cache()
def test_extension_status_disabled_by_default():
from api.extensions import get_extension_status
assert get_extension_status() == {
"enabled": False,
"extension_dir_configured": False,
"extension_dir_valid": False,
"script_urls": [],
"stylesheet_urls": [],
"counts": {"script_urls": 0, "stylesheet_urls": 0},
"manifest": {
"configured": False,
"loaded": False,
"status": "not_configured",
"entry_count": 0,
"script_count": 0,
"stylesheet_count": 0,
},
"warnings": [],
}
def test_extension_status_reports_invalid_extension_dir_without_path(tmp_path, monkeypatch):
missing = tmp_path / "missing-extension-dir"
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(missing))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["enabled"] is False
assert status["extension_dir_configured"] is True
assert status["extension_dir_valid"] is False
assert status["manifest"]["status"] == "extension_disabled"
assert status["warnings"] == [
{"code": "extension_dir_unavailable", "source": "extension_dir"}
]
assert str(missing) not in repr(status)
def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "extensions.json").write_text(
"""
{
"scripts": ["runtime.js"],
"stylesheets": ["base.css"],
"extensions": [
{"id": "templates", "scripts": ["templates/app.js"], "stylesheets": ["templates/app.css"]},
{"id": "off", "enabled": false, "scripts": ["off.js"]}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["enabled"] is True
assert status["extension_dir_configured"] is True
assert status["extension_dir_valid"] is True
assert status["script_urls"] == [
"/extensions/runtime.js",
"/extensions/templates/app.js",
"/extensions/env.js",
]
assert status["stylesheet_urls"] == [
"/extensions/base.css",
"/extensions/templates/app.css",
]
assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2}
assert status["manifest"] == {
"configured": True,
"loaded": True,
"status": "loaded",
"entry_count": 2,
"script_count": 2,
"stylesheet_count": 2,
}
assert status["warnings"] == []
def test_extension_status_ignores_non_dict_manifest_extensions_in_entry_count(
tmp_path, monkeypatch
):
root = tmp_path / "extensions"
root.mkdir()
(root / "extensions.json").write_text(
"""
{
"extensions": [
null,
"not-an-extension",
{"id": "templates", "scripts": ["templates/app.js"]},
{"id": "off", "enabled": false, "scripts": ["off.js"]}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["script_urls"] == ["/extensions/templates/app.js"]
assert status["manifest"]["entry_count"] == 2
assert status["manifest"]["script_count"] == 1
assert status["warnings"] == []
def test_extension_status_reports_missing_manifest_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "missing.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["manifest"]["status"] == "missing"
assert status["manifest"]["loaded"] is False
assert status["warnings"] == [{"code": "manifest_missing", "source": "manifest"}]
assert str(root) not in repr(status)
assert "missing.json" not in repr(status)
def test_extension_status_reports_malformed_manifest_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "bad.json").write_text("{not json", encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bad.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["manifest"]["status"] == "malformed"
assert status["warnings"] == [{"code": "manifest_malformed", "source": "manifest"}]
assert "bad.json" not in repr(status)
def test_extension_status_reports_unreadable_manifest_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "bad-utf8.json").write_bytes(b"\xff\xfe")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bad-utf8.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["manifest"]["status"] == "unreadable"
assert status["warnings"] == [{"code": "manifest_unreadable", "source": "manifest"}]
assert "bad-utf8.json" not in repr(status)
def test_extension_status_reports_manifest_disabled_when_dir_unconfigured(monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["enabled"] is False
assert status["extension_dir_configured"] is False
assert status["extension_dir_valid"] is False
assert status["manifest"]["status"] == "extension_disabled"
assert status["manifest"]["configured"] is True
assert status["warnings"] == []
assert "extensions.json" not in repr(status)
def test_extension_status_reports_oversized_manifest_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "huge.json").write_text(" " * (64 * 1024 + 1), encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "huge.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["manifest"]["status"] == "oversized"
assert status["warnings"] == [{"code": "manifest_oversized", "source": "manifest"}]
assert "huge.json" not in repr(status)
def test_extension_status_reports_invalid_manifest_path_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "../outside.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["manifest"]["status"] == "invalid_path"
assert status["warnings"] == [
{"code": "manifest_invalid_path", "source": "manifest"}
]
assert "outside.json" not in repr(status)
assert str(root) not in repr(status)
def test_extension_status_reports_recursion_error_safely(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "deep.json").write_text("[]", encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "deep.json")
import api.extensions as extensions
def raise_recursion_error(_manifest_file):
raise RecursionError("manifest nesting exceeded")
monkeypatch.setattr(extensions, "_read_manifest_text", raise_recursion_error)
status = extensions.get_extension_status()
assert status["manifest"]["status"] == "too_deeply_nested"
assert status["warnings"] == [
{"code": "manifest_too_deeply_nested", "source": "manifest"}
]
assert "deep.json" not in repr(status)
def test_extension_status_reports_rejected_assets_without_rejected_values(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "extensions.json").write_text(
"""
{
"scripts": ["safe.js", "https://evil.example/app.js", "../escape.js"],
"stylesheets": ["safe.css", "nested/../escape.css"]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
from api.extensions import get_extension_status
status = get_extension_status()
assert status["script_urls"] == ["/extensions/safe.js"]
assert status["stylesheet_urls"] == ["/extensions/safe.css"]
assert {tuple(sorted(item.items())) for item in status["warnings"]} == {
tuple(sorted({"code": "asset_url_rejected", "source": "manifest:scripts"}.items())),
tuple(sorted({"code": "asset_url_rejected", "source": "manifest:stylesheets"}.items())),
}
rendered = repr(status)
assert "evil.example" not in rendered
assert "escape.js" not in rendered
assert "escape.css" not in rendered
def test_extension_status_reports_rejected_env_assets_without_rejected_values(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/safe.js, https://evil.example/env.js",
)
from api.extensions import get_extension_status
status = get_extension_status()
assert status["script_urls"] == ["/extensions/safe.js"]
assert status["warnings"] == [
{"code": "asset_url_rejected", "source": "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"}
]
rendered = repr(status)
assert "evil.example" not in rendered
assert "env.js" not in rendered
def test_extension_status_route_is_wired(monkeypatch):
from api import routes
captured = {}
def fake_j(handler, data, status=200, headers=None):
captured["data"] = data
captured["status"] = status
return True
monkeypatch.setattr(routes, "j", fake_j)
handler = FakeHandler()
assert routes.handle_get(handler, SimpleNamespace(path="/api/extensions/status")) is True
assert captured["status"] == 200
assert captured["data"]["enabled"] is False
def test_extension_status_route_requires_webui_auth(monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth
handler = FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/api/extensions/status", query="")) is False
assert handler.status == 401
assert handler.header("Location") is None