mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 11:48:06 +00:00
test: cover the untested fragile logic (compatibility gate, secrets, config merges, durations, skin cards)
New unit tests for pure or filesystem-only logic that previously had zero direct coverage: - test_compatibility.py: the semver install gate (parse_semver suffix handling, every range operator, TRUSTWORTHY_FLOOR behavior for cores reporting untrustworthy versions, 'more restrictive wins', and the malformed-manifest shapes that used to raise). - test/web_interface/test_secret_helpers.py: the canonical x-secret helpers — find/separate/mask/remove, array-item secrets, no input mutation, and a separate->recombine round-trip. - test/web_interface/test_api_v3_helpers.py: the module-level helpers behind the plugin config save endpoint (_is_plugin_update_available, _coerce_to_bool including the int==1 quirk, deep_merge including its shared-subtree shallowness, _parse_form_value, dotted-key-aware _get_schema_property/_set_nested_value). - test_base_plugin_duration.py: get_display_duration's full coercion ladder (instance attr -> config -> 15.0), including the bool-is-int quirk where display_duration=True means one second. - test_config_manager_secrets.py: the secrets round-trip — deep-merge on load, strip on save, group pruning, the load fast path — and two characterized sharp edges marked SUSPECTED BUG: an unreadable secrets file at save time writes secrets into config.json in plaintext, and a same-mtime-same-size content swap is served stale. - test_schema_manager_merge.py: merge_with_defaults branch behavior (None replacement vs falsey preservation, dict-vs-scalar mismatches, arrays replaced wholesale, defaults never mutated). - test_skin_system.py (extended): render_skin_card shares _render_game's 3-strike counter but never resets it on success — the asymmetry is pinned in both directions, along with card fallthrough and the disable interaction between the two paths. Suspected bugs are characterized, not fixed — each carries a comment so a future behavior change is deliberate rather than accidental. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 = {
|
||||
"properties": {
|
||||
"brightness": {"type": "integer"},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time_text": {
|
||||
"type": "object",
|
||||
"properties": {"font": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fifa.world": {"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}}},
|
||||
}
|
||||
}
|
||||
|
||||
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_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_does_not_overwrite_existing(self):
|
||||
config = {"a": 1}
|
||||
_set_nested_value(config, "a", None)
|
||||
assert config == {"a": 1}
|
||||
|
||||
def test_none_sets_missing_key(self):
|
||||
config = {}
|
||||
_set_nested_value(config, "a", None)
|
||||
assert config == {"a": None}
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Tests for src/web_interface/secret_helpers.py — the canonical secret
|
||||
identification / separation / masking helpers.
|
||||
|
||||
This module is the extracted single source of truth for x-secret handling,
|
||||
but until now had zero test coverage (only ``mask_secret_fields`` is even
|
||||
imported by production code, from pages_v3). api_v3.py still carries three
|
||||
inline re-implementations of ``find_secret_fields``/``separate_secrets`` —
|
||||
see test_secret_separation_parity.py — so pinning the canonical behavior
|
||||
here is a precondition for ever migrating those copies.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from src.web_interface.secret_helpers import (
|
||||
find_secret_fields,
|
||||
separate_secrets,
|
||||
mask_secret_fields,
|
||||
mask_all_secret_values,
|
||||
remove_empty_secrets,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_PROPS = {
|
||||
"api_key": {"type": "string", "x-secret": True},
|
||||
"city": {"type": "string"},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
"username": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"accounts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
"recovery_codes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "x-secret": True},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestFindSecretFields:
|
||||
def test_top_level_secret(self):
|
||||
assert "api_key" in find_secret_fields(SCHEMA_PROPS)
|
||||
|
||||
def test_non_secret_not_included(self):
|
||||
assert "city" not in find_secret_fields(SCHEMA_PROPS)
|
||||
|
||||
def test_nested_object_secret_uses_dot_path(self):
|
||||
assert "auth.token" in find_secret_fields(SCHEMA_PROPS)
|
||||
assert "auth.username" not in find_secret_fields(SCHEMA_PROPS)
|
||||
|
||||
def test_array_item_object_secret_uses_bracket_path(self):
|
||||
assert "accounts[].token" in find_secret_fields(SCHEMA_PROPS)
|
||||
|
||||
def test_array_of_secrets_uses_bracket_path(self):
|
||||
assert "recovery_codes[]" in find_secret_fields(SCHEMA_PROPS)
|
||||
|
||||
def test_full_set(self):
|
||||
assert find_secret_fields(SCHEMA_PROPS) == {
|
||||
"api_key", "auth.token", "accounts[].token", "recovery_codes[]",
|
||||
}
|
||||
|
||||
def test_non_dict_properties_tolerated(self):
|
||||
assert find_secret_fields({"weird": "not-a-dict"}) == set()
|
||||
|
||||
def test_non_dict_input_returns_empty(self):
|
||||
assert find_secret_fields(None) == set()
|
||||
assert find_secret_fields([]) == set()
|
||||
|
||||
|
||||
class TestSeparateSecrets:
|
||||
def test_flat_partition(self):
|
||||
regular, secrets = separate_secrets(
|
||||
{"api_key": "s3cret", "city": "Austin"}, {"api_key"})
|
||||
assert regular == {"city": "Austin"}
|
||||
assert secrets == {"api_key": "s3cret"}
|
||||
|
||||
def test_nested_partition(self):
|
||||
config = {"auth": {"token": "t0k", "username": "chuck"}}
|
||||
regular, secrets = separate_secrets(config, {"auth.token"})
|
||||
assert regular == {"auth": {"username": "chuck"}}
|
||||
assert secrets == {"auth": {"token": "t0k"}}
|
||||
|
||||
def test_empty_nested_dicts_pruned_from_regular(self):
|
||||
# A dict that is all secrets leaves nothing behind on the regular
|
||||
# side — the key must be dropped, not kept as {}.
|
||||
config = {"auth": {"token": "t0k"}}
|
||||
regular, secrets = separate_secrets(config, {"auth.token"})
|
||||
assert regular == {}
|
||||
assert secrets == {"auth": {"token": "t0k"}}
|
||||
|
||||
def test_whole_array_secret(self):
|
||||
config = {"recovery_codes": ["a", "b"], "city": "Austin"}
|
||||
regular, secrets = separate_secrets(config, {"recovery_codes[]"})
|
||||
assert regular == {"city": "Austin"}
|
||||
assert secrets == {"recovery_codes": ["a", "b"]}
|
||||
|
||||
def test_array_item_secrets_produce_parallel_lists(self):
|
||||
# Per-item secrets keep the arrays index-aligned so they can be
|
||||
# recombined: regular gets the stripped items, secrets a parallel
|
||||
# list of the extracted values.
|
||||
config = {"accounts": [
|
||||
{"name": "a", "token": "ta"},
|
||||
{"name": "b", "token": "tb"},
|
||||
]}
|
||||
regular, secrets = separate_secrets(config, {"accounts[].token"})
|
||||
assert regular == {"accounts": [{"name": "a"}, {"name": "b"}]}
|
||||
assert secrets == {"accounts": [{"token": "ta"}, {"token": "tb"}]}
|
||||
|
||||
def test_array_item_non_dict_items_get_placeholder(self):
|
||||
config = {"accounts": [{"name": "a", "token": "ta"}, "oddball"]}
|
||||
regular, secrets = separate_secrets(config, {"accounts[].token"})
|
||||
assert regular == {"accounts": [{"name": "a"}, "oddball"]}
|
||||
assert secrets == {"accounts": [{"token": "ta"}, {}]}
|
||||
|
||||
def test_array_without_secret_paths_stays_regular(self):
|
||||
config = {"teams": ["DAL", "HOU"]}
|
||||
regular, secrets = separate_secrets(config, {"api_key"})
|
||||
assert regular == {"teams": ["DAL", "HOU"]}
|
||||
assert secrets == {}
|
||||
|
||||
def test_round_trip_loses_nothing(self):
|
||||
# separate + naive recombine must reconstruct the original config.
|
||||
config = {
|
||||
"api_key": "k",
|
||||
"city": "Austin",
|
||||
"auth": {"token": "t", "username": "chuck"},
|
||||
"recovery_codes": ["a", "b"],
|
||||
}
|
||||
paths = find_secret_fields(SCHEMA_PROPS)
|
||||
regular, secrets = separate_secrets(copy.deepcopy(config), paths)
|
||||
|
||||
def recombine(reg, sec):
|
||||
out = copy.deepcopy(reg)
|
||||
for k, v in sec.items():
|
||||
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||||
out[k] = recombine(out[k], v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
assert recombine(regular, secrets) == config
|
||||
|
||||
|
||||
class TestMaskSecretFields:
|
||||
def test_masks_present_secret_to_empty_string(self):
|
||||
result = mask_secret_fields({"api_key": "s3cret"}, SCHEMA_PROPS)
|
||||
assert result["api_key"] == ""
|
||||
|
||||
def test_leaves_non_secret_untouched(self):
|
||||
result = mask_secret_fields({"city": "Austin"}, SCHEMA_PROPS)
|
||||
assert result["city"] == "Austin"
|
||||
|
||||
def test_none_and_empty_left_alone(self):
|
||||
result = mask_secret_fields({"api_key": None}, SCHEMA_PROPS)
|
||||
assert result["api_key"] is None
|
||||
result = mask_secret_fields({"api_key": ""}, SCHEMA_PROPS)
|
||||
assert result["api_key"] == ""
|
||||
|
||||
def test_falsey_but_set_values_are_masked(self):
|
||||
# 0 and False are real values; the check is `is not None and != ''`.
|
||||
# Note False == '' is False in Python, so False IS masked; 0 == '' is
|
||||
# also False, so 0 is masked too.
|
||||
result = mask_secret_fields({"api_key": 0}, SCHEMA_PROPS)
|
||||
assert result["api_key"] == ""
|
||||
result = mask_secret_fields({"api_key": False}, SCHEMA_PROPS)
|
||||
assert result["api_key"] == ""
|
||||
|
||||
def test_nested_object_masked_without_mutating_input(self):
|
||||
config = {"auth": {"token": "t0k", "username": "chuck"}}
|
||||
original = copy.deepcopy(config)
|
||||
result = mask_secret_fields(config, SCHEMA_PROPS)
|
||||
assert result["auth"]["token"] == ""
|
||||
assert result["auth"]["username"] == "chuck"
|
||||
assert config == original # input not mutated
|
||||
|
||||
def test_array_of_secrets_masked_elementwise(self):
|
||||
result = mask_secret_fields(
|
||||
{"recovery_codes": ["a", "b"]}, SCHEMA_PROPS)
|
||||
assert result["recovery_codes"] == ["", ""]
|
||||
|
||||
def test_array_of_objects_masked_per_item(self):
|
||||
config = {"accounts": [{"name": "a", "token": "ta"}, "oddball"]}
|
||||
result = mask_secret_fields(config, SCHEMA_PROPS)
|
||||
assert result["accounts"][0] == {"name": "a", "token": ""}
|
||||
assert result["accounts"][1] == "oddball"
|
||||
|
||||
def test_non_dict_schema_property_tolerated(self):
|
||||
assert mask_secret_fields({"x": 1}, {"x": "bogus"}) == {"x": 1}
|
||||
|
||||
|
||||
class TestMaskAllSecretValues:
|
||||
def test_real_values_replaced_with_bullets(self):
|
||||
assert mask_all_secret_values({"key": "abc"}) == {"key": "••••••••"}
|
||||
|
||||
def test_placeholders_preserved(self):
|
||||
# YOUR_* placeholders must survive so the UI can show "not set".
|
||||
result = mask_all_secret_values({"key": "YOUR_API_KEY_HERE"})
|
||||
assert result == {"key": "YOUR_API_KEY_HERE"}
|
||||
|
||||
def test_empty_and_none_preserved(self):
|
||||
assert mask_all_secret_values({"a": "", "b": None}) == {"a": "", "b": None}
|
||||
|
||||
def test_recurses_into_nested_dicts(self):
|
||||
result = mask_all_secret_values({"plugin": {"token": "t", "empty": ""}})
|
||||
assert result == {"plugin": {"token": "••••••••", "empty": ""}}
|
||||
|
||||
def test_non_string_real_values_masked(self):
|
||||
assert mask_all_secret_values({"port": 8080}) == {"port": "••••••••"}
|
||||
|
||||
|
||||
class TestRemoveEmptySecrets:
|
||||
def test_strips_empty_string(self):
|
||||
assert remove_empty_secrets({"a": "", "b": "real"}) == {"b": "real"}
|
||||
|
||||
def test_strips_whitespace_only(self):
|
||||
assert remove_empty_secrets({"a": " "}) == {}
|
||||
|
||||
def test_strips_none(self):
|
||||
assert remove_empty_secrets({"a": None}) == {}
|
||||
|
||||
def test_prunes_empty_nested_dicts(self):
|
||||
assert remove_empty_secrets({"plugin": {"token": ""}}) == {}
|
||||
|
||||
def test_keeps_nested_real_values(self):
|
||||
result = remove_empty_secrets({"plugin": {"token": "t", "empty": ""}})
|
||||
assert result == {"plugin": {"token": "t"}}
|
||||
|
||||
def test_keeps_falsey_non_string_values(self):
|
||||
# 0 and False are neither None nor blank strings — they are kept.
|
||||
assert remove_empty_secrets({"a": 0, "b": False}) == {"a": 0, "b": False}
|
||||
Reference in New Issue
Block a user