mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety - Route plugin lookups (installed list, update, recorded version, config form, web UI pages) through the plugin manager's resolver so plugins in ledmatrix-<id> directories work. - Captive-portal detection also sees the nmcli fallback AP (cached). - WiFi monitor daemon re-reads wifi_config.json when its mtime changes. - Drop the AP check in disconnect_from_network that could never fire. - LED status file per WiFiManager; config path falls back to this checkout. - BDF font preview via src.common.bdf_font. - Asset uploads validate every file before saving; metadata and calendar credentials written atomically; no absolute path in the response; asset delete answers 400 for a missing body. - Coerce string booleans in plugin toggle, on-demand start and AP force. - SSE broadcaster clears its thread handle before exiting. - start.py log filter handles every exc_info form. - Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls, raw-config error helper, update-route tidy, redundant imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): request BDF font previews now that the server renders them The Fonts tab skipped the preview request for .bdf files because the server used to refuse them; /fonts/preview now draws BDF with the shared loader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): take the update route's plugin directory from a directory listing CodeQL flagged the path built from the request's plugin_id (the id was already validated with safe_path_component, which CodeQL doesn't model; the same flow on main is alerts 738/739). The directory is now the entry of plugins_dir matched by name, so nothing built from user input reaches the filesystem; an id with nothing installed goes to the store manager, which reports it not found as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): read the blueprint's plugin_manager defensively in _plugin_directory _get_plugin_version now goes through _plugin_directory, which read api_v3.plugin_manager directly; the attribute exists only once the app sets it, so test_path_traversal_guards::test_a_real_manifest_is_read failed when run on its own (order-dependent in the full suite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
9.2 KiB
Python
201 lines
9.2 KiB
Python
"""Web routes find a plugin through the plugin manager's resolver.
|
|
|
|
Several routes built ``plugins_dir/<id>`` themselves. A plugin whose
|
|
directory is ``ledmatrix-<id>`` (the store's repo naming), or whose directory
|
|
name is not its manifest id, is not there, so those routes silently worked
|
|
on nothing: the installed list never refreshed its manifest or read its git
|
|
info, the recorded version was '', the update route compared manifests and
|
|
commits of a directory that does not exist, and the plugin pages 404'd or
|
|
rendered no schema. ``_plugin_directory()`` / ``get_plugin_directory()``
|
|
(src/plugin_system/plugin_dirs.py) is the one answer to "where is plugin X".
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from src.plugin_system.plugin_dirs import resolve_plugin_dir # noqa: E402
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
|
|
|
|
def _resolver(plugins_dir):
|
|
"""What PluginManager.get_plugin_directory does for an id discovery has
|
|
not mapped: <id>, then ledmatrix-<id>, in plugins_dir."""
|
|
def get_plugin_directory(plugin_id):
|
|
found = resolve_plugin_dir(plugin_id, [plugins_dir], prefix=True,
|
|
by_manifest=False)
|
|
return str(found) if found else None
|
|
return get_plugin_directory
|
|
|
|
|
|
@pytest.fixture
|
|
def prefixed_plugin(tmp_path, api_v3_module):
|
|
"""A 'demo' plugin installed as plugins_dir/ledmatrix-demo."""
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugin_dir = plugins_dir / "ledmatrix-demo"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
|
"id": "demo", "name": "Demo", "version": "2.0.0",
|
|
"description": "fresh from disk", "last_updated": "2026-09-01",
|
|
}), encoding="utf-8")
|
|
|
|
api = api_v3_module.api_v3
|
|
api.plugin_manager.plugins_dir = str(plugins_dir)
|
|
api.plugin_manager.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir))
|
|
api.plugin_store_manager.plugins_dir = str(plugins_dir)
|
|
return plugin_dir
|
|
|
|
|
|
class TestInstalledList:
|
|
def test_the_manifest_is_refreshed_from_the_prefixed_directory(
|
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
|
api = api_v3_module.api_v3
|
|
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
|
|
"description": "stale cached copy", "loaded": False}
|
|
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
|
|
api.plugin_manager.get_plugin = MagicMock(return_value=None)
|
|
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
|
api.config_manager.load_config = MagicMock(return_value={})
|
|
|
|
response = api_v3_client.get("/api/v3/plugins/installed")
|
|
|
|
assert response.status_code == 200
|
|
[entry] = [p for p in response.get_json()["data"]["plugins"] if p["id"] == "demo"]
|
|
assert entry["description"] == "fresh from disk"
|
|
# And git info is read from the same directory.
|
|
api.plugin_store_manager._get_local_git_info.assert_called_once_with(prefixed_plugin)
|
|
|
|
|
|
class TestRecordedVersion:
|
|
def test_the_version_is_read_from_the_prefixed_directory(
|
|
self, api_v3_module, prefixed_plugin):
|
|
assert api_v3_module._get_plugin_version("demo") == "2.0.0"
|
|
|
|
def test_an_unsafe_id_is_still_refused(self, api_v3_module, prefixed_plugin):
|
|
assert api_v3_module._get_plugin_version("../ledmatrix-demo") == ""
|
|
|
|
|
|
class TestUpdateRoute:
|
|
def _update(self, client, api):
|
|
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
|
|
api.plugin_store_manager.update_plugin = MagicMock(return_value=True)
|
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
|
api.plugin_manager.plugins = {}
|
|
api.schema_manager = None
|
|
api.plugin_state_manager = None
|
|
api.operation_history = None
|
|
return client.post("/api/v3/plugins/update", json={"plugin_id": "demo"})
|
|
|
|
def test_it_works_on_the_prefixed_directory(
|
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
|
api = api_v3_module.api_v3
|
|
response = self._update(api_v3_client, api)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
# The manifest it reports from is the plugin's, not a missing one.
|
|
assert response.get_json()["data"]["last_updated"] == "2026-09-01"
|
|
called_with = {c.args[0] for c in api.plugin_store_manager._get_local_git_info.call_args_list}
|
|
assert called_with == {prefixed_plugin}
|
|
|
|
def test_git_info_is_read_once_before_and_once_after(
|
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
|
# A third read existed only to feed a debug log line. (A plain
|
|
# plugins_dir/demo, so the old code's existence check let it run.)
|
|
(prefixed_plugin.parent / "demo").mkdir()
|
|
api = api_v3_module.api_v3
|
|
self._update(api_v3_client, api)
|
|
assert api.plugin_store_manager._get_local_git_info.call_count == 2
|
|
|
|
def test_a_plugin_that_is_not_installed_touches_no_path(
|
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
|
# The directory is taken from a listing of plugins_dir, so an id
|
|
# with nothing by that name installed never becomes a path at all.
|
|
api = api_v3_module.api_v3
|
|
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
|
|
api.plugin_store_manager.update_plugin = MagicMock(return_value=False)
|
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
|
api.plugin_manager.plugins = {}
|
|
api.operation_history = None
|
|
|
|
response = api_v3_client.post("/api/v3/plugins/update", json={"plugin_id": "ghost"})
|
|
|
|
assert response.status_code >= 400
|
|
assert "not found" in response.get_json()["message"]
|
|
api.plugin_store_manager._get_local_git_info.assert_not_called()
|
|
api.plugin_store_manager.update_plugin.assert_called_once_with("ghost")
|
|
|
|
|
|
@pytest.fixture
|
|
def pages(tmp_path, monkeypatch):
|
|
from web_interface.blueprints import pages_v3 as module
|
|
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugins_dir.mkdir()
|
|
plugin_manager = MagicMock()
|
|
plugin_manager.plugins_dir = plugins_dir
|
|
plugin_manager.get_plugin.return_value = None
|
|
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
|
|
monkeypatch.setattr(module.pages_v3, "config_manager",
|
|
MagicMock(load_config=lambda: {}), raising=False)
|
|
monkeypatch.setattr(module.pages_v3, "schema_manager", None, raising=False)
|
|
monkeypatch.setattr(module.pages_v3, "plugin_store_manager", MagicMock(), raising=False)
|
|
app = Flask(__name__, template_folder=str(
|
|
Path(module.__file__).resolve().parents[1] / "templates"))
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(module.pages_v3)
|
|
return module, plugins_dir, plugin_manager, app.test_client()
|
|
|
|
|
|
class TestPluginPages:
|
|
def test_web_ui_is_served_from_the_directory_discovery_found(self, pages):
|
|
# A directory name that is neither <id> nor ledmatrix-<id>: only the
|
|
# plugin manager's discovery map knows it holds 'radar'.
|
|
_, plugins_dir, plugin_manager, client = pages
|
|
web_ui = plugins_dir / "Radar-Checkout" / "web_ui"
|
|
web_ui.mkdir(parents=True)
|
|
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
|
|
plugin_manager.get_plugin_directory.side_effect = (
|
|
lambda pid: str(plugins_dir / "Radar-Checkout") if pid == "radar" else None)
|
|
|
|
response = client.get("/plugin-ui/radar/web-ui/panel.html")
|
|
|
|
assert response.status_code == 200
|
|
assert "radar panel" in response.get_data(as_text=True)
|
|
|
|
def test_the_config_form_reads_the_prefixed_directorys_schema(self, pages):
|
|
_, plugins_dir, plugin_manager, client = pages
|
|
plugin_dir = plugins_dir / "ledmatrix-weather"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "config_schema.json").write_text(json.dumps({
|
|
"type": "object",
|
|
"properties": {"enabled": {"type": "boolean"},
|
|
"units": {"type": "string", "title": "Turn It On"}},
|
|
}), encoding="utf-8")
|
|
(plugin_dir / "manifest.json").write_text(
|
|
json.dumps({"id": "weather", "name": "Weather"}), encoding="utf-8")
|
|
plugin_manager.get_plugin_info.return_value = {"id": "weather", "name": "Weather"}
|
|
plugin_manager.get_plugin_directory.side_effect = _resolver(plugins_dir)
|
|
|
|
response = client.get("/partials/plugin-config/weather")
|
|
|
|
# plugins_dir/weather has no schema, which rendered as a 500
|
|
# "schema unavailable".
|
|
assert response.status_code == 200, response.get_data(as_text=True)[:300]
|
|
assert "Turn It On" in response.get_data(as_text=True)
|
|
|
|
|
|
class TestPartialsDoNoUnusedWork:
|
|
def test_the_plugins_tab_reads_no_plugin_data(self, pages):
|
|
# plugins.html takes no plugin list; plugins_manager.js fetches it.
|
|
_, _, plugin_manager, client = pages
|
|
response = client.get("/partials/plugins")
|
|
assert response.status_code == 200
|
|
plugin_manager.get_all_plugin_info.assert_not_called()
|