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:
Chuck
2026-09-28 10:42:07 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c00bf5e8e6
commit 6cfcf2e384
23 changed files with 1300 additions and 426 deletions
+76
View File
@@ -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
+138
View File
@@ -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
+7 -2
View File
@@ -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")
+200
View File
@@ -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()
+198
View File
@@ -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"]