mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
v3.4.0 shows "Plugin Config Warning - In config but not installed: auto_update. Reinstall via the Plugin Store, or remove these entries from config.json." auto_update is the core weekly-update setting from #581. Reconciliation treated every top-level dict not in its private _SYSTEM_CONFIG_KEYS list as a plugin id, and #581 could not know to extend that list. - Move core top-level keys into src/core_config_keys.py (CORE_CONFIG_KEYS) and use it in reconciliation. Tests fail if a config.template.json key or a key written by the general-settings save is missing from it. - A secrets-file key only counts as a non-plugin when no installed plugin has that id. Plugin secrets are namespaced by id, so installed plugins with secrets were reported as missing from config on every run. - still_unresolved() drops "not on disk" findings whose id is no longer a plugin entry in config, so a stored verdict clears without a restart. - A plugin whose id is a core key is skipped with a warning, and the fix never writes a plugin stub over or in place of a core setting. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""Core settings in config.json must never be reported as orphaned plugins.
|
|
|
|
Observed on a v3.4.0 device: the web UI showed "Plugin Config Warning -- In
|
|
config but not installed: auto_update. Reinstall via the Plugin Store, or
|
|
remove these entries from config.json." ``auto_update`` is the top-level core
|
|
section #581 added for weekly automatic updates. Reconciliation kept its own
|
|
private list of non-plugin keys, which #581 did not know to extend, so any
|
|
top-level dict it did not list was treated as a plugin id. Following the
|
|
warning's advice deletes a real core setting.
|
|
|
|
These tests pin the shared list in src/core_config_keys.py to the sources that
|
|
actually define top-level keys, so the next new core setting cannot regress it.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO))
|
|
|
|
from src.core_config_keys import CORE_CONFIG_KEYS # noqa: E402
|
|
from src.plugin_system.state_reconciliation import ( # noqa: E402
|
|
InconsistencyType,
|
|
StateReconciliation,
|
|
)
|
|
|
|
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK
|
|
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
|
|
|
|
TEMPLATE = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
|
|
BUNDLED_PLUGINS = {p.parent.name for p in (REPO / "plugin-repos").glob("*/manifest.json")}
|
|
|
|
# Top-level keys of the hdpi device's config.json (v3.4.0 fddb0e06), keys only.
|
|
DEVICE_CONFIG_KEYS = [
|
|
'web_display_autostart', 'schedule', 'dim_schedule', 'timezone', 'target_fps',
|
|
'location', 'display', 'sync', 'plugin_system', 'web-ui-info', 'starlark-apps',
|
|
'football-scoreboard', 'hockey-scoreboard', 'lacrosse-scoreboard', 'afl-scoreboard',
|
|
'nrl-scoreboard', 'soccer-scoreboard', 'baseball-scoreboard', 'ledmatrix-flights',
|
|
'birdnet-go', 'geochron', 'incoming-packages', 'jellyfin-now-playing', 'tide-display',
|
|
'ledmatrix-leaderboard', 'ledmatrix-music', 'odds-ticker', 'stock-news',
|
|
'basketball-scoreboard', 'ledmatrix-stocks', 'ledmatrix-weather', 'news',
|
|
'clock-simple', 'christmas-countdown', '7-segment-clock', 'calendar', 'on-air',
|
|
'static-image', 'ledmatrix-dresden-departures', 'olympics', 'ledmatrix-elections',
|
|
'f1-scoreboard', 'hello-world', 'tidbyt-baseball-scoreboard', 'youtube-stats',
|
|
'mqtt-notifications', 'march-madness', 'pga-tour-leaderboard', 'nfl-draft',
|
|
'pomodoro-timer', 'countdown', 'gif-player', 'of-the-day', 'text-display',
|
|
'masters-tournament', 'cricket-scoreboard', 'ufc-scoreboard', 'auto_update',
|
|
]
|
|
DEVICE_SECRETS_KEYS = ['github', 'incoming-packages', 'jellyfin-now-playing']
|
|
DEVICE_INSTALLED = [k for k in DEVICE_CONFIG_KEYS if k not in CORE_CONFIG_KEYS] + [
|
|
'ledmatrix-flights.standalone-backup-migrating']
|
|
|
|
|
|
def _device_config():
|
|
"""Device-shaped config: real key set, placeholder values."""
|
|
cfg = {}
|
|
for key in DEVICE_CONFIG_KEYS:
|
|
template_value = TEMPLATE.get(key)
|
|
cfg[key] = template_value if template_value is not None else {"enabled": True}
|
|
return cfg
|
|
|
|
|
|
class _ConfigManager:
|
|
def __init__(self, config, secrets_path=None):
|
|
self._config = config
|
|
self._secrets_path = secrets_path
|
|
self.saved = []
|
|
|
|
def load_config(self):
|
|
return dict(self._config)
|
|
|
|
def save_config(self, config):
|
|
self.saved.append(dict(config))
|
|
self._config = dict(config)
|
|
|
|
def get_secrets_path(self):
|
|
return self._secrets_path
|
|
|
|
|
|
def _install(plugins_dir, plugin_id):
|
|
d = plugins_dir / plugin_id
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": "1.0.0"}),
|
|
encoding="utf-8")
|
|
|
|
|
|
def _reconcile(tmp_path, config, installed=(), secrets=None):
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugins_dir.mkdir(exist_ok=True)
|
|
for pid in installed:
|
|
_install(plugins_dir, pid)
|
|
secrets_path = tmp_path / "config_secrets.json"
|
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
|
state_manager = Mock()
|
|
state_manager.get_all_states.return_value = {}
|
|
plugin_manager = Mock()
|
|
plugin_manager.plugin_manifests = {}
|
|
reconciler = StateReconciliation(
|
|
state_manager=state_manager,
|
|
config_manager=_ConfigManager(config, str(secrets_path)),
|
|
plugin_manager=plugin_manager,
|
|
plugins_dir=plugins_dir,
|
|
)
|
|
return reconciler, reconciler.reconcile_state()
|
|
|
|
|
|
def _ids(result, kind):
|
|
return sorted(i.plugin_id for i in result.inconsistencies_found
|
|
if i.inconsistency_type == kind)
|
|
|
|
|
|
class TestTheListCoversEveryCoreKey:
|
|
def test_every_template_top_level_key_is_core_or_a_bundled_plugin(self):
|
|
unaccounted = [k for k in TEMPLATE
|
|
if k not in CORE_CONFIG_KEYS and k not in BUNDLED_PLUGINS]
|
|
assert unaccounted == [], (
|
|
"config.template.json has top-level keys that are neither in "
|
|
"src/core_config_keys.py nor a plugin under plugin-repos/; "
|
|
"reconciliation will report them as plugins missing from disk")
|
|
|
|
def test_the_template_keys_this_bug_was_about_are_covered(self):
|
|
for key in ("auto_update", "sync", "schedule", "dim_schedule", "location",
|
|
"display", "plugin_system", "timezone", "target_fps",
|
|
"web_display_autostart"):
|
|
assert key in CORE_CONFIG_KEYS, key
|
|
|
|
def test_keys_written_by_the_general_settings_save_are_core(self):
|
|
"""A new setting can be added to the save endpoint without touching
|
|
the template; catch that too."""
|
|
source = (REPO / "web_interface" / "blueprints" / "api_v3" / "config.py").read_text(
|
|
encoding="utf-8")
|
|
written = set(re.findall(r"current_config\[['\"]([A-Za-z0-9_-]+)['\"]\]\s*=", source))
|
|
assert "auto_update" in written, "pattern no longer finds the writes; update the test"
|
|
assert written - CORE_CONFIG_KEYS == set()
|
|
|
|
def test_no_core_key_is_also_a_bundled_plugin(self):
|
|
assert CORE_CONFIG_KEYS & BUNDLED_PLUGINS == set()
|
|
|
|
|
|
class TestReconciliationIgnoresCoreKeys:
|
|
def test_auto_update_is_not_reported(self, tmp_path):
|
|
_, result = _reconcile(tmp_path, {"auto_update": {"enabled": False}})
|
|
assert "auto_update" not in [i.plugin_id for i in result.inconsistencies_found]
|
|
|
|
def test_the_whole_template_yields_only_its_bundled_plugins(self, tmp_path):
|
|
template_plugins = {k for k in TEMPLATE if k in BUNDLED_PLUGINS}
|
|
reconciler, result = _reconcile(tmp_path, dict(TEMPLATE), installed=template_plugins)
|
|
assert result.inconsistencies_found == []
|
|
assert set(reconciler._get_config_state()) == template_plugins
|
|
|
|
@pytest.mark.parametrize("key", sorted(CORE_CONFIG_KEYS))
|
|
def test_no_core_key_is_ever_a_plugin_missing_from_disk(self, tmp_path, key):
|
|
_, result = _reconcile(tmp_path, {key: {"enabled": True}})
|
|
assert _ids(result, ON_DISK) == []
|
|
|
|
def test_a_genuinely_orphaned_plugin_is_still_reported(self, tmp_path):
|
|
config = {"auto_update": {"enabled": False}, "display": {"hardware": {}},
|
|
"ghost-plugin": {"enabled": True}, "odds-ticker": {"enabled": True}}
|
|
_, result = _reconcile(tmp_path, config, installed=["odds-ticker"])
|
|
assert _ids(result, ON_DISK) == ["ghost-plugin"]
|
|
assert [i.plugin_id for i in result.inconsistencies_manual] == ["ghost-plugin"]
|
|
|
|
def test_the_device_config_reports_nothing(self, tmp_path):
|
|
_, result = _reconcile(
|
|
tmp_path, _device_config(), installed=DEVICE_INSTALLED,
|
|
secrets={k: {"api_key": "x"} for k in DEVICE_SECRETS_KEYS})
|
|
assert result.inconsistencies_found == []
|
|
assert result.reconciliation_successful
|
|
|
|
|
|
class TestPluginSecretsDoNotHideThePlugin:
|
|
"""Plugin secrets are namespaced by plugin id in config_secrets.json. The
|
|
device had 'incoming-packages' and 'jellyfin-now-playing' there; ignoring
|
|
every secrets key reported both installed plugins as missing from config on
|
|
every run (auto-"fixed" as a no-op, so it never reached the banner)."""
|
|
|
|
def test_installed_plugin_with_secrets_is_a_plugin(self, tmp_path):
|
|
config = {"incoming-packages": {"enabled": True, "api_key": "x"}}
|
|
reconciler, result = _reconcile(tmp_path, config, installed=["incoming-packages"],
|
|
secrets={"incoming-packages": {"api_key": "x"}})
|
|
assert result.inconsistencies_found == []
|
|
assert "incoming-packages" in reconciler._get_config_state()
|
|
|
|
def test_a_secrets_only_key_is_still_not_a_plugin(self, tmp_path):
|
|
# The #557 phantom: a non-plugin 'data' key in the secrets file.
|
|
_, result = _reconcile(tmp_path, {"data": {"mode": "nfl_recent"}},
|
|
secrets={"data": {"mode": "nfl_recent"}})
|
|
assert result.inconsistencies_found == []
|
|
|
|
|
|
class TestAPluginNamedLikeACoreKey:
|
|
"""A plugin whose id collides with a core key has no config section of its
|
|
own. Reconciliation must neither nag about it nor write a stub over (or
|
|
in place of) the core setting."""
|
|
|
|
def test_installed_plugin_named_like_a_core_key_is_not_reported(self, tmp_path):
|
|
config = {"sync": {"role": "standalone"}}
|
|
reconciler, result = _reconcile(tmp_path, config, installed=["sync"])
|
|
assert result.inconsistencies_found == []
|
|
assert reconciler.config_manager.saved == []
|
|
assert reconciler.config_manager.load_config() == config
|
|
|
|
def test_core_key_absent_from_config_is_not_stubbed(self, tmp_path):
|
|
reconciler, result = _reconcile(tmp_path, {}, installed=["display"])
|
|
assert result.inconsistencies_found == []
|
|
assert reconciler.config_manager.saved == []
|
|
|
|
def test_fix_refuses_to_write_a_core_key(self, tmp_path):
|
|
reconciler, _ = _reconcile(tmp_path, {})
|
|
inc = Mock(inconsistency_type=IN_CONFIG, plugin_id="auto_update")
|
|
assert reconciler._fix_inconsistency(inc) is False
|
|
assert reconciler.config_manager.saved == []
|
|
|
|
|
|
class TestTheStatusEndpoint:
|
|
@pytest.fixture
|
|
def get_status(self, tmp_path, monkeypatch):
|
|
import tempfile
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
def _get(verdict, config, installed=(), secrets=None):
|
|
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
|
json.dumps(verdict), encoding="utf-8")
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugins_dir.mkdir(exist_ok=True)
|
|
for pid in installed:
|
|
_install(plugins_dir, pid)
|
|
secrets_path = tmp_path / "config_secrets.json"
|
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
|
cm = MagicMock()
|
|
cm.load_config.return_value = config
|
|
cm.get_secrets_path.return_value = str(secrets_path)
|
|
pm = MagicMock()
|
|
pm.plugins_dir = str(plugins_dir)
|
|
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
|
|
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
resp = app.test_client().get("/api/v3/plugins/reconciliation-status")
|
|
return resp.get_json()["data"]
|
|
|
|
return _get
|
|
|
|
def test_the_verdict_the_device_served_now_clears(self, get_status):
|
|
"""The exact stored verdict from the device, written by the old build,
|
|
is dropped against the device's config -- no restart needed."""
|
|
verdict = {"done": True, "fixed_count": 3, "successful": False, "unresolved": [
|
|
{"description": "Plugin auto_update in config but not on disk",
|
|
"plugin_id": "auto_update", "type": ON_DISK.value}]}
|
|
data = get_status(verdict, _device_config(), installed=DEVICE_INSTALLED,
|
|
secrets={k: {} for k in DEVICE_SECRETS_KEYS})
|
|
assert data["unresolved"] == []
|
|
|
|
def test_a_real_orphan_still_reaches_the_banner(self, get_status):
|
|
verdict = {"done": True, "unresolved": [
|
|
{"plugin_id": "auto_update", "type": ON_DISK.value},
|
|
{"plugin_id": "ghost-plugin", "type": ON_DISK.value}]}
|
|
config = _device_config()
|
|
config["ghost-plugin"] = {"enabled": True}
|
|
data = get_status(verdict, config, installed=DEVICE_INSTALLED)
|
|
assert [e["plugin_id"] for e in data["unresolved"]] == ["ghost-plugin"]
|