mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
fix(api-v3): address CodeRabbit findings from the blueprint-split review
Fixes to the api_v3 package split (PR #553), one per finding verified against the actual code: - __init__.py: _redact_credentials only blanked scalar values under a credential-named key; a bare list of secrets under such a key (e.g. tokens: ["a", "b"]) passed through untouched, since the list branch recursed with no memory that its key looked like a credential. Nested dicts still walk normally (a documented, tested behaviour -- a container like secrets: {api_key: ..., note: ...} is a section name, not a value to blank outright), but any value reached under a credential-shaped key is now actually blanked. - __init__.py: the OAuth helper script's raw stderr/stdout went to logger.error unredacted (CWE-532) right next to a comment claiming this was deliberate; the HTTP response already used the existing redact_text helper. Routed the log line through the same helper. - __init__.py / starlark.py: the standalone Starlark manifest fallback (used when the plugin instance isn't loaded) read-modified-wrote manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py), which already holds an flock for the same file when the plugin is loaded. Added _starlark_manifest_lock, mirroring that pattern, and wrapped every standalone read-modify-write call site in it. The app-config update route also wrote config.json and the manifest as two separate, non-transactional writes (a second, distinct finding at the same call site); config.json is now rolled back if the manifest write that follows it fails. - backup.py: restore options used bare bool() on values from the request, so {"restore_secrets": "false"} restored secrets anyway (bool("false") is True). Switched to the existing _coerce_to_bool helper already used for this exact purpose elsewhere in the package. - config.py: an automated import-rewrite mangled four user-facing validation strings and their neighbouring comments -- "Invalid start time" had become "Invalid start _pkg.time" (and likewise for "end time") in both the schedule and dim-schedule per-day validation paths. - display.py: `import _pkg.time as time_module` -- _pkg is a local alias for the package, not a real importable module, so this raised ModuleNotFoundError whenever a caller restarted an already-running display service via /display/on-demand/start, after the on-demand request was already written to cache. Fixed to `import time`. Audited the rest of the package for the same `_pkg.<module>` import mistake; every other `_pkg.` reference is a legitimate attribute read-through (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken import statement. - fonts.py: validate_file_upload's max_size_mb parameter is silently unused by that helper (it only checks filename/extension) -- the font upload route saved arbitrarily large files as a result. Added the same seek-and-check pattern already used for the sibling .star upload. - wifi.py: two ad hoc, inconsistent bool coercions. POST /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled it. POST /wifi/radio's enabled/force parsing recognized real bool and some strings but not int 1/0 (1 is True is False in Python). Factored one small _parse_bool_ish helper local to this file and used it at all three sites. Not changed: the "unknown/misspelled restore option keys default to True" half of the backup.py finding -- the file's own comment documents that a missing key deliberately means "restore everything," matching the already-existing JSON-parse-failure guard a few lines above it; only the bool-coercion defect was a real bug. Added or extended regression tests for every fix, following each area's existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed on both this branch and origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change) -- no new failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5
This commit is contained in:
@@ -395,6 +395,132 @@ class TestTheManifestStaysRelocatable:
|
||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||
|
||||
|
||||
class TestManifestLockPreventsLostUpdates:
|
||||
"""The standalone manifest fallback (no plugin instance loaded) reads,
|
||||
mutates and writes manifest.json with no coordination across requests.
|
||||
Each write is atomic on its own (temp file + rename), but two concurrent
|
||||
read-modify-write cycles can still race: both read the same starting
|
||||
manifest, and the second write silently discards whatever the first one
|
||||
added. _starlark_manifest_lock closes that window -- mirrors
|
||||
StarlarkAppsPlugin._update_manifest_safe, which already does this when
|
||||
the plugin instance is loaded.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
module._write_starlark_manifest({'apps': {}})
|
||||
return apps_dir
|
||||
|
||||
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
|
||||
import threading
|
||||
import time as _time
|
||||
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
def add_app(app_id):
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
# Widen the window between read and write. Without the lock
|
||||
# both threads read here before either writes, and whichever
|
||||
# writes second overwrites the other's addition; with the
|
||||
# lock, the second thread cannot even start its read until
|
||||
# the first has written and released.
|
||||
_time.sleep(0.05)
|
||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||
module._write_starlark_manifest(manifest)
|
||||
|
||||
threads = [threading.Thread(target=add_app, args=(app_id,))
|
||||
for app_id in ('a', 'b')]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
manifest = module._read_starlark_manifest()
|
||||
assert set(manifest['apps']) == {'a', 'b'}, (
|
||||
"a concurrent update was lost: %r" % (manifest,))
|
||||
|
||||
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
|
||||
"""Not reentrant within one thread -- just that using it twice in a
|
||||
row (the ordinary case: one request, then the next) works cleanly
|
||||
and does not leak the lock file descriptor or leave it locked."""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
for app_id in ('first', 'second'):
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||
module._write_starlark_manifest(manifest)
|
||||
|
||||
manifest = module._read_starlark_manifest()
|
||||
assert set(manifest['apps']) == {'first', 'second'}
|
||||
|
||||
|
||||
class TestConfigAndManifestStayInSync:
|
||||
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
|
||||
loaded) writes config.json and then the manifest. If the manifest write
|
||||
fails after config.json was already written, the two disagree about
|
||||
what was saved unless config.json is rolled back.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
one_app_dir = apps_dir / 'demo'
|
||||
one_app_dir.mkdir()
|
||||
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||
return one_app_dir
|
||||
|
||||
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
|
||||
config_file = app_dir / 'config.json'
|
||||
config_file.write_text(json.dumps({'existing': 'value'}))
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
|
||||
"config.json kept the new value even though the manifest write "
|
||||
"that was supposed to follow it failed")
|
||||
|
||||
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
|
||||
config_file = app_dir / 'config.json'
|
||||
assert not config_file.exists()
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert not config_file.exists(), (
|
||||
"config.json was left behind even though the manifest write "
|
||||
"that was supposed to follow it failed")
|
||||
|
||||
def test_success_updates_both_config_and_manifest(self, client, app_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
|
||||
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
|
||||
assert manifest['apps']['demo']['config']['new_field'] == 'x'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The store loaded, then stopped loading, and nothing anywhere said why.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user