fix(web): plugin config page survives stored values the schema outgrew (#578)

Two stored shapes broke the config form:

* A scalar under a field that is now an object. News' dynamic_duration
  was a boolean and is becoming an object; render_nested_section did
  `key in true` and the whole page failed to render. Look into dicts
  only, and carry a legacy boolean over as the object's `enabled`, so
  the next save upgrades it without switching the feature off.
* A custom feed logo with a path but no id. The template always emitted
  an empty `logo.id` input, which the save route parsed to null, failing
  the id's string type on every save. Emit it only when there is an id,
  as custom-feeds.js already does.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 14:03:15 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 8e6d7c280f
commit 6d1cbfb70b
2 changed files with 200 additions and 3 deletions
@@ -0,0 +1,180 @@
"""Stored values the current schema no longer describes must not break the form.
Two cases from the news plugin:
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
install that has not saved since still holds ``true``; rendering the nested
section did ``key in true`` and the whole config page failed to load.
* A custom feed logo stored with a ``path`` but no ``id``. The template always
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
and the schema rejected (``Expected type string``) -- 400 on every save.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
},
"additionalProperties": False,
},
}, "additionalProperties": False},
"feeds": {"type": "object", "properties": {
"custom_feeds": {
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"logo": {"type": "object", "required": ["path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
},
"required": ["name", "url"],
"additionalProperties": False,
},
},
}, "additionalProperties": False},
},
"required": ["enabled"],
"additionalProperties": False,
}
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
STORED = {
"enabled": True,
"global": {"dynamic_duration": True},
"feeds": {"custom_feeds": [{"name": "Verge",
"url": "https://www.theverge.com/rss/index.xml",
"enabled": True,
"logo": {"path": LOGO_PATH}}]},
}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "input" or not a.get("name") or "disabled" in a:
return
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((a["name"], a.get("value", "")))
def _render():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_a_scalar_stored_under_an_object_field_still_renders():
html = _render()
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
def test_a_logo_without_an_id_posts_no_id_field():
parser = _FormFields()
parser.feed(_render())
names = [name for name, _ in parser.pairs]
assert "feeds.custom_feeds.0.logo.path" in names
assert "feeds.custom_feeds.0.logo.id" not in names
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
parser = _FormFields()
parser.feed(_render())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert isinstance(cfg["global"]["dynamic_duration"], dict)
# The legacy `true` was the on switch; upgrading must not turn it off.
assert cfg["global"]["dynamic_duration"]["enabled"] is True
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}
@@ -613,7 +613,11 @@
{% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{# Only when there is one, as custom-feeds.js does: a blank id
is saved as null and fails the id's string type. #}
{% if logo_value.get('id') %}
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id') }}">
{% endif %}
{% else %}
<span class="text-xs text-gray-400">No logo</span>
{% endif %}
@@ -1108,8 +1112,21 @@
{% set section_id = (plugin_id ~ '-section-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
{% set description = prop.description if prop.description else '' %}
{% set nested_value = value if value else {} %}
{# Only a dict can be looked into. A schema that turns a legacy scalar into
an object (news' dynamic_duration: true -> {enabled, ...}) still finds
the scalar in config.json until the next save, and `key in true` is a
TypeError that takes down the whole config page. A legacy boolean was
the on/off switch the object's `enabled` now holds, so carry it over --
rendering {} would draw that checkbox unchecked and the next save would
quietly turn the feature off. The save then writes the object. #}
{% if value is mapping %}
{% set nested_value = value %}
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
{% set nested_value = {'enabled': value} %}
{% else %}
{% set nested_value = {} %}
{% endif %}
<div class="nested-section border border-gray-300 rounded-lg mb-4">
<button type="button"
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"