Files
LEDMatrix/test/web_interface/test_plugin_config_json_saves.py
T
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:39:44 -04:00

298 lines
12 KiB
Python

"""JSON plugin-config saves store what the device would load, and only what was sent.
Runs a real ConfigManager and SchemaManager over tmp_path, like
test_api_v3_secret_roundtrip.py, so assertions are on config.json itself.
- POST /plugins/config with a partial ``config`` reset every unsent setting to
its schema default: the JSON path built on defaults, not the stored section.
- Legacy booleans (#588) were normalized only at load, so posting back what
GET /plugins/config returned failed validation.
- The JSON save's filter kept only enabled/display_duration/live_priority, so
a submitted vegas_* tuning key was silently dropped.
- ``skin`` and ``skin_options`` outlived the skin system in stored configs:
every save path must accept a section carrying them, and drop them.
- Plugin sections posted to /config/main skipped all of that and were stored
verbatim, including values /plugins/config rejects.
"""
import json
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.config_manager import ConfigManager
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints.api_v3 import api_v3
PLUGIN_ID = "demo"
SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": True},
"city": {"type": "string", "default": "Dallas"},
"update_interval": {"type": "integer", "default": 300, "minimum": 30},
"api_key": {"type": "string", "x-secret": True, "default": ""},
"display": {
"type": "object",
"additionalProperties": False,
"properties": {
"brightness": {"type": "integer", "default": 50},
"show_icons": {"type": "boolean", "default": True},
},
},
"global": {
"type": "object",
"additionalProperties": False,
"properties": {
"dynamic_duration": {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30},
},
},
},
},
},
}
STORED = {
"enabled": True,
"city": "Paris",
"update_interval": 600,
"display": {"brightness": 80, "show_icons": False},
"global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}},
"vegas_width_pct": 60,
# Written by a release that still had the skin system
"skin": "neon",
"skin_options": {"accent": "#00ff00"},
}
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@pytest.fixture
def env(tmp_path):
config_file = tmp_path / "config.json"
secrets_file = tmp_path / "config_secrets.json"
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}))
sentinel = object()
originals = {name: getattr(api_v3, name, sentinel) for name in _ATTRS}
config_manager = ConfigManager(config_path=str(config_file),
secrets_path=str(secrets_file))
config_manager.template_path = str(tmp_path / "no-template.json")
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
plugin_manager.plugins_dir = plugins_dir
for name in _ATTRS:
setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api_v3.plugin_catalog = plugin_manager
api_v3.operation_queue = None
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
client = app.test_client()
@staticmethod
def store(section):
config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: section}))
@staticmethod
def main():
return json.loads(config_file.read_text())
@staticmethod
def stored():
return json.loads(config_file.read_text())[PLUGIN_ID]
@staticmethod
def secrets():
return json.loads(secrets_file.read_text()) if secrets_file.exists() else {}
@staticmethod
def save(config):
return app.test_client().post("/api/v3/plugins/config",
json={"plugin_id": PLUGIN_ID, "config": config})
Env.store(json.loads(json.dumps(STORED)))
yield Env
for name, original in originals.items():
if original is sentinel:
if hasattr(api_v3, name):
delattr(api_v3, name)
else:
setattr(api_v3, name, original)
class TestPartialJsonSave:
def test_unsent_settings_keep_their_values(self, env):
resp = env.save({"enabled": True})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["city"] == "Paris"
assert stored["update_interval"] == 600
assert stored["display"] == {"brightness": 80, "show_icons": False}
assert stored["global"]["dynamic_duration"] == {"enabled": False, "min_duration_seconds": 45}
def test_a_nested_partial_keeps_its_siblings(self, env):
resp = env.save({"display": {"brightness": 20}})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["display"] == {"brightness": 20, "show_icons": False}
def test_a_sent_setting_still_changes(self, env):
assert env.save({"city": "Lyon", "enabled": False}).status_code == 200
stored = env.stored()
assert stored["city"] == "Lyon" and stored["enabled"] is False
def test_an_invalid_sent_value_is_still_rejected(self, env):
resp = env.save({"update_interval": 5})
assert resp.status_code == 400
assert env.stored()["update_interval"] == 600
def test_config_must_be_an_object(self, env):
assert env.save(["city"]).status_code == 400
class TestCoreOwnedKeysSurviveTheFilter:
def test_submitted_core_keys_are_stored(self, env):
env.store({"enabled": True, "city": "Paris"})
resp = env.save({
"vegas_width_pct": 50, "vegas_overflow": "truncate",
"vegas_max_width_screens": 2, "live_priority": True,
})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["vegas_width_pct"] == 50
assert stored["vegas_overflow"] == "truncate"
assert stored["vegas_max_width_screens"] == 2
assert stored["live_priority"] is True
def test_stored_core_keys_survive_an_unrelated_save(self, env):
assert env.save({"city": "Nice"}).status_code == 200
stored = env.stored()
assert stored["vegas_width_pct"] == 60
def test_a_non_core_unknown_key_is_still_filtered(self, env):
assert env.save({"not_in_schema": 1}).status_code == 200
assert "not_in_schema" not in env.stored()
class TestRetiredSkinKeys:
"""STORED carries skin/skin_options, and SCHEMA sets
additionalProperties: false without declaring them."""
@staticmethod
def _assert_dropped(stored):
assert "skin" not in stored and "skin_options" not in stored
assert stored["vegas_width_pct"] == 60 # still a core-owned key
def test_json_save_accepts_and_drops_them(self, env):
resp = env.save({"city": "Nice"})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["city"] == "Nice"
self._assert_dropped(env.stored())
def test_form_save_accepts_and_drops_them(self, env):
resp = env.client.post(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}",
data={"city": "Nice"})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["city"] == "Nice"
self._assert_dropped(env.stored())
def test_config_main_accepts_and_drops_them(self, env):
resp = env.client.post("/api/v3/config/main", json={PLUGIN_ID: {"city": "Nice"}})
assert resp.status_code == 200, resp.get_json()
self._assert_dropped(env.stored())
def test_submitted_ones_are_not_stored(self, env):
env.store({"enabled": True, "city": "Paris", "vegas_width_pct": 60})
resp = env.save({"skin": "retro", "skin_options": {"accent": "#ff0000"}})
assert resp.status_code == 200, resp.get_json()
self._assert_dropped(env.stored())
def test_get_config_leaves_them_out(self, env):
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
assert data["city"] == "Paris"
assert "skin" not in data and "skin_options" not in data
class TestLegacyBooleans:
LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}}
def test_get_returns_the_object_shape(self, env):
env.store(dict(self.LEGACY))
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
assert data["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_get_output_posts_back(self, env):
env.store(dict(self.LEGACY))
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
resp = env.save(data)
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_an_unrelated_json_save_upgrades_the_stored_boolean(self, env):
env.store(dict(self.LEGACY))
resp = env.save({"city": "Lyon"})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
def test_a_posted_legacy_boolean_is_read_as_the_object(self, env):
resp = env.save({"global": {"dynamic_duration": False}})
assert resp.status_code == 200, resp.get_json()
assert env.stored()["global"]["dynamic_duration"]["enabled"] is False
class TestPluginSectionsInConfigMain:
def _post(self, env, body):
return env.client.post("/api/v3/config/main", json=body)
def test_a_value_plugins_config_rejects_is_rejected_here_too(self, env):
resp = self._post(env, {PLUGIN_ID: {"update_interval": "abc"}})
assert resp.status_code == 400
assert env.stored()["update_interval"] == 600
assert env.save({"update_interval": "abc"}).status_code == 400
def test_nothing_is_saved_when_a_plugin_section_fails(self, env):
resp = self._post(env, {"timezone": "Europe/Paris", PLUGIN_ID: {"update_interval": 1}})
assert resp.status_code == 400
assert env.main()["timezone"] == "UTC"
assert env.stored()["update_interval"] == 600
def test_partial_section_merges_and_keeps_core_keys(self, env):
resp = self._post(env, {PLUGIN_ID: {"city": "Lyon", "vegas_overflow": "rotate"}})
assert resp.status_code == 200, resp.get_json()
stored = env.stored()
assert stored["city"] == "Lyon"
assert stored["display"] == {"brightness": 80, "show_icons": False}
assert stored["vegas_overflow"] == "rotate"
assert stored["vegas_width_pct"] == 60
def test_secrets_still_go_to_the_secrets_file(self, env):
resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}})
assert resp.status_code == 200, resp.get_json()
assert "api_key" not in env.stored() or env.stored()["api_key"] in ("", None)
assert env.secrets()[PLUGIN_ID]["api_key"] == "s3cret"
def test_a_non_object_section_is_rejected(self, env):
assert self._post(env, {PLUGIN_ID: "on"}).status_code == 400