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:
@@ -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