mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(config): load_config hands each caller a private copy ConfigManager.load_config() returned its cached self.config itself (the mtime fast path from #410 kept the full path's aliasing). Web handlers edit what they load and then validate: the plugin form save applies the posted fields to the loaded section (a shallow .copy(), so nested dicts were the cache's own), and save_main_config sets its checkboxes before it checks auto_update_channel. When the save was refused, the edit stayed in the cache the fast path serves, and the next save of any other setting wrote it to config.json: the refused value, and a nested secret typed into the same form (mqtt.password, league.espn_s2, flightaware.api_key) in plain text, since it never reached config_secrets.json to be stripped. The form also reloaded showing the refused values. load_config() now returns a private copy on both paths, and save_config/save_config_atomic keep a copy of what they were given, so nothing a caller edits reaches the cache unless it is saved. Fixing it here rather than in each handler covers every route that edits before it validates. No caller relies on editing the cache without saving: every src/ and web_interface/ caller either reads, or saves the dict it edited. get_config() still returns the live dict for the display process's readers. The copy is a pickle round trip: on a Pi 4 with its real 64 KiB config, 2.0 ms against 6.9 ms for copy.deepcopy (json round trip 3.4 ms). Two tests asserted the aliasing itself and now assert a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): GET /plugins/config masks secrets and refuses core sections The route returned the plugin's section as load_config() has it, with config_secrets.json merged in: API keys and tokens went out in plain text. #276 masked them here; #330's rewrite of the route dropped it, while the settings page and GET /config/secrets kept masking. It also took any plugin_id, so ?plugin_id=web_auth returned the login's cookie-signing key and password hash, and ?plugin_id=github the Plugin Store token, which GET /config/main strips and redacts. The route now refuses what _non_plugin_id_error refuses for reset and uninstall (core sections, malformed ids) with a 400, and blanks x-secret fields with mask_secret_fields after the defaults merge, as the page does. A plugin with no schema has its credential-named fields blanked by _redact_credentials, as GET /config/main does. Blank rather than the bullets of GET /config/secrets: the save drops a blank secret as "unchanged" (remove_empty_secrets) but would store the bullets, so the response must post back as it came. Tested: GET, then POST the response unchanged, keeps every stored secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): parse a table row's cells against the list's item schema An array of objects drawn as a table posts each cell as "cities.0.timezone". _get_schema_property stopped at "cities" (an array, not an object with properties), so _parse_form_value_with_schema got no schema for the cell and guessed: a blank optional text cell became None and a text cell holding digits became an int. Validation refused both, so every save of the page failed for as long as such a row existed -- geochron's city without a timezone, a countdown named "2027". A secret cell is always drawn blank, so a plugin with secrets in its rows could not be saved from the form at all. The lookup now steps from an index segment into the array's items: to the item schema itself for "color.2", into its properties for a row cell. Number, boolean and required cells convert as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a blank secret field saves as "unchanged", required or not The settings page draws a stored secret blank (mask_secret_fields) and posts the blank back. _parse_form_value_with_schema turned a blank optional string into "" -- which the save drops as unchanged (remove_empty_secrets) -- but a blank required one into None. For a secret that is required with no default (youtube-stats' api_key) that None failed validation, so every save of the page was refused until the key was typed in again. A blank text secret (x-secret, type string) now parses to "", whatever its required list says; a list or object secret keeps getting [] or {}, which the save drops the same way. Not _SKIP_FIELD: skipping keeps the value load_config() merged in, and the save would then write it back to config_secrets.json -- after a secret change the cached section can still hold the old one, so that write reverted it. A test covers that sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): POST /plugins/config refuses core sections and malformed ids Reset and uninstall check the plugin id with _non_plugin_id_error; the save did not. {"plugin_id": "display", "config": {...}} found no schema, so nothing was validated or filtered, and the body was merged into the core display section along with "enabled": true -- rows: "banana" included. A plugin_id that was not a string (a list, an object, a number) reached config.get() or the schema lookup, raised TypeError, and came back as a 500. Both the JSON and the form path now call _non_plugin_id_error first and answer its 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a text field keeps "true", "[1, 2]" and "{}" as typed _parse_form_value_with_schema guessed before it consulted the schema: "true"/"false" became booleans, and a value starting with "[" or "{" that parsed as JSON became a list or object, whatever the field's type. A text setting holding "true", "False", "[1, 2]" or "{}" was then refused by validation ("Expected type string, got bool"), and the save with it. A field whose schema type is string, or string-or-null, now returns the posted text as it came. Every other type goes through the conversions as before; numbers in text fields were already left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): copy the cached config without pickle _private_copy was a pickle round trip. It only ever unpickled bytes it had just made from our own dict, so nothing untrusted reached it, but it put pickle in the config path and Codacy failed the PR for it (B301/B403). The config is JSON data, so copying its dicts and lists is a full copy; every other value is immutable. Measured on ledpi (Pi 4) with its real 60 KiB config: 2.11 ms, against 1.92 ms for pickle and 6.75 ms for copy.deepcopy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): describe the config copy without pickle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
9.9 KiB
Python
256 lines
9.9 KiB
Python
"""
|
|
Unit tests for the module-level helper functions in
|
|
web_interface/blueprints/api_v3.py.
|
|
|
|
These helpers back the plugin config save endpoint (the largest function in
|
|
the repo) and the store's update-available detection, but were previously
|
|
exercised only indirectly through full Flask route tests. Testing them
|
|
directly pins behavior that the routes rely on — including a few
|
|
characterized quirks marked below.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar, Dict
|
|
|
|
import pytest
|
|
|
|
project_root = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from web_interface.blueprints.api_v3 import ( # noqa: E402
|
|
_is_plugin_update_available,
|
|
_coerce_to_bool,
|
|
deep_merge,
|
|
_parse_form_value,
|
|
_get_schema_property,
|
|
_set_nested_value,
|
|
_SKIP_FIELD,
|
|
)
|
|
|
|
|
|
class TestIsPluginUpdateAvailable:
|
|
def test_equal_versions_no_update(self):
|
|
assert _is_plugin_update_available("1.2.0", "1.2.0") is False
|
|
|
|
def test_newer_registry_version_needs_update(self):
|
|
assert _is_plugin_update_available("1.2.0", "1.3.0") is True
|
|
|
|
def test_installed_ahead_of_registry_no_update(self):
|
|
# A locally modified plugin ahead of the registry must not be
|
|
# flagged — this is the whole point of semantic comparison here.
|
|
assert _is_plugin_update_available("2.0.0", "1.9.0") is False
|
|
|
|
def test_empty_versions_no_update(self):
|
|
assert _is_plugin_update_available("", "1.0.0") is False
|
|
assert _is_plugin_update_available("1.0.0", "") is False
|
|
assert _is_plugin_update_available("", "") is False
|
|
|
|
def test_v_prefix_parses_as_equal(self):
|
|
# packaging.version treats "v1.2.0" == "1.2.0" (PEP 440 tolerates the
|
|
# prefix), so no update is flagged. Contrast with store_manager's
|
|
# string-equality check — see test_version_comparison_consistency.py.
|
|
assert _is_plugin_update_available("v1.2.0", "1.2.0") is False
|
|
|
|
def test_two_part_version_parses_as_equal(self):
|
|
assert _is_plugin_update_available("1.2", "1.2.0") is False
|
|
|
|
def test_unparseable_version_surfaces_mismatch(self):
|
|
# Direction unknowable → surface the difference rather than hide a
|
|
# potential update.
|
|
assert _is_plugin_update_available("abc.def", "1.0.0") is True
|
|
|
|
def test_prerelease_below_release(self):
|
|
assert _is_plugin_update_available("1.2.0-rc1", "1.2.0") is True
|
|
|
|
|
|
class TestCoerceToBool:
|
|
@pytest.mark.parametrize("value", ["true", "TRUE", "on", "1", "yes", "YES"])
|
|
def test_truthy_strings(self, value):
|
|
assert _coerce_to_bool(value) is True
|
|
|
|
@pytest.mark.parametrize("value", ["false", "off", "0", "no", "", "banana"])
|
|
def test_falsey_strings(self, value):
|
|
assert _coerce_to_bool(value) is False
|
|
|
|
def test_none_is_false(self):
|
|
assert _coerce_to_bool(None) is False
|
|
|
|
def test_bools_pass_through(self):
|
|
assert _coerce_to_bool(True) is True
|
|
assert _coerce_to_bool(False) is False
|
|
|
|
def test_int_only_one_is_true(self):
|
|
# Characterized quirk: ints coerce via `value == 1`, so 2 (truthy in
|
|
# Python) is False here.
|
|
assert _coerce_to_bool(1) is True
|
|
assert _coerce_to_bool(2) is False
|
|
assert _coerce_to_bool(0) is False
|
|
|
|
def test_other_types_false(self):
|
|
assert _coerce_to_bool([1]) is False
|
|
assert _coerce_to_bool({"a": 1}) is False
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_nested_dicts_merge_recursively(self):
|
|
base = {"a": {"x": 1, "y": 2}, "b": 1}
|
|
update = {"a": {"y": 3, "z": 4}}
|
|
assert deep_merge(base, update) == {"a": {"x": 1, "y": 3, "z": 4}, "b": 1}
|
|
|
|
def test_scalar_over_dict_replaces(self):
|
|
assert deep_merge({"a": {"x": 1}}, {"a": 5}) == {"a": 5}
|
|
|
|
def test_dict_over_scalar_replaces(self):
|
|
assert deep_merge({"a": 5}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
|
|
|
def test_lists_replaced_wholesale(self):
|
|
assert deep_merge({"a": [1, 2]}, {"a": [3]}) == {"a": [3]}
|
|
|
|
def test_top_level_not_mutated_but_shallow_copy(self):
|
|
# Characterized: result = base.copy() protects base's top level, but
|
|
# nested dicts NOT touched by the update are shared by reference.
|
|
base = {"a": {"x": 1}, "keep": {"y": 2}}
|
|
result = deep_merge(base, {"a": {"x": 9}})
|
|
assert base == {"a": {"x": 1}, "keep": {"y": 2}} # base unchanged
|
|
assert result["keep"] is base["keep"] # untouched subtree is shared
|
|
|
|
|
|
class TestParseFormValue:
|
|
def test_boolean_strings(self):
|
|
assert _parse_form_value("true") is True
|
|
assert _parse_form_value("False") is False
|
|
|
|
def test_null_like_strings(self):
|
|
assert _parse_form_value("null") is None
|
|
assert _parse_form_value("none") is None
|
|
assert _parse_form_value("") is None
|
|
|
|
def test_none_passthrough(self):
|
|
assert _parse_form_value(None) is None
|
|
|
|
def test_numbers(self):
|
|
assert _parse_form_value("42") == 42
|
|
assert isinstance(_parse_form_value("42"), int)
|
|
assert _parse_form_value("3.5") == 3.5
|
|
assert isinstance(_parse_form_value("3.5"), float)
|
|
|
|
def test_json_array_parsed_before_numbers(self):
|
|
# RGB arrays like "[255, 0, 0]" must come back as lists.
|
|
assert _parse_form_value("[255, 0, 0]") == [255, 0, 0]
|
|
|
|
def test_json_object(self):
|
|
assert _parse_form_value('{"a": 1}') == {"a": 1}
|
|
|
|
def test_malformed_json_falls_back_to_string(self):
|
|
assert _parse_form_value("[not json") == "[not json"
|
|
|
|
def test_plain_string_returned_unstripped(self):
|
|
# The original value (not the stripped copy) is returned.
|
|
assert _parse_form_value(" hello ") == " hello "
|
|
|
|
def test_non_string_passthrough(self):
|
|
assert _parse_form_value(7) == 7
|
|
assert _parse_form_value([1, 2]) == [1, 2]
|
|
|
|
|
|
class TestGetSchemaProperty:
|
|
SCHEMA: ClassVar[Dict[str, Any]] = {
|
|
"properties": {
|
|
"brightness": {"type": "integer"},
|
|
"customization": {
|
|
"type": "object",
|
|
"properties": {
|
|
"time_text": {
|
|
"type": "object",
|
|
"properties": {"font": {"type": "string"}},
|
|
},
|
|
},
|
|
},
|
|
"fifa.world": {"type": "object",
|
|
"properties": {"enabled": {"type": "boolean"}}},
|
|
"cities": {"type": "array",
|
|
"items": {"type": "object",
|
|
"properties": {"timezone": {"type": "string"}}}},
|
|
"color": {"type": ["array", "null"], "items": {"type": "integer"}},
|
|
}
|
|
}
|
|
|
|
def test_top_level_lookup(self):
|
|
assert _get_schema_property(self.SCHEMA, "brightness") == {"type": "integer"}
|
|
|
|
def test_nested_dot_path(self):
|
|
prop = _get_schema_property(self.SCHEMA, "customization.time_text.font")
|
|
assert prop == {"type": "string"}
|
|
|
|
def test_dotted_schema_key_matched_longest_first(self):
|
|
# League keys like "fifa.world" contain a literal dot and must match
|
|
# as a single key, not be split into nested fifa -> world lookups.
|
|
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
|
|
assert prop == {"type": "boolean"}
|
|
|
|
def test_an_index_steps_into_the_array_items(self):
|
|
# How a table row posts its cells
|
|
assert _get_schema_property(self.SCHEMA, "cities.0.timezone") == {"type": "string"}
|
|
assert _get_schema_property(self.SCHEMA, "color.2") == {"type": "integer"}
|
|
|
|
def test_a_non_index_under_an_array_is_not_found(self):
|
|
assert _get_schema_property(self.SCHEMA, "cities.timezone") is None
|
|
assert _get_schema_property(self.SCHEMA, "cities.0.nope") is None
|
|
|
|
def test_missing_path_returns_none(self):
|
|
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
|
|
|
|
def test_no_properties_returns_none(self):
|
|
assert _get_schema_property({}, "a") is None
|
|
assert _get_schema_property(None, "a") is None
|
|
|
|
|
|
class TestSetNestedValue:
|
|
def test_sets_top_level(self):
|
|
config = {}
|
|
_set_nested_value(config, "brightness", 80)
|
|
assert config == {"brightness": 80}
|
|
|
|
def test_creates_intermediate_dicts(self):
|
|
config = {}
|
|
_set_nested_value(config, "customization.time_text.font", "5x7")
|
|
assert config == {"customization": {"time_text": {"font": "5x7"}}}
|
|
|
|
def test_merges_into_existing_nested_dict(self):
|
|
config = {"customization": {"color": "red"}}
|
|
_set_nested_value(config, "customization.font", "5x7")
|
|
assert config == {"customization": {"color": "red", "font": "5x7"}}
|
|
|
|
def test_scalar_intermediate_replaced_with_dict(self):
|
|
# Characterized: a non-dict intermediate is silently replaced.
|
|
config = {"customization": "oops"}
|
|
_set_nested_value(config, "customization.font", "5x7")
|
|
assert config == {"customization": {"font": "5x7"}}
|
|
|
|
def test_existing_dotted_key_preserved(self):
|
|
# An existing literal "fifa.world" key must be updated in place, not
|
|
# exploded into nested {"fifa": {"world": ...}}.
|
|
config = {"fifa.world": {"enabled": False}}
|
|
_set_nested_value(config, "fifa.world.enabled", True)
|
|
assert config == {"fifa.world": {"enabled": True}}
|
|
|
|
def test_none_overwrites_existing(self):
|
|
# Regression: None is a real value here (the per-mode "inherit the
|
|
# base" override for a nullable field, or a blank indexed color
|
|
# channel) and must replace whatever was already stored. Only the
|
|
# _SKIP_FIELD sentinel means "leave it alone" -- see the next test.
|
|
config = {"a": 1}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": None}
|
|
|
|
def test_none_sets_missing_key(self):
|
|
config = {}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": None}
|
|
|
|
def test_skip_field_does_not_overwrite_existing(self):
|
|
config = {"a": 1}
|
|
_set_nested_value(config, "a", _SKIP_FIELD)
|
|
assert config == {"a": 1}
|