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>
165 lines
7.7 KiB
Python
165 lines
7.7 KiB
Python
"""'Check & Update All' must only send ids POST /plugins/update handles.
|
|
|
|
Seen on a device running core 3.4.0: update-all posted every entry from
|
|
/plugins/installed, including the virtual `starlark:<app_id>` entries that
|
|
list installed Starlark apps, and the route answered each with a 500
|
|
"Plugin update failed: plugin not found". A web-service restart during the
|
|
same run also cost stock-news its update: its request was refused while the
|
|
service came back, and update-all never sent it again.
|
|
|
|
The id selection and retry live in install_manager.js and are covered by
|
|
test/js/unit/test_update_all.js, which this module runs so CI sees it. The
|
|
route contract is tested here directly.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
JS_SUITE = REPO / 'test' / 'js' / 'unit' / 'test_update_all.js'
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from web_interface.app import app
|
|
app.config['TESTING'] = True
|
|
with app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
"""A store manager that records calls; no git, no network."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
sm = MagicMock()
|
|
sm.plugins_dir = str(tmp_path)
|
|
sm._get_local_git_info.return_value = None
|
|
sm.get_plugin_info.return_value = None
|
|
sm.update_plugin.return_value = True
|
|
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
|
|
patch.object(api_v3, 'plugin_manager', None, create=True), \
|
|
patch.object(api_v3, 'schema_manager', None, create=True), \
|
|
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
|
|
patch.object(api_v3, 'operation_history', None, create=True):
|
|
yield sm
|
|
|
|
|
|
class TestUpdateRouteRejectsStarlarkIds:
|
|
|
|
@pytest.mark.parametrize('app_id', ['starlark:analogtime', 'starlark:analogclock'])
|
|
def test_a_starlark_id_is_a_400_not_a_500(self, client, store, app_id):
|
|
resp = client.post('/api/v3/plugins/update', json={'plugin_id': app_id})
|
|
assert resp.status_code == 400, resp.get_json()
|
|
body = resp.get_json()
|
|
assert body['status'] == 'error'
|
|
assert body['error_code'] == 'INVALID_INPUT'
|
|
assert 'Starlark app' in body['message'], body
|
|
assert 'not found' not in body['message'], \
|
|
"the app is installed; 'not found' is the misleading message this replaces"
|
|
|
|
def test_the_store_manager_is_never_asked(self, client, store):
|
|
client.post('/api/v3/plugins/update', json={'plugin_id': 'starlark:analogtime'})
|
|
store.update_plugin.assert_not_called()
|
|
|
|
def test_form_encoded_starlark_id_is_rejected_the_same_way(self, client, store):
|
|
resp = client.post('/api/v3/plugins/update', data={'plugin_id': 'starlark:analogtime'})
|
|
assert resp.status_code == 400
|
|
|
|
def test_a_plugin_id_still_reaches_the_updater(self, client, store, tmp_path):
|
|
# The guard is on the 'starlark:' prefix only: the starlark-apps plugin
|
|
# and a disabled plugin with an update are still updated.
|
|
for pid in ('stock-news', 'starlark-apps'):
|
|
(tmp_path / pid).mkdir()
|
|
(tmp_path / pid / 'manifest.json').write_text('{"id": "%s"}' % pid, encoding='utf-8')
|
|
resp = client.post('/api/v3/plugins/update', json={'plugin_id': pid})
|
|
assert resp.status_code == 200, (pid, resp.get_json())
|
|
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
|
|
|
|
|
|
class TestUpdateRouteReportsNoOps:
|
|
"""update_plugin() answers True when there was nothing to do.
|
|
|
|
A ZIP-installed monorepo plugin (no .git) already at the registry version
|
|
is the common case for official plugins. The route used to call that
|
|
"updated successfully", so Check & Update All counted it as updated.
|
|
"""
|
|
|
|
def _install(self, tmp_path, pid, version, last_updated='2026-09-01'):
|
|
(tmp_path / pid).mkdir()
|
|
(tmp_path / pid / 'manifest.json').write_text(
|
|
'{"id": "%s", "version": "%s", "last_updated": "%s"}' % (pid, version, last_updated),
|
|
encoding='utf-8')
|
|
|
|
def test_a_zip_plugin_already_at_the_registry_version_is_up_to_date(self, client, store, tmp_path):
|
|
self._install(tmp_path, 'stock-news', '2.8.0')
|
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
|
|
assert body['status'] == 'success'
|
|
assert body['data']['update_status'] == 'up_to_date'
|
|
assert 'already up to date' in body['message'], body
|
|
assert 'updated successfully' not in body['message']
|
|
|
|
def test_a_zip_plugin_reinstalled_at_a_new_version_is_updated(self, client, store, tmp_path):
|
|
self._install(tmp_path, 'stock-news', '2.6.2')
|
|
|
|
def reinstall(pid):
|
|
(tmp_path / pid / 'manifest.json').write_text(
|
|
'{"id": "%s", "version": "2.8.0", "last_updated": "2026-09-01"}' % pid,
|
|
encoding='utf-8')
|
|
return True
|
|
store.update_plugin.side_effect = reinstall
|
|
|
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
|
|
assert body['data']['update_status'] == 'updated'
|
|
assert '2.8.0' in body['message'], body
|
|
|
|
def test_a_git_plugin_whose_commit_is_unchanged_is_up_to_date(self, client, store, tmp_path):
|
|
self._install(tmp_path, 'clock', '1.0.0')
|
|
store._get_local_git_info.return_value = {'sha': 'abcdef1234567', 'branch': 'main'}
|
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
|
assert body['data']['update_status'] == 'up_to_date'
|
|
|
|
def test_a_git_plugin_that_moved_is_updated(self, client, store, tmp_path):
|
|
self._install(tmp_path, 'clock', '1.0.0')
|
|
store._get_local_git_info.side_effect = [
|
|
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
|
|
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
|
|
]
|
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
|
assert body['data']['update_status'] == 'updated', body
|
|
|
|
def test_a_local_only_plugin_says_so(self, client, store, tmp_path):
|
|
(tmp_path / 'mine').mkdir()
|
|
(tmp_path / 'mine' / 'manifest.json').write_text(
|
|
'{"id": "mine", "version": "0.1.0", "local_only": true}', encoding='utf-8')
|
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'mine'}).get_json()
|
|
assert body['data']['update_status'] == 'local_only'
|
|
store.update_plugin.assert_not_called()
|
|
|
|
|
|
class TestInstalledListContract:
|
|
"""What update-all filters on is what /plugins/installed publishes."""
|
|
|
|
def test_starlark_entries_are_flagged_and_prefixed(self, client):
|
|
apps = {'apps': {'analogtime': {'name': 'Analog Time', 'enabled': False}}}
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=apps):
|
|
resp = client.get('/api/v3/plugins/installed')
|
|
plugins = resp.get_json()['data']['plugins']
|
|
entry = next(p for p in plugins if p['id'] == 'starlark:analogtime')
|
|
assert entry['is_starlark_app'] is True
|
|
assert not any(p.get('is_starlark_app') for p in plugins
|
|
if not p['id'].startswith('starlark:')), \
|
|
"a real plugin carries the Starlark flag and would be dropped from update-all"
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
|
|
def test_update_all_js_selection_and_retry():
|
|
node = shutil.which('node')
|
|
result = subprocess.run([node, str(JS_SUITE)], capture_output=True, text=True,
|
|
timeout=120, cwd=str(JS_SUITE.parent))
|
|
assert result.returncode == 0, result.stdout + result.stderr
|