Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 95bd8a1a67 fix(web): stop an unrelated config edit from erasing a plugin's secret
Saving any field on a plugin's config form destroyed that plugin's stored
credential. On a rig with a weather API key, changing the city silently
emptied the key, and the plugin stopped working at the next fetch with no
indication why.

The path had no guard at any step. The config partial masks secrets before
rendering (pages_v3.py:740), so the browser posts them back blank; _parse_value
deliberately preserves "" for optional string fields; separate_secrets routes
that "" into secrets_config, which is a truthy dict; deep_merge writes it over
the stored value; save_raw_file_content persists it.

The blank does not even need the round-trip. merge_with_defaults injects the
schema's api_key default ("") into every save, so a client that never sends
the field at all still erases it. test_secret_count_message_counts_top_level_keys
was counting exactly that injected blank as a saved secret field -- the visible
edge of the bug, pinned as expected behaviour.

remove_empty_secrets() already existed for this, with seven unit tests and a
docstring describing this precise scenario ("clients will send those empty
strings back ... so that existing stored secrets are not overwritten with
blanks"). It was never wired into a call site. This wires it into both save
paths that merge into the secrets file.

A blank now means "unchanged" rather than "delete", which is the same contract
the helper's tests already describe. The cost is that a secret can no longer be
cleared by emptying the field; clearing needs its own affordance, since a
control that erases credentials as a side effect of ordinary edits is not one.

Verified by reverting the guard: the new round-trip test then fails with the
stored key read back as ''. 262 web tests pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-20 05:53:51 -04:00
4 changed files with 55 additions and 141 deletions
-85
View File
@@ -1,85 +0,0 @@
"""A check that could not run must not be reported as "up to date".
check-update returned update_available=False whenever git failed. The banner
is the only route to the update button, so a checkout git refuses to touch
looked exactly like a current one -- permanently, and with nothing for the
user to act on. The usual cause is an install performed as root, after which
every git command fails with "detected dubious ownership".
"""
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import api_v3 as mod # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
DUBIOUS = ("fatal: detected dubious ownership in repository at "
"'/home/pi/LEDMatrix'\nTo add an exception for this directory, call:\n"
"\tgit config --global --add safe.directory /home/pi/LEDMatrix\n")
@pytest.fixture
def client():
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
mod._update_check_cache['result'] = None
mod._update_check_cache['ts'] = 0
return app.test_client()
def _fetch_fails(stderr: bytes):
def fake_run(args, **kwargs):
if args[:2] == ['git', 'fetch']:
return subprocess.CompletedProcess(args, 1, stdout=b'', stderr=stderr)
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
return fake_run
class TestFailedCheckIsNotSilence:
def test_dubious_ownership_is_reported_not_swallowed(self, client):
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
data = client.get('/api/v3/system/check-update').get_json()
assert data['check_failed'] is True, (
"a git failure was reported as a successful 'no update' check")
assert data['update_available'] is False
def test_the_message_tells_the_user_what_to_do(self, client):
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
data = client.get('/api/v3/system/check-update').get_json()
assert 'chown' in data['error'], (
"dubious ownership is unactionable without the fix command")
assert 'root' in data['error']
def test_an_ordinary_git_failure_still_surfaces(self, client):
with patch.object(mod.subprocess, 'run',
_fetch_fails(b'fatal: some other git problem\n')):
data = client.get('/api/v3/system/check-update').get_json()
assert data['check_failed'] is True
assert 'some other git problem' in data['error']
def test_offline_reads_as_offline(self, client):
with patch.object(mod.subprocess, 'run',
_fetch_fails(b'fatal: could not resolve host: github.com\n')):
data = client.get('/api/v3/system/check-update').get_json()
assert 'Could not reach GitHub' in data['error']
class TestSuccessPathUnchanged:
def test_up_to_date_carries_no_failure_flag(self, client):
def fake_run(args, **kwargs):
if args[:2] == ['git', 'fetch']:
return subprocess.CompletedProcess(args, 0, stdout=b'', stderr=b'')
if args[:2] == ['git', 'rev-parse']:
return subprocess.CompletedProcess(args, 0, stdout='abc123\n', stderr='')
return subprocess.CompletedProcess(args, 0, stdout='0\n', stderr='')
with patch.object(mod.subprocess, 'run', fake_run):
data = client.get('/api/v3/system/check-update').get_json()
assert data['update_available'] is False
assert not data.get('check_failed'), "a healthy check must not look like a failure"
@@ -194,15 +194,46 @@ class TestSavePluginConfig:
def test_secret_count_message_counts_top_level_keys(self, env):
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
# the separated secrets dict. Here that is 2: the posted accounts
# array (all its item tokens count as ONE key) plus the schema's
# api_key default ("") that merge_with_defaults adds before
# separation.
# the separated secrets dict. Here that is 1: the posted accounts
# array, whose item tokens all count as ONE key.
#
# It was 2 before blank secrets were dropped, the second being the
# schema's api_key default (""), which merge_with_defaults adds to
# every save. Counting it was the visible edge of a real bug: that
# injected blank was merged over the stored api_key, so saving any
# unrelated field destroyed the credential. See
# test_an_unrelated_edit_does_not_erase_a_stored_secret.
resp = self._save(env, {
"accounts": [{"name": "a", "token": "t"}],
})
message = resp.get_json()["message"]
assert "(2 secret field(s) saved to config_secrets.json)" in message
assert "(1 secret field(s) saved to config_secrets.json)" in message
def test_an_unrelated_edit_does_not_erase_a_stored_secret(self, env):
"""Editing one field must not wipe the plugin's API key.
The config form renders secrets masked, so the browser posts them
back blank; merge_with_defaults injects a blank api_key even when
the client omits it entirely. Either way a "" reached the secrets
file and deep_merge wrote it over the stored credential.
"""
assert self._save(env, {"api_key": "REAL-KEY-0123456789",
"city": "Austin"}).status_code == 200
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
"REAL-KEY-0123456789"
# the user changes the city; the masked api_key rides along blank
assert self._save(env, {"api_key": "", "city": "Dallas"}).status_code == 200
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
"REAL-KEY-0123456789", "an unrelated edit destroyed the API key"
assert env.fresh_load()[PLUGIN_ID]["city"] == "Dallas"
def test_a_secret_can_still_be_changed(self, env):
"""Dropping blanks must not stop a real new value from being saved."""
self._save(env, {"api_key": "first-key"})
self._save(env, {"api_key": "second-key"})
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == "second-key"
def test_resave_replaces_stored_secrets_list_wholesale(self, env):
# Characterized: api_v3's deep_merge intentionally replaces lists,
+17 -35
View File
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
# Import new infrastructure
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
from src.web_interface.secret_helpers import (find_secret_fields, remove_empty_secrets,
separate_secrets)
from src.web_interface.error_handler import describe_exception, redact_text
from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import (
@@ -1216,6 +1217,11 @@ def save_main_config():
# Separate secrets from regular config (same logic as save_plugin_config)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
@@ -1821,33 +1827,6 @@ def get_system_version():
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
def _update_check_failed(detail: str) -> Dict[str, Any]:
"""A check that could not run is not the same as being up to date.
Reporting update_available=False on a git failure hides the banner, and
the banner is the only route to the update button -- so a checkout git
refuses to touch looks exactly like a current one, permanently. The most
common cause is an install performed as root: git then reports "dubious
ownership" and every command fails, including the fetch here.
"""
return {'update_available': False, 'remote_sha': 'unknown',
'commits_behind': 0, 'check_failed': True, 'error': detail}
def _describe_git_failure(stderr: str) -> str:
"""Turn git's stderr into something the user can act on."""
text = (stderr or '').strip()
if 'dubious ownership' in text or 'detected dubious ownership' in text:
return ("This checkout is owned by a different user than the one "
"running the web interface, so git refuses to use it. It is "
"usually the result of installing as root. Fix the ownership "
"and the update will work: sudo chown -R $USER:$USER "
+ str(PROJECT_ROOT))
if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower():
return "Could not reach GitHub to check for updates."
return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed")
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
@@ -1863,13 +1842,12 @@ def check_for_update():
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode, stderr)
failed = _update_check_failed(_describe_git_failure(stderr))
_update_check_cache['result'] = failed
fetch_result.returncode,
fetch_result.stderr.decode(errors='replace').strip())
_update_check_cache['result'] = _safe
_update_check_cache['ts'] = now
return jsonify(failed)
return jsonify(_safe)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
@@ -1897,8 +1875,7 @@ def check_for_update():
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
return jsonify(_safe)
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
@@ -5628,6 +5605,11 @@ def save_plugin_config():
# Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# Get current configs
current_config = api_v3.config_manager.load_config()
+2 -16
View File
@@ -1107,29 +1107,15 @@
fetch('/api/v3/system/check-update')
.then(function(r) { return r.json(); })
.then(function(data) {
var banner = document.getElementById('update-banner');
var btn = document.getElementById('update-banner-btn');
if (data.check_failed) {
// A check that could not run is not the same as being up
// to date. Hiding the banner here made a checkout git
// refuses to touch look permanently current, with no
// route to the update button and nothing to act on.
document.getElementById('update-banner-text').textContent =
data.error || 'Could not check for updates.';
if (btn) btn.style.display = 'none';
banner.style.display = '';
return;
}
if (btn) btn.style.display = '';
if (data.update_available && getDismissedSha() !== data.remote_sha) {
var n = data.commits_behind || 0;
var msg = 'A new LEDMatrix update is available';
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
document.getElementById('update-banner-text').textContent = msg;
banner.style.display = '';
document.getElementById('update-banner').style.display = '';
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
} else {
banner.style.display = 'none';
document.getElementById('update-banner').style.display = 'none';
}
})
.catch(function() {});