mirror of
https://github.com/openhop-dev/openhop_repeater.git
synced 2026-10-25 22:42:34 +00:00
180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
"""Tests for plugin API endpoints and static UI serving."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import cherrypy
|
|
import pytest
|
|
|
|
from repeater.plugins.manifest import PluginManifest, UISpec
|
|
from repeater.plugins.storage import PluginStorage
|
|
from repeater.web.http_server import StatsApp
|
|
from repeater.web.plugin_endpoints import PluginAPIEndpoints
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self):
|
|
self.status = 200
|
|
self.headers = {}
|
|
|
|
|
|
@pytest.fixture
|
|
def cherrypy_request():
|
|
cherrypy.request.method = "GET"
|
|
cherrypy.request.json = {}
|
|
cherrypy.response = _Resp()
|
|
yield
|
|
cherrypy.response = _Resp()
|
|
|
|
|
|
def test_list_returns_503_when_manager_missing(tmp_path, cherrypy_request):
|
|
cfg = {"storage": {"storage_dir": str(tmp_path)}}
|
|
api = PluginAPIEndpoints(cfg)
|
|
cherrypy.request.method = "GET"
|
|
result = api.index()
|
|
assert result["success"] is False
|
|
assert cherrypy.response.status == 503
|
|
|
|
|
|
def test_list_and_status_via_client_mock(tmp_path, cherrypy_request):
|
|
cfg = {"storage": {"storage_dir": str(tmp_path)}}
|
|
api = PluginAPIEndpoints(cfg)
|
|
mock_client = MagicMock()
|
|
mock_client.available.return_value = True
|
|
mock_client.list_plugins.return_value = [
|
|
{"id": "openhop.demo", "state": "STOPPED", "enabled": False}
|
|
]
|
|
mock_client.status.return_value = {
|
|
"id": "openhop.demo",
|
|
"state": "RUNNING",
|
|
"enabled": True,
|
|
}
|
|
mock_client.enable.return_value = {"id": "openhop.demo", "enabled": True, "state": "RUNNING"}
|
|
mock_client.get_runtime.return_value = {
|
|
"id": "openhop.demo",
|
|
"exists": True,
|
|
"runtime": {"schema": 1, "running": True},
|
|
}
|
|
|
|
with patch.object(api, "_client_or_raise", return_value=mock_client):
|
|
cherrypy.request.method = "GET"
|
|
listed = api.index()
|
|
assert listed["success"] is True
|
|
assert listed["plugins"][0]["id"] == "openhop.demo"
|
|
|
|
status = api.default("openhop.demo")
|
|
assert status["success"] is True
|
|
assert status["state"] == "RUNNING"
|
|
|
|
cherrypy.request.method = "POST"
|
|
cherrypy.request.json = {"id": "openhop.demo"}
|
|
enabled = api.enable()
|
|
assert enabled["success"] is True
|
|
|
|
cherrypy.request.method = "GET"
|
|
runtime = api.runtime(id="openhop.demo")
|
|
assert runtime["success"] is True
|
|
assert runtime["runtime"]["schema"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize("upload_safe", [False, True])
|
|
def test_upload_timeout_reports_unknown_and_preserves_unacknowledged_file(
|
|
tmp_path, cherrypy_request, upload_safe
|
|
):
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from repeater.plugins.ipc import PluginIPCOutcomeUnknown
|
|
|
|
api = PluginAPIEndpoints({"storage": {"storage_dir": str(tmp_path)}})
|
|
uploaded = []
|
|
|
|
def install(path):
|
|
uploaded.append(Path(path))
|
|
raise PluginIPCOutcomeUnknown(upload_safe=upload_safe)
|
|
|
|
client = SimpleNamespace(install=install)
|
|
cherrypy.request.method = "POST"
|
|
wheel = SimpleNamespace(filename="demo-1-py3-none-any.whl", file=BytesIO(b"wheel"))
|
|
with patch.object(api, "_client_or_raise", return_value=client):
|
|
result = api.install(wheel=wheel)
|
|
assert cherrypy.response.status == 504
|
|
assert result["outcome"] == "unknown"
|
|
assert "may still complete" in result["error"]
|
|
assert uploaded[0].exists() is (not upload_safe)
|
|
|
|
|
|
def test_upload_completed_install_preserves_sync_contract(tmp_path, cherrypy_request):
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
api = PluginAPIEndpoints({"storage": {"storage_dir": str(tmp_path)}})
|
|
uploaded = []
|
|
|
|
def install(path):
|
|
uploaded.append(Path(path))
|
|
assert uploaded[0].read_bytes() == b"wheel"
|
|
return {"id": "demo", "version": "1"}
|
|
|
|
cherrypy.request.method = "POST"
|
|
wheel = SimpleNamespace(filename="demo-1-py3-none-any.whl", file=BytesIO(b"wheel"))
|
|
with patch.object(api, "_client_or_raise", return_value=SimpleNamespace(install=install)):
|
|
result = api.install(wheel=wheel)
|
|
assert result == {"success": True, "plugin": {"id": "demo", "version": "1"}}
|
|
assert not uploaded[0].exists()
|
|
|
|
|
|
def test_ui_serving_index_assets_spa_and_disabled(tmp_path):
|
|
plugins_root = tmp_path / "plugins"
|
|
storage = PluginStorage(plugins_root)
|
|
plugin_id = "openhop.console"
|
|
version = "1.0.0"
|
|
storage.ensure_plugin_layout(plugin_id, version)
|
|
storage.write_manifest(
|
|
plugin_id,
|
|
version,
|
|
PluginManifest(
|
|
schema=1,
|
|
id=plugin_id,
|
|
name="Console",
|
|
version=version,
|
|
ui=UISpec(type="application", entry="ui/index.html"),
|
|
),
|
|
)
|
|
storage.set_current(plugin_id, version)
|
|
release = storage.paths_for(plugin_id).release_dir(version)
|
|
(release / "ui").mkdir(parents=True)
|
|
(release / "ui" / "index.html").write_text("<html>ok</html>")
|
|
(release / "ui" / "assets").mkdir()
|
|
(release / "ui" / "assets" / "app.js").write_text("console.log(1)")
|
|
|
|
app = StatsApp(config={"storage": {"storage_dir": str(tmp_path)}})
|
|
|
|
# Disabled → 404
|
|
storage.write_state(plugin_id, {"version": version, "enabled": False})
|
|
with pytest.raises(cherrypy.HTTPError):
|
|
# NotFound is HTTPError subclass in cherrypy
|
|
try:
|
|
app._serve_plugin_ui(plugin_id, ())
|
|
except cherrypy.NotFound:
|
|
raise cherrypy.HTTPError(404)
|
|
|
|
storage.write_state(plugin_id, {"version": version, "enabled": True})
|
|
|
|
index = app._serve_plugin_ui(plugin_id, ())
|
|
assert b"ok" in index
|
|
|
|
# Paths are relative to the entry directory (ui/), matching Vite base './'
|
|
asset = app._serve_plugin_ui(plugin_id, ("assets", "app.js"))
|
|
assert b"console.log" in asset
|
|
|
|
# SPA fallback
|
|
spa = app._serve_plugin_ui(plugin_id, ("dashboard", "settings"))
|
|
assert b"ok" in spa
|
|
|
|
# Path traversal rejected
|
|
with pytest.raises(cherrypy.HTTPError):
|
|
app._serve_plugin_ui(plugin_id, ("..", "..", "etc", "passwd"))
|