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>
145 lines
5.7 KiB
Python
145 lines
5.7 KiB
Python
"""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"]
|