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, BDF preview (#655)
* 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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
"""Boolean request fields are coerced, not used raw.
|
||||
|
||||
``bool("false")`` is True. /plugins/toggle stored a JSON ``"enabled": "false"``
|
||||
as-is in config.json (a truthy string the display then treats as enabled) and
|
||||
passed it to the Starlark toggle the same way, and /display/on-demand/start
|
||||
pinned the mode and restarted the service for ``"pinned": "false"`` /
|
||||
``"start_service": "false"``.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
class TestPluginToggle:
|
||||
@pytest.fixture
|
||||
def saved(self, api_v3_module, monkeypatch):
|
||||
monkeypatch.setattr(api_v3_module, '_discovered_plugin_manifests',
|
||||
lambda *a, **k: {'clock': {}})
|
||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={})
|
||||
captured = {}
|
||||
|
||||
def save(config_manager, config, create_backup=True):
|
||||
captured.update(config)
|
||||
return True, None
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', save)
|
||||
return captured
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("false", False), ("true", True), (False, False), (True, True), (0, False),
|
||||
])
|
||||
def test_enabled_is_stored_as_a_real_bool(self, api_v3_client, saved, raw, expected):
|
||||
response = api_v3_client.post('/api/v3/plugins/toggle',
|
||||
json={'plugin_id': 'clock', 'enabled': raw})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert saved['clock']['enabled'] is expected
|
||||
|
||||
def test_a_starlark_app_gets_the_coerced_value(self, api_v3_client, api_v3_module):
|
||||
toggle = MagicMock(return_value=({'status': 'success'}, 200))
|
||||
with patch('web_interface.blueprints.api_v3.plugins._toggle_starlark_app', toggle):
|
||||
api_v3_client.post('/api/v3/plugins/toggle',
|
||||
json={'plugin_id': 'starlark:clock', 'enabled': 'false'})
|
||||
toggle.assert_called_once_with('clock', False)
|
||||
|
||||
|
||||
class TestOnDemandStart:
|
||||
@pytest.fixture
|
||||
def service(self, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
return_value={"active": True}), \
|
||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
||||
return_value={"active": True}) as ensure:
|
||||
yield stop, ensure
|
||||
|
||||
def test_string_false_neither_pins_nor_restarts(self, api_v3_client, service):
|
||||
stop, ensure = service
|
||||
response = api_v3_client.post('/api/v3/display/on-demand/start', json={
|
||||
'plugin_id': 'weather', 'pinned': 'false', 'start_service': 'false'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['pinned'] is False
|
||||
stop.assert_not_called()
|
||||
ensure.assert_not_called()
|
||||
|
||||
def test_string_true_pins(self, api_v3_client, service):
|
||||
response = api_v3_client.post('/api/v3/display/on-demand/start', json={
|
||||
'plugin_id': 'weather', 'pinned': 'true', 'start_service': False})
|
||||
assert response.get_json()['data']['pinned'] is True
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Plugin asset uploads and the calendar credentials upload write safely.
|
||||
|
||||
* An upload of several files checked and saved them one at a time, so a bad
|
||||
third file answered 400 after the first two were already on disk and in
|
||||
.metadata.json -- the user was told it failed and the images appeared anyway.
|
||||
* .metadata.json and credentials.json were written in place (open 'w' /
|
||||
FileStorage.save), so a failure mid-write left a truncated file.
|
||||
* The asset delete route called get_json() without silent=True.
|
||||
* The credentials route returned the server's absolute path; nothing reads it.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 20
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, api_v3_module, monkeypatch):
|
||||
import web_interface.blueprints.api_v3.plugins as plugins_module
|
||||
monkeypatch.setattr(plugins_module, "PROJECT_ROOT", tmp_path)
|
||||
return tmp_path / "assets" / "plugins" / "static-image" / "uploads"
|
||||
|
||||
|
||||
def _upload(client, files):
|
||||
return client.post(
|
||||
"/api/v3/plugins/assets/upload",
|
||||
data={"plugin_id": "static-image",
|
||||
"files": [(io.BytesIO(body), name) for name, body in files]},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
|
||||
class TestAssetUpload:
|
||||
def test_a_bad_file_saves_none_of_the_batch(self, api_v3_client, project):
|
||||
response = _upload(api_v3_client, [
|
||||
("a.png", PNG), ("b.png", PNG), ("c.png", b"not an image at all"),
|
||||
])
|
||||
assert response.status_code == 400
|
||||
saved = [p.name for p in project.iterdir()] if project.exists() else []
|
||||
assert saved == [], "files before the bad one were saved anyway"
|
||||
|
||||
def test_a_batch_over_the_total_limit_saves_none(self, api_v3_client, project, monkeypatch):
|
||||
# The total-size check also runs before anything is written.
|
||||
project.mkdir(parents=True)
|
||||
(project / ".metadata.json").write_text(json.dumps(
|
||||
{"old": {"id": "old", "size": 50 * 1024 * 1024 - 30}}), encoding="utf-8")
|
||||
response = _upload(api_v3_client, [("a.png", PNG), ("b.png", PNG)])
|
||||
assert response.status_code == 400
|
||||
assert sorted(p.name for p in project.iterdir()) == [".metadata.json"]
|
||||
|
||||
def test_a_good_batch_is_all_saved_and_recorded(self, api_v3_client, project):
|
||||
response = _upload(api_v3_client, [("a.png", PNG), ("b.png", PNG)])
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()
|
||||
assert len(body["uploaded_files"]) == 2
|
||||
metadata = json.loads((project / ".metadata.json").read_text(encoding="utf-8"))
|
||||
assert sorted(metadata) == sorted(f["id"] for f in body["uploaded_files"])
|
||||
for entry in body["uploaded_files"]:
|
||||
assert (project / entry["filename"]).exists()
|
||||
|
||||
def test_metadata_is_replaced_by_rename(self, api_v3_client, project, monkeypatch):
|
||||
import src.config_manager_atomic as atomic
|
||||
replaced = []
|
||||
real = atomic.os.replace
|
||||
monkeypatch.setattr(atomic.os, "replace",
|
||||
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||
_upload(api_v3_client, [("a.png", PNG)])
|
||||
assert ".metadata.json" in replaced
|
||||
|
||||
|
||||
class TestAssetDelete:
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{}, # no body at all
|
||||
{"data": "plugin_id=x", "content_type": "application/x-www-form-urlencoded"},
|
||||
{"json": ["plugin_id", "image_id"]}, # JSON, not an object
|
||||
])
|
||||
def test_a_missing_or_non_object_body_is_a_400(self, api_v3_client, project, kwargs):
|
||||
response = api_v3_client.post("/api/v3/plugins/assets/delete", **kwargs)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_metadata_is_replaced_by_rename(self, api_v3_client, project, monkeypatch):
|
||||
uploaded = _upload(api_v3_client, [("a.png", PNG)]).get_json()["uploaded_files"][0]
|
||||
import src.config_manager_atomic as atomic
|
||||
replaced = []
|
||||
real = atomic.os.replace
|
||||
monkeypatch.setattr(atomic.os, "replace",
|
||||
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||
response = api_v3_client.post("/api/v3/plugins/assets/delete",
|
||||
json={"plugin_id": "static-image",
|
||||
"image_id": uploaded["id"]})
|
||||
assert response.status_code == 200
|
||||
assert ".metadata.json" in replaced
|
||||
assert json.loads((project / ".metadata.json").read_text(encoding="utf-8")) == {}
|
||||
|
||||
|
||||
class TestCalendarCredentials:
|
||||
CREDS = {"installed": {"client_id": "x", "client_secret": "y"}}
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(self, tmp_path, api_v3_module):
|
||||
directory = tmp_path / "plugins" / "calendar"
|
||||
directory.mkdir(parents=True)
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
def _post(self, client):
|
||||
return client.post(
|
||||
"/api/v3/plugins/calendar/upload-credentials",
|
||||
data={"file": (io.BytesIO(json.dumps(self.CREDS).encode()), "credentials.json")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
def test_the_absolute_server_path_is_not_returned(self, api_v3_client, plugin_dir):
|
||||
body = self._post(api_v3_client).get_json()
|
||||
assert body["status"] == "success"
|
||||
assert body["path"] == "credentials.json"
|
||||
assert str(plugin_dir) not in json.dumps(body)
|
||||
|
||||
def test_the_file_is_replaced_by_rename(self, api_v3_client, plugin_dir, monkeypatch):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
|
||||
import src.config_manager_atomic as atomic
|
||||
replaced = []
|
||||
real = atomic.os.replace
|
||||
monkeypatch.setattr(atomic.os, "replace",
|
||||
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||
assert self._post(api_v3_client).status_code == 200
|
||||
assert replaced == ["credentials.json"]
|
||||
assert json.loads((plugin_dir / "credentials.json").read_text()) == self.CREDS
|
||||
@@ -261,8 +261,13 @@ class TestApMode:
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), (False, False),
|
||||
("true", True), ("TRUE", True), ("1", True),
|
||||
("false", False), ("no", False), ("yes", False),
|
||||
(1, False), # only real True or the listed strings count
|
||||
("false", False), ("no", False),
|
||||
# Parsed by _parse_bool_ish like every other boolean on these routes
|
||||
# (the radio route's force included); this one used to have its own
|
||||
# rules, under which "yes" and 1 meant False.
|
||||
("yes", True), (1, True), (0, False),
|
||||
# Anything unrecognised is not a request to force.
|
||||
("typo", False), (None, False), (2, False),
|
||||
])
|
||||
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""WiFi / AP-mode fixes around the web UI and the monitor daemon.
|
||||
|
||||
* disconnect_from_network ran an AP-mode check that could never enable the
|
||||
AP: the web routes build a fresh WiFiManager per request, whose grace
|
||||
counter starts at 0 and needs 3 consecutive checks. It only added sleeps.
|
||||
* The monitor daemon read wifi_config.json once at startup, so the web UI's
|
||||
auto-enable toggle (which only writes the file) had no effect until the
|
||||
daemon restarted.
|
||||
* The captive-portal endpoints only checked hostapd, but enable_ap_mode falls
|
||||
back to an nmcli AP; with that AP up, phones got "internet works".
|
||||
* The LED status file path was a module global set by whichever WiFiManager
|
||||
was built first, and the config path fell back to /home/ledpi/LEDMatrix.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import src.wifi_manager as wm # noqa: E402
|
||||
from src.wifi_manager import WiFiManager, WiFiStatus # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _ok(stdout=""):
|
||||
return SimpleNamespace(returncode=0, stdout=stdout, stderr="")
|
||||
|
||||
|
||||
def _manager(config_path):
|
||||
with patch("src.wifi_manager.subprocess.run", return_value=_ok("wlan0\n")):
|
||||
manager = WiFiManager(config_path=config_path)
|
||||
manager._wifi_interface = "wlan0"
|
||||
manager.has_nmcli = True
|
||||
return manager
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
def test_no_ap_check_and_no_extra_sleep(self, tmp_path):
|
||||
manager = _manager(tmp_path / "config" / "wifi_config.json")
|
||||
manager.get_wifi_status = MagicMock(
|
||||
return_value=WiFiStatus(connected=True, ssid="home"))
|
||||
manager._find_profile_for_ssid = MagicMock(return_value=None)
|
||||
manager.check_and_manage_ap_mode = MagicMock()
|
||||
sleeps = []
|
||||
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
|
||||
patch("src.wifi_manager.time.sleep", side_effect=sleeps.append):
|
||||
ok, _message = manager.disconnect_from_network()
|
||||
assert ok
|
||||
manager.check_and_manage_ap_mode.assert_not_called()
|
||||
assert sum(sleeps) == 2
|
||||
|
||||
|
||||
class TestLedStatusFile:
|
||||
def test_each_manager_writes_next_to_its_own_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wm, "LED_STATUS_FILE", None)
|
||||
first = _manager(tmp_path / "a" / "wifi_config.json")
|
||||
second = _manager(tmp_path / "b" / "wifi_config.json")
|
||||
second._show_led_message("hello", duration=3)
|
||||
assert json.loads((tmp_path / "b" / "wifi_status.json").read_text())["message"] == "hello"
|
||||
assert not (tmp_path / "a" / "wifi_status.json").exists()
|
||||
first._show_led_message("from a")
|
||||
assert (tmp_path / "a" / "wifi_status.json").exists()
|
||||
|
||||
def test_the_default_manager_writes_where_the_display_reads(self, tmp_path):
|
||||
manager = _manager(tmp_path / "config" / "wifi_config.json")
|
||||
assert manager._led_status_file == tmp_path / "config" / "wifi_status.json"
|
||||
# And for the default config, that is get_wifi_status_path().
|
||||
assert wm.get_wifi_config_path().parent / "wifi_status.json" == wm.get_wifi_status_path()
|
||||
|
||||
|
||||
class TestConfigPathFallback:
|
||||
def test_without_a_config_dir_the_project_root_is_used(self, monkeypatch):
|
||||
monkeypatch.delenv("LEDMATRIX_ROOT", raising=False)
|
||||
# A fresh checkout has no config/ yet; the fallback was a hardcoded
|
||||
# /home/ledpi/LEDMatrix, which is nobody's checkout on most installs.
|
||||
monkeypatch.setattr(pathlib.Path, "exists", lambda self: False)
|
||||
assert wm.get_wifi_config_path() == REPO / "config" / "wifi_config.json"
|
||||
|
||||
|
||||
def _load_daemon():
|
||||
"""Import scripts/utils/wifi_monitor_daemon.py without its /var/log
|
||||
FileHandler, which cannot be opened off the Pi."""
|
||||
path = REPO / "scripts" / "utils" / "wifi_monitor_daemon.py"
|
||||
spec = importlib.util.spec_from_file_location("wifi_monitor_daemon_under_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.object(logging, "FileHandler", lambda *a, **k: logging.NullHandler()):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestDaemonReloadsConfig:
|
||||
@pytest.fixture
|
||||
def daemon(self, tmp_path):
|
||||
module = _load_daemon()
|
||||
config_path = tmp_path / "wifi_config.json"
|
||||
config_path.write_text(json.dumps({"auto_enable_ap_mode": True}))
|
||||
|
||||
manager = SimpleNamespace(config_path=config_path,
|
||||
config={"auto_enable_ap_mode": True})
|
||||
manager._load_config = MagicMock(side_effect=lambda: manager.config.update(
|
||||
json.loads(config_path.read_text())))
|
||||
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
|
||||
daemon.wifi_manager = manager
|
||||
daemon._config_mtime = daemon._config_file_mtime()
|
||||
return daemon, manager, config_path
|
||||
|
||||
def _rewrite(self, config_path, data):
|
||||
before = config_path.stat().st_mtime_ns
|
||||
config_path.write_text(json.dumps(data))
|
||||
os.utime(config_path, ns=(before + 10**9, before + 10**9))
|
||||
|
||||
def test_a_changed_file_is_reread(self, daemon):
|
||||
daemon, manager, config_path = daemon
|
||||
self._rewrite(config_path, {"auto_enable_ap_mode": False})
|
||||
daemon._reload_config_if_changed()
|
||||
assert manager.config["auto_enable_ap_mode"] is False
|
||||
|
||||
def test_an_unchanged_file_is_not(self, daemon):
|
||||
daemon, manager, _ = daemon
|
||||
daemon._reload_config_if_changed()
|
||||
daemon._reload_config_if_changed()
|
||||
manager._load_config.assert_not_called()
|
||||
|
||||
def test_the_loop_rereads_before_each_check(self, tmp_path):
|
||||
module = _load_daemon()
|
||||
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
|
||||
daemon.check_interval = 0
|
||||
daemon.running = True
|
||||
daemon.last_state = None
|
||||
daemon._consecutive_internet_failures = 0
|
||||
daemon._nm_restart_threshold = 5
|
||||
order = []
|
||||
manager = MagicMock()
|
||||
manager.config = {}
|
||||
manager.get_wifi_status.return_value = WiFiStatus(connected=False)
|
||||
manager._is_ethernet_connected.return_value = False
|
||||
manager.check_and_manage_ap_mode_with_state.side_effect = lambda: (
|
||||
order.append("check"), (False, WiFiStatus(connected=False), False, False))[1]
|
||||
daemon.wifi_manager = manager
|
||||
daemon._reload_config_if_changed = lambda: order.append("reload")
|
||||
|
||||
def stop(_seconds):
|
||||
daemon.running = False
|
||||
with patch.object(module.time, "sleep", side_effect=stop):
|
||||
daemon.run()
|
||||
assert order == ["reload", "check"]
|
||||
|
||||
|
||||
class TestCaptivePortalSeesTheNmcliAp:
|
||||
@pytest.fixture
|
||||
def web_app(self, monkeypatch):
|
||||
import web_interface.app as web_app
|
||||
from web_interface.cache import TTLCache
|
||||
monkeypatch.setattr(web_app, "_service_status_cache", TTLCache())
|
||||
monkeypatch.setattr(web_app, "_SYSTEMCTL", "/bin/systemctl")
|
||||
monkeypatch.setattr(web_app, "_NMCLI", "/usr/bin/nmcli")
|
||||
return web_app
|
||||
|
||||
def _run(self, active_connections):
|
||||
calls = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
if "is-active" in argv:
|
||||
return _ok("inactive\n")
|
||||
return _ok(active_connections)
|
||||
return calls, run
|
||||
|
||||
def test_an_nmcli_ap_counts_as_ap_mode(self, web_app, monkeypatch):
|
||||
calls, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
|
||||
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||
assert web_app.is_ap_mode_active() is True
|
||||
# Cached: these endpoints are hit per request.
|
||||
web_app.is_ap_mode_active()
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_an_ordinary_wifi_connection_does_not(self, web_app, monkeypatch):
|
||||
_, run = self._run("home:802-11-wireless\nWired connection 1:802-3-ethernet\n")
|
||||
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||
assert web_app.is_ap_mode_active() is False
|
||||
|
||||
def test_the_detection_endpoints_redirect(self, web_app, monkeypatch):
|
||||
_, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
|
||||
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||
web_app.app.config["TESTING"] = True
|
||||
with web_app.app.test_client() as client:
|
||||
response = client.get("/generate_204")
|
||||
assert response.status_code == 302
|
||||
@@ -178,6 +178,25 @@ class TestSaveRawSecrets:
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||
|
||||
@pytest.mark.parametrize("error,code", [
|
||||
(ConfigError("cannot write", config_path="/etc/s.json"), "CONFIG_SAVE_FAILED"),
|
||||
(RuntimeError("nope"), "UNKNOWN_ERROR"),
|
||||
])
|
||||
def test_errors_answer_in_the_main_routes_shape(self, env, monkeypatch, error, code):
|
||||
# Both raw routes build their 500 with one helper now; this one used
|
||||
# to hand-roll a body without error_code or context. raw_json.html
|
||||
# reads only `message`, which both shapes carry.
|
||||
def boom(kind, data):
|
||||
raise error
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
secrets = env.client.post(SECRETS, json={"a": 1})
|
||||
main = env.client.post(MAIN, json={"a": 1})
|
||||
assert secrets.status_code == main.status_code == 500
|
||||
body = secrets.get_json()
|
||||
assert body["error_code"] == code
|
||||
assert body["message"] == main.get_json()["message"]
|
||||
assert set(body) == set(main.get_json())
|
||||
|
||||
|
||||
class TestRawEndpointsBypassSecretSeparation:
|
||||
"""Pinned behaviour, deliberately not "fixed".
|
||||
|
||||
@@ -122,8 +122,22 @@ def test_credentials_are_redacted_from_the_detail(client):
|
||||
assert body["details"].startswith("RuntimeError: forced failure")
|
||||
|
||||
|
||||
def test_a_client_error_keeps_its_own_status(client):
|
||||
def _raise_415():
|
||||
raise UnsupportedMediaType(
|
||||
"Did not attempt to load JSON data because the request Content-Type "
|
||||
"was not 'application/json'.")
|
||||
|
||||
|
||||
# An api_v3 route raising a 415 from inside. No route does that on its own
|
||||
# any more (the asset delete route, which used to, now reads its body with
|
||||
# get_json(silent=True)), so one route's view is swapped for one that does;
|
||||
# the endpoint stays api_v3's, so its error handler is the one that answers.
|
||||
_SWAPPED_ENDPOINT = "api_v3.delete_plugin_asset"
|
||||
|
||||
|
||||
def test_a_client_error_keeps_its_own_status(client, monkeypatch):
|
||||
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
||||
monkeypatch.setitem(client.application.view_functions, _SWAPPED_ENDPOINT, _raise_415)
|
||||
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
assert resp.status_code == 415
|
||||
@@ -151,8 +165,9 @@ class TestInTheRealApp:
|
||||
assert resp.get_json() == EXPECTED
|
||||
|
||||
def test_client_errors_read_the_same_as_the_global_handler(
|
||||
self, web_app, exploding_managers):
|
||||
self, web_app, exploding_managers, monkeypatch):
|
||||
"""The blueprint's 4xx shape must not drift from app.py's."""
|
||||
monkeypatch.setitem(web_app.app.view_functions, _SWAPPED_ENDPOINT, _raise_415)
|
||||
resp = web_app.app.test_client().post(
|
||||
"/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
|
||||
@@ -126,7 +126,6 @@ class TestUpdateRouteReportsNoOps:
|
||||
self._install(tmp_path, 'clock', '1.0.0')
|
||||
store._get_local_git_info.side_effect = [
|
||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
|
||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # is-git check
|
||||
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
|
||||
]
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Small web-backend fixes: BDF font preview, the SSE broadcaster restart
|
||||
window, start.py's werkzeug log filter, and the backup routes' errors."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestBdfFontPreview:
|
||||
"""The route refused every BDF font ("needs complex rendering") although
|
||||
src/common/bdf_font.py draws them for the panel."""
|
||||
|
||||
@pytest.fixture
|
||||
def fonts_root(self, tmp_path, api_v3_module, monkeypatch):
|
||||
import web_interface.blueprints.api_v3.fonts as fonts_module
|
||||
fonts_dir = tmp_path / "assets" / "fonts"
|
||||
fonts_dir.mkdir(parents=True)
|
||||
shutil.copy(REPO / "assets" / "fonts" / "6x10.bdf", fonts_dir / "6x10.bdf")
|
||||
monkeypatch.setattr(fonts_module, "PROJECT_ROOT", tmp_path)
|
||||
return fonts_dir
|
||||
|
||||
def _preview(self, client, **params):
|
||||
query = {"font": "6x10.bdf", "text": "Hi", "size": 10,
|
||||
"bg": "000000", "fg": "ffffff"}
|
||||
query.update(params)
|
||||
return client.get("/api/v3/fonts/preview", query_string=query)
|
||||
|
||||
def test_a_bdf_font_renders(self, api_v3_client, fonts_root):
|
||||
response = self._preview(api_v3_client)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
data = response.get_json()["data"]
|
||||
assert data["image"].startswith("data:image/png;base64,")
|
||||
image = Image.open(io.BytesIO(base64.b64decode(data["image"].split(",", 1)[1])))
|
||||
assert image.size == (data["width"], data["height"])
|
||||
colors = {c for _, c in image.convert("RGB").getcolors(10**6)}
|
||||
assert (255, 255, 255) in colors
|
||||
|
||||
def test_the_glyphs_are_the_panels(self, api_v3_client, fonts_root):
|
||||
# The same rasterizer as the panel: exactly the pixels draw_bdf_text
|
||||
# lights for this string, nothing anti-aliased.
|
||||
from PIL import ImageDraw
|
||||
from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||
data = self._preview(api_v3_client, text="A").get_json()["data"]
|
||||
preview = Image.open(io.BytesIO(base64.b64decode(data["image"].split(",", 1)[1]))).convert("RGB")
|
||||
|
||||
face, _ = load_bdf_face(str(fonts_root / "6x10.bdf"), 10)
|
||||
glyph = Image.new("RGB", (20, 20))
|
||||
draw_bdf_text(ImageDraw.Draw(glyph), "A", 0, 0, face, color=(255, 255, 255))
|
||||
lit = sum(1 for p in glyph.getdata() if p == (255, 255, 255))
|
||||
assert lit > 0
|
||||
assert sum(1 for p in preview.getdata() if p == (255, 255, 255)) == lit
|
||||
assert set(preview.getdata()) <= {(0, 0, 0), (255, 255, 255)}
|
||||
|
||||
def test_multi_line_text_is_taller(self, api_v3_client, fonts_root):
|
||||
one = self._preview(api_v3_client, text="Hi", size=10).get_json()["data"]
|
||||
# Tall enough to exceed the 30px minimum.
|
||||
three = self._preview(api_v3_client, text="a\nb\nc", size=10).get_json()["data"]
|
||||
assert three["height"] > one["height"]
|
||||
|
||||
|
||||
class TestStreamBroadcasterRestart:
|
||||
"""Between the broadcast thread's break and its exit, is_alive() was
|
||||
still True, so a client subscribing in that window got no thread."""
|
||||
|
||||
def test_a_subscriber_during_shutdown_gets_a_new_thread(self):
|
||||
import web_interface.app as web_app
|
||||
|
||||
closing = threading.Event()
|
||||
release = threading.Event()
|
||||
produced = threading.Event()
|
||||
started = []
|
||||
|
||||
def factory():
|
||||
started.append(threading.current_thread())
|
||||
|
||||
def gen():
|
||||
try:
|
||||
while True:
|
||||
produced.set()
|
||||
yield {"n": 1}
|
||||
finally:
|
||||
# Closing the generator after the break: hold the thread
|
||||
# alive here, which is the window in question.
|
||||
closing.set()
|
||||
release.wait(5)
|
||||
return gen()
|
||||
|
||||
broadcaster = web_app._StreamBroadcaster(factory)
|
||||
first = broadcaster.subscribe()
|
||||
assert produced.wait(5)
|
||||
broadcaster.unsubscribe(first)
|
||||
assert closing.wait(5), "the broadcast thread never noticed it had no clients"
|
||||
try:
|
||||
second = broadcaster.subscribe()
|
||||
assert second.get(timeout=5) == {"n": 1}
|
||||
assert len(started) == 2
|
||||
finally:
|
||||
release.set()
|
||||
broadcaster.unsubscribe(second)
|
||||
|
||||
|
||||
class TestStartLogFilterExcInfo:
|
||||
"""logging takes exc_info as a tuple, an exception, or True; the filter
|
||||
unpacked it as a 3-tuple, so the other two raised TypeError."""
|
||||
|
||||
def test_every_form_yields_the_exception(self):
|
||||
from web_interface.start import _exc_info_value
|
||||
err = BrokenPipeError(32, "Broken pipe")
|
||||
assert _exc_info_value(err) is err
|
||||
assert _exc_info_value((type(err), err, None)) is err
|
||||
try:
|
||||
raise err
|
||||
except BrokenPipeError:
|
||||
assert _exc_info_value(True) is err
|
||||
|
||||
def test_true_outside_an_except_is_none(self):
|
||||
from web_interface.start import _exc_info_value
|
||||
assert _exc_info_value(True) is None
|
||||
|
||||
|
||||
class TestBackupErrors:
|
||||
"""The backup routes' own catch-alls are gone; the blueprint handler
|
||||
answers with the fields backup_restore.html reads."""
|
||||
|
||||
def test_a_failed_export_is_a_500_with_a_message(self, api_v3_client):
|
||||
with patch("src.backup_manager.create_backup", side_effect=OSError(28, "No space left")):
|
||||
response = api_v3_client.post("/api/v3/backup/export")
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["status"] == "error"
|
||||
assert body["message"]
|
||||
Reference in New Issue
Block a user