mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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>
237 lines
9.3 KiB
Python
237 lines
9.3 KiB
Python
"""An exception no api_v3 route catches is answered once, by the blueprint.
|
|
|
|
Fifty-three routes used to end in a copy of the same catch-all:
|
|
|
|
except Exception as e:
|
|
logger.error(..., exc_info=True)
|
|
return jsonify({'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
They were removed in favour of one errorhandler on the api_v3 blueprint. These
|
|
tests pin that the answer did not change: the same status, exactly the same
|
|
keys and values, credentials still redacted, and the traceback still logged.
|
|
|
|
The exact-equality matters. web_interface/app.py's global handler answers with
|
|
an extra `error_code: UNKNOWN_ERROR`, and the plugin API client routes a body
|
|
that carries an error_code to a different UI path (api_client.js) -- so
|
|
"falls through to the global handler" would not have been the same answer.
|
|
"""
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from werkzeug.exceptions import UnsupportedMediaType
|
|
|
|
from src.web_interface.error_handler import describe_exception
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
# A credential in three of the forms describe_exception redacts.
|
|
FORCED = RuntimeError(
|
|
"forced failure token=SECRET123 at https://u:pw1@example.com/x?api_key=K1")
|
|
|
|
# What every removed catch-all returned, written out rather than imported so
|
|
# a change to the shared payload cannot also change the expectation.
|
|
EXPECTED = {
|
|
'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(FORCED),
|
|
}
|
|
|
|
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
|
|
"saved_repositories_manager", "schema_manager", "operation_queue",
|
|
"plugin_state_manager", "operation_history", "cache_manager")
|
|
|
|
|
|
class Boom:
|
|
"""A manager that fails on any use -- attribute, truthiness, call."""
|
|
|
|
def _raise(self, *args, **kwargs):
|
|
raise FORCED
|
|
|
|
__getattr__ = _raise
|
|
__bool__ = _raise
|
|
__call__ = _raise
|
|
__iter__ = _raise
|
|
__len__ = _raise
|
|
|
|
|
|
@pytest.fixture
|
|
def exploding_managers(monkeypatch):
|
|
for name in MANAGERS:
|
|
monkeypatch.setattr(api_v3, name, Boom(), raising=False)
|
|
# The WiFi routes build their own manager rather than using one above.
|
|
import src.wifi_manager
|
|
monkeypatch.setattr(src.wifi_manager, "WiFiManager", Boom())
|
|
|
|
|
|
@pytest.fixture
|
|
def client(exploding_managers):
|
|
"""The blueprint alone, on an app with no error handlers of its own."""
|
|
app = Flask(__name__)
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
return app.test_client()
|
|
|
|
|
|
# A sample of routes whose catch-all was removed, across every module that
|
|
# lost one. Each reaches a manager (or WiFiManager) inside what used to be
|
|
# the try block.
|
|
REMOVED_CATCH_ALLS = [
|
|
("GET", "/api/v3/config/main", None),
|
|
("GET", "/api/v3/config/secrets", None),
|
|
("GET", "/api/v3/display/modes", None),
|
|
("POST", "/api/v3/display/on-demand/stop", {}),
|
|
("GET", "/api/v3/cache/list", None),
|
|
("GET", "/api/v3/plugins/installed", None),
|
|
("GET", "/api/v3/plugins/health", None),
|
|
("GET", "/api/v3/plugins/metrics/some-plugin", None),
|
|
("GET", "/api/v3/plugins/schema?plugin_id=some-plugin", None),
|
|
("GET", "/api/v3/plugins/store/list", None),
|
|
("GET", "/api/v3/plugins/saved-repositories", None),
|
|
("POST", "/api/v3/plugins/install", {"plugin_id": "some-plugin"}),
|
|
("POST", "/api/v3/plugins/config/reset?plugin_id=some-plugin", {}),
|
|
("GET", "/api/v3/plugins/limits/some-plugin", None),
|
|
("GET", "/api/v3/wifi/status", None),
|
|
("POST", "/api/v3/wifi/disconnect", {}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("method,url,body", REMOVED_CATCH_ALLS,
|
|
ids=[f"{m} {u}" for m, u, _ in REMOVED_CATCH_ALLS])
|
|
def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url, body):
|
|
with caplog.at_level(logging.ERROR, logger="web_interface.blueprints.api_v3"):
|
|
resp = client.open(url, method=method, json=body)
|
|
|
|
assert resp.status_code == 500
|
|
assert resp.get_json() == EXPECTED
|
|
|
|
# The promise in the message: the traceback is in the log.
|
|
records = [r for r in caplog.records
|
|
if r.name == "web_interface.blueprints.api_v3"
|
|
and r.levelno >= logging.ERROR and r.exc_info]
|
|
assert records, "the unhandled exception was not logged with its traceback"
|
|
assert records[-1].exc_info[1] is FORCED
|
|
|
|
|
|
def test_credentials_are_redacted_from_the_detail(client):
|
|
body = client.get("/api/v3/plugins/installed").get_json()
|
|
for secret in ("SECRET123", "pw1", "K1"):
|
|
assert secret not in body["details"]
|
|
assert "<redacted>" in body["details"]
|
|
assert body["details"].startswith("RuntimeError: forced failure")
|
|
|
|
|
|
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
|
|
body = resp.get_json()
|
|
assert body == {
|
|
'status': 'error',
|
|
'error_code': 'UNSUPPORTED_MEDIA_TYPE',
|
|
'message': body['message'],
|
|
}
|
|
assert "Content-Type" in body['message']
|
|
|
|
|
|
class TestInTheRealApp:
|
|
"""Mounted in web_interface/app.py, beside its global handlers."""
|
|
|
|
@pytest.fixture
|
|
def web_app(self):
|
|
import web_interface.app as web_app
|
|
return web_app
|
|
|
|
def test_the_blueprint_handler_answers_not_the_global_one(
|
|
self, web_app, exploding_managers):
|
|
resp = web_app.app.test_client().get("/api/v3/plugins/installed")
|
|
assert resp.status_code == 500
|
|
assert resp.get_json() == EXPECTED
|
|
|
|
def test_client_errors_read_the_same_as_the_global_handler(
|
|
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")
|
|
with web_app.app.test_request_context():
|
|
global_resp, global_status = web_app.handle_exception(
|
|
UnsupportedMediaType(description=resp.get_json()['message']))
|
|
assert resp.status_code == global_status == 415
|
|
assert resp.get_json() == global_resp.get_json()
|
|
|
|
def test_global_handler_shape(self, web_app):
|
|
"""Everything outside api_v3 still gets app.py's answer."""
|
|
with web_app.app.test_request_context("/somewhere"):
|
|
resp, status = web_app.handle_exception(FORCED)
|
|
body = resp.get_json()
|
|
assert status == 500
|
|
assert body == {
|
|
'status': 'error',
|
|
'error_code': 'UNKNOWN_ERROR',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(FORCED),
|
|
}
|
|
assert "SECRET123" not in body["details"]
|
|
|
|
|
|
class TestPluginActionStep1:
|
|
"""execute_plugin_action's OAuth step-1 handler reports the script's error.
|
|
|
|
The route bound a local `logger` in its JSON-parsing arm, which made
|
|
`logger` local to the whole function; every other `logger.error` in it
|
|
then raised UnboundLocalError. The step-1 handler therefore answered
|
|
"UnboundLocalError: cannot access local variable 'logger'" instead of
|
|
whatever the plugin's auth script actually raised.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def plugin_dir(self, tmp_path):
|
|
import json
|
|
d = tmp_path / "demo-plugin"
|
|
d.mkdir()
|
|
(d / "manifest.json").write_text(json.dumps({
|
|
"id": "demo-plugin",
|
|
"web_ui_actions": [{"id": "auth", "type": "script",
|
|
"script": "auth.py", "oauth_flow": True}],
|
|
}), encoding="utf-8")
|
|
(d / "auth.py").write_text(
|
|
"def get_auth_url():\n"
|
|
" raise RuntimeError('the auth script failed')\n",
|
|
encoding="utf-8")
|
|
return d
|
|
|
|
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
manager = MagicMock()
|
|
manager.get_plugin_directory.return_value = str(plugin_dir)
|
|
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
|
|
app = Flask(__name__)
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
|
|
resp = app.test_client().post(
|
|
"/api/v3/plugins/action",
|
|
json={"plugin_id": "demo-plugin", "action_id": "auth"})
|
|
|
|
assert resp.status_code == 500
|
|
body = resp.get_json()
|
|
assert body["details"] == "RuntimeError: the auth script failed"
|
|
assert body["message"] == 'An error occurred; see logs for details'
|