mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 19:58:08 +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,110 @@
|
|||||||
|
"""
|
||||||
|
Tests for BasePlugin.get_display_duration — ~100 lines of type coercion that
|
||||||
|
every plugin's rotation slot depends on, previously untested.
|
||||||
|
|
||||||
|
The contract: a positive number wins wherever it comes from; everything else
|
||||||
|
falls through instance attr → config → the 15.0 default, logging on the way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
|
||||||
|
class _MinimalPlugin(BasePlugin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_plugin(config=None, instance_duration="__unset__"):
|
||||||
|
plugin = _MinimalPlugin(
|
||||||
|
plugin_id="duration-test",
|
||||||
|
config=config or {},
|
||||||
|
display_manager=MagicMock(),
|
||||||
|
cache_manager=MagicMock(),
|
||||||
|
plugin_manager=MagicMock(),
|
||||||
|
)
|
||||||
|
if instance_duration != "__unset__":
|
||||||
|
plugin.display_duration = instance_duration
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstanceVariable:
|
||||||
|
def test_positive_int_wins(self):
|
||||||
|
assert make_plugin(instance_duration=30).get_display_duration() == 30.0
|
||||||
|
|
||||||
|
def test_positive_float_wins(self):
|
||||||
|
assert make_plugin(instance_duration=12.5).get_display_duration() == 12.5
|
||||||
|
|
||||||
|
def test_returns_float_type(self):
|
||||||
|
result = make_plugin(instance_duration=30).get_display_duration()
|
||||||
|
assert isinstance(result, float)
|
||||||
|
|
||||||
|
def test_numeric_string_wins(self):
|
||||||
|
assert make_plugin(instance_duration="25").get_display_duration() == 25.0
|
||||||
|
|
||||||
|
def test_zero_falls_through_to_config(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration=0)
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_negative_falls_through_to_config(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration=-5)
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_none_falls_through_to_config(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration=None)
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_garbage_string_falls_through(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration="abc")
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_non_positive_string_falls_through(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration="0")
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_unexpected_type_falls_through(self):
|
||||||
|
plugin = make_plugin(config={"display_duration": 20},
|
||||||
|
instance_duration=[30])
|
||||||
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_bool_true_is_one_second(self):
|
||||||
|
# Characterized quirk: bool is an int subclass, so display_duration =
|
||||||
|
# True passes the isinstance((int, float)) branch and returns 1.0.
|
||||||
|
assert make_plugin(instance_duration=True).get_display_duration() == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigFallback:
|
||||||
|
def test_config_number(self):
|
||||||
|
assert make_plugin({"display_duration": 20}).get_display_duration() == 20.0
|
||||||
|
|
||||||
|
def test_config_numeric_string(self):
|
||||||
|
assert make_plugin({"display_duration": "12.5"}).get_display_duration() == 12.5
|
||||||
|
|
||||||
|
def test_missing_config_uses_default(self):
|
||||||
|
assert make_plugin({}).get_display_duration() == 15.0
|
||||||
|
|
||||||
|
def test_config_zero_uses_default(self):
|
||||||
|
assert make_plugin({"display_duration": 0}).get_display_duration() == 15.0
|
||||||
|
|
||||||
|
def test_config_negative_uses_default(self):
|
||||||
|
assert make_plugin({"display_duration": -10}).get_display_duration() == 15.0
|
||||||
|
|
||||||
|
def test_config_garbage_string_uses_default(self):
|
||||||
|
assert make_plugin({"display_duration": "soon"}).get_display_duration() == 15.0
|
||||||
|
|
||||||
|
def test_config_unexpected_type_uses_default(self):
|
||||||
|
assert make_plugin({"display_duration": {"s": 5}}).get_display_duration() == 15.0
|
||||||
|
|
||||||
|
def test_config_none_uses_default(self):
|
||||||
|
assert make_plugin({"display_duration": None}).get_display_duration() == 15.0
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Tests for src/plugin_system/compatibility.py — the "can this plugin run on
|
||||||
|
this core?" gate used by both the plugin loader (advisory) and the store
|
||||||
|
manager (blocking at install/update time).
|
||||||
|
|
||||||
|
This module had zero direct test coverage despite guarding every install.
|
||||||
|
These tests pin the documented contract: refuse only on evidence, resolve
|
||||||
|
every uncertain case (unparseable versions, missing fields, untrustworthy
|
||||||
|
core) to compatible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.compatibility import (
|
||||||
|
TRUSTWORTHY_FLOOR,
|
||||||
|
parse_semver,
|
||||||
|
_parse_strict,
|
||||||
|
_satisfies_range,
|
||||||
|
satisfies_compatible_versions,
|
||||||
|
declared_min_version,
|
||||||
|
check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSemver:
|
||||||
|
def test_plain_triplet(self):
|
||||||
|
assert parse_semver("1.2.3") == (1, 2, 3)
|
||||||
|
|
||||||
|
def test_leading_v_tolerated(self):
|
||||||
|
assert parse_semver("v3.2.1") == (3, 2, 1)
|
||||||
|
|
||||||
|
def test_prerelease_suffix_stripped(self):
|
||||||
|
# "3.2.0-rc1" must NOT parse as (3, 2, 1) — a release candidate must
|
||||||
|
# not rank above its own release.
|
||||||
|
assert parse_semver("3.2.0-rc1") == (3, 2, 0)
|
||||||
|
|
||||||
|
def test_build_suffix_stripped(self):
|
||||||
|
# "3.2.0+build42" must NOT parse as (3, 2, 42).
|
||||||
|
assert parse_semver("3.2.0+build42") == (3, 2, 0)
|
||||||
|
|
||||||
|
def test_two_part_version_pads_zero(self):
|
||||||
|
assert parse_semver("1.2") == (1, 2, 0)
|
||||||
|
|
||||||
|
def test_one_part_version_pads_zeros(self):
|
||||||
|
assert parse_semver("2") == (2, 0, 0)
|
||||||
|
|
||||||
|
def test_extra_parts_ignored(self):
|
||||||
|
assert parse_semver("1.2.3.4") == (1, 2, 3)
|
||||||
|
|
||||||
|
def test_non_string_returns_none(self):
|
||||||
|
assert parse_semver(None) is None
|
||||||
|
assert parse_semver(123) is None
|
||||||
|
assert parse_semver((1, 2, 3)) is None
|
||||||
|
|
||||||
|
def test_garbage_with_no_digits_is_lenient_zero(self):
|
||||||
|
# Documented leniency: digit-scraping yields (0, 0, 0) for pure
|
||||||
|
# garbage. Fine for a floor (0.0.0 never blocks), wrong for ranges —
|
||||||
|
# which is why ranges go through _parse_strict instead.
|
||||||
|
assert parse_semver("garbage") == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_whitespace_stripped(self):
|
||||||
|
assert parse_semver(" 1.2.3 ") == (1, 2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseStrict:
|
||||||
|
def test_accepts_real_versions(self):
|
||||||
|
assert _parse_strict("1.2.3") == (1, 2, 3)
|
||||||
|
assert _parse_strict("v1.2.3-rc1") == (1, 2, 3)
|
||||||
|
assert _parse_strict("2.0") == (2, 0, 0)
|
||||||
|
|
||||||
|
def test_rejects_garbage(self):
|
||||||
|
assert _parse_strict("not-a-version") is None
|
||||||
|
assert _parse_strict("") is None
|
||||||
|
|
||||||
|
def test_rejects_non_string(self):
|
||||||
|
assert _parse_strict(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSatisfiesRange:
|
||||||
|
CORE = (3, 1, 0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec,expected", [
|
||||||
|
(">=3.0.0", True),
|
||||||
|
(">=3.1.0", True),
|
||||||
|
(">=3.2.0", False),
|
||||||
|
("<=3.1.0", True),
|
||||||
|
("<=3.0.9", False),
|
||||||
|
(">3.0.9", True),
|
||||||
|
(">3.1.0", False),
|
||||||
|
("<3.2.0", True),
|
||||||
|
("<3.1.0", False),
|
||||||
|
])
|
||||||
|
def test_comparison_operators(self, spec, expected):
|
||||||
|
assert _satisfies_range(self.CORE, spec) is expected
|
||||||
|
|
||||||
|
def test_tilde_allows_patch_only(self):
|
||||||
|
# ~3.1.0 means >=3.1.0, <3.2.0
|
||||||
|
assert _satisfies_range((3, 1, 5), "~3.1.0") is True
|
||||||
|
assert _satisfies_range((3, 2, 0), "~3.1.0") is False
|
||||||
|
assert _satisfies_range((3, 0, 9), "~3.1.0") is False
|
||||||
|
|
||||||
|
def test_caret_allows_minor_and_patch(self):
|
||||||
|
# ^3.1.0 means >=3.1.0, <4.0.0
|
||||||
|
assert _satisfies_range((3, 9, 9), "^3.1.0") is True
|
||||||
|
assert _satisfies_range((4, 0, 0), "^3.1.0") is False
|
||||||
|
assert _satisfies_range((3, 0, 0), "^3.1.0") is False
|
||||||
|
|
||||||
|
def test_bare_exact_version(self):
|
||||||
|
assert _satisfies_range((3, 1, 0), "3.1.0") is True
|
||||||
|
assert _satisfies_range((3, 1, 1), "3.1.0") is False
|
||||||
|
|
||||||
|
def test_inclusive_dash_range(self):
|
||||||
|
assert _satisfies_range((2, 5, 0), "2.0.0 - 3.1.0") is True
|
||||||
|
assert _satisfies_range((2, 0, 0), "2.0.0 - 3.1.0") is True
|
||||||
|
assert _satisfies_range((3, 1, 0), "2.0.0 - 3.1.0") is True
|
||||||
|
assert _satisfies_range((3, 1, 1), "2.0.0 - 3.1.0") is False
|
||||||
|
|
||||||
|
def test_unparseable_spec_returns_none_not_false(self):
|
||||||
|
# Garbage must read as "no evidence", never as a refusal — an
|
||||||
|
# unrecognised spelling must not cost a user a working install.
|
||||||
|
assert _satisfies_range(self.CORE, "banana") is None
|
||||||
|
assert _satisfies_range(self.CORE, ">=banana") is None
|
||||||
|
assert _satisfies_range(self.CORE, "") is None
|
||||||
|
assert _satisfies_range(self.CORE, "banana - 3.0.0") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSatisfiesCompatibleVersions:
|
||||||
|
def test_any_entry_satisfying_wins(self):
|
||||||
|
manifest = {"compatible_versions": ["<1.0.0", ">=3.0.0"]}
|
||||||
|
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
|
||||||
|
|
||||||
|
def test_all_entries_failing_is_false(self):
|
||||||
|
manifest = {"compatible_versions": ["<1.0.0", "2.0.0 - 2.9.9"]}
|
||||||
|
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is False
|
||||||
|
|
||||||
|
def test_absent_field_returns_none(self):
|
||||||
|
assert satisfies_compatible_versions({}, (3, 1, 0)) is None
|
||||||
|
|
||||||
|
def test_empty_list_returns_none(self):
|
||||||
|
assert satisfies_compatible_versions(
|
||||||
|
{"compatible_versions": []}, (3, 1, 0)) is None
|
||||||
|
|
||||||
|
def test_non_list_returns_none(self):
|
||||||
|
assert satisfies_compatible_versions(
|
||||||
|
{"compatible_versions": ">=2.0.0"}, (3, 1, 0)) is None
|
||||||
|
|
||||||
|
def test_all_unparseable_entries_returns_none(self):
|
||||||
|
manifest = {"compatible_versions": ["banana", 42, None]}
|
||||||
|
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is None
|
||||||
|
|
||||||
|
def test_mixed_parseable_and_garbage_uses_parseable(self):
|
||||||
|
manifest = {"compatible_versions": ["banana", ">=3.0.0"]}
|
||||||
|
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeclaredMinVersion:
|
||||||
|
def test_top_level_field(self):
|
||||||
|
assert declared_min_version({"min_ledmatrix_version": "2.1.0"}) == "2.1.0"
|
||||||
|
|
||||||
|
def test_requires_dict_fallback(self):
|
||||||
|
manifest = {"requires": {"min_ledmatrix_version": "2.2.0"}}
|
||||||
|
assert declared_min_version(manifest) == "2.2.0"
|
||||||
|
|
||||||
|
def test_versions_array_fallback(self):
|
||||||
|
manifest = {"versions": [{"ledmatrix_min_version": "2.3.0"}]}
|
||||||
|
assert declared_min_version(manifest) == "2.3.0"
|
||||||
|
|
||||||
|
def test_versions_array_deprecated_spelling(self):
|
||||||
|
manifest = {"versions": [{"ledmatrix_min": "2.4.0"}]}
|
||||||
|
assert declared_min_version(manifest) == "2.4.0"
|
||||||
|
|
||||||
|
def test_top_level_wins_over_versions_array(self):
|
||||||
|
manifest = {
|
||||||
|
"min_ledmatrix_version": "2.1.0",
|
||||||
|
"versions": [{"ledmatrix_min_version": "9.9.9"}],
|
||||||
|
}
|
||||||
|
assert declared_min_version(manifest) == "2.1.0"
|
||||||
|
|
||||||
|
def test_requires_as_list_does_not_raise(self):
|
||||||
|
# A hand-edited manifest can carry `requires` as a list; this used to
|
||||||
|
# raise AttributeError and one malformed manifest would take down the
|
||||||
|
# whole install path.
|
||||||
|
assert declared_min_version({"requires": ["something"]}) is None
|
||||||
|
|
||||||
|
def test_versions_as_dict_does_not_raise(self):
|
||||||
|
# Same for `versions` as a mapping (used to raise KeyError).
|
||||||
|
assert declared_min_version({"versions": {"0": {}}}) is None
|
||||||
|
|
||||||
|
def test_nothing_declared_returns_none(self):
|
||||||
|
assert declared_min_version({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheck:
|
||||||
|
def test_compatible_when_nothing_declared(self):
|
||||||
|
assert check({}, "3.1.0") == (True, None)
|
||||||
|
|
||||||
|
def test_min_version_blocks_older_core(self):
|
||||||
|
manifest = {"name": "Test Plugin", "min_ledmatrix_version": "3.2.0"}
|
||||||
|
ok, reason = check(manifest, "3.1.0")
|
||||||
|
assert ok is False
|
||||||
|
assert "3.2.0" in reason and "3.1.0" in reason
|
||||||
|
|
||||||
|
def test_min_version_allows_equal_core(self):
|
||||||
|
manifest = {"min_ledmatrix_version": "3.1.0"}
|
||||||
|
assert check(manifest, "3.1.0") == (True, None)
|
||||||
|
|
||||||
|
def test_compatible_versions_upper_bound_blocks(self):
|
||||||
|
# A range is the only field that can express "not compatible with
|
||||||
|
# newer cores" — it must win even when the floor passes.
|
||||||
|
manifest = {
|
||||||
|
"name": "Old Plugin",
|
||||||
|
"min_ledmatrix_version": "2.0.0",
|
||||||
|
"compatible_versions": ["2.0.0 - 2.9.9"],
|
||||||
|
}
|
||||||
|
ok, reason = check(manifest, "3.1.0")
|
||||||
|
assert ok is False
|
||||||
|
assert "2.0.0 - 2.9.9" in reason
|
||||||
|
|
||||||
|
def test_unparseable_core_version_is_compatible(self):
|
||||||
|
manifest = {"min_ledmatrix_version": "3.2.0",
|
||||||
|
"compatible_versions": [">=3.2.0"]}
|
||||||
|
# An unparseable core version is "unknown", not "old"... but note
|
||||||
|
# parse_semver("garbage") == (0,0,0) which is below TRUSTWORTHY_FLOOR,
|
||||||
|
# so this rides the untrustworthy-core branch: floor > 2.0.0 blocks.
|
||||||
|
ok, reason = check(manifest, "garbage")
|
||||||
|
assert ok is False
|
||||||
|
assert "too old to identify reliably" in reason
|
||||||
|
|
||||||
|
def test_untrustworthy_core_allows_ecosystem_baseline_floor(self):
|
||||||
|
# A core reporting 1.0.0 may really be v3.1.0 (which shipped with a
|
||||||
|
# wrong __version__). Floors at or below TRUSTWORTHY_FLOOR must not
|
||||||
|
# block, or that population could install nothing.
|
||||||
|
manifest = {"min_ledmatrix_version": "2.0.0",
|
||||||
|
"compatible_versions": [">=2.0.0"]}
|
||||||
|
assert check(manifest, "1.0.0") == (True, None)
|
||||||
|
|
||||||
|
def test_untrustworthy_core_blocks_floor_above_baseline(self):
|
||||||
|
# But a floor above 2.0.0 needs modules that no core reporting below
|
||||||
|
# the floor can have — the one refusal on that branch.
|
||||||
|
manifest = {"name": "New Plugin", "min_ledmatrix_version": "3.2.0"}
|
||||||
|
ok, reason = check(manifest, "1.0.0")
|
||||||
|
assert ok is False
|
||||||
|
assert "too old to identify reliably" in reason
|
||||||
|
|
||||||
|
def test_untrustworthy_core_ignores_compatible_versions(self):
|
||||||
|
# On the untrustworthy branch only the declared floor is consulted;
|
||||||
|
# ranges cannot be evaluated against a version that isn't evidence.
|
||||||
|
manifest = {"compatible_versions": ["2.0.0 - 2.9.9"]}
|
||||||
|
assert check(manifest, "1.0.0") == (True, None)
|
||||||
|
|
||||||
|
def test_floor_exactly_at_trustworthy_floor_is_allowed(self):
|
||||||
|
floor = ".".join(str(n) for n in TRUSTWORTHY_FLOOR)
|
||||||
|
manifest = {"min_ledmatrix_version": floor}
|
||||||
|
assert check(manifest, "1.0.0") == (True, None)
|
||||||
|
|
||||||
|
def test_reason_uses_manifest_name(self):
|
||||||
|
manifest = {"name": "Fancy Clock", "min_ledmatrix_version": "9.0.0"}
|
||||||
|
ok, reason = check(manifest, "3.1.0")
|
||||||
|
assert ok is False
|
||||||
|
assert reason.startswith("Fancy Clock")
|
||||||
|
|
||||||
|
def test_reason_falls_back_to_id(self):
|
||||||
|
manifest = {"id": "fancy-clock", "min_ledmatrix_version": "9.0.0"}
|
||||||
|
ok, reason = check(manifest, "3.1.0")
|
||||||
|
assert ok is False
|
||||||
|
assert reason.startswith("fancy-clock")
|
||||||
|
|
||||||
|
def test_prerelease_core_compares_equal_to_release(self):
|
||||||
|
# Documented: prereleases compare equal to their release.
|
||||||
|
manifest = {"min_ledmatrix_version": "3.2.0"}
|
||||||
|
assert check(manifest, "3.2.0-rc1") == (True, None)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Tests for the ConfigManager secrets round-trip and the load_config fast path.
|
||||||
|
|
||||||
|
The contract under test: config_secrets.json values are deep-merged INTO the
|
||||||
|
in-memory config at load time, and stripped back OUT before anything is
|
||||||
|
written to config.json — so secrets live in exactly one file on disk. This
|
||||||
|
suite pins that round-trip plus its known sharp edges (some marked as
|
||||||
|
SUSPECTED BUG and characterized rather than fixed).
|
||||||
|
|
||||||
|
Complements test_config_manager.py, which covers loading/migration/validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config_manager import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(tmp_path, config=None, secrets=None):
|
||||||
|
"""A ConfigManager over tmp_path files, template migration neutralized."""
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
secrets_file = tmp_path / "config_secrets.json"
|
||||||
|
config_file.write_text(json.dumps(config if config is not None else {}))
|
||||||
|
if secrets is not None:
|
||||||
|
secrets_file.write_text(json.dumps(secrets))
|
||||||
|
manager = ConfigManager(config_path=str(config_file),
|
||||||
|
secrets_path=str(secrets_file))
|
||||||
|
# Point the (CWD-relative) template at nothing so migration never runs —
|
||||||
|
# these tests assert exact on-disk contents.
|
||||||
|
manager.template_path = str(tmp_path / "no-template.json")
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadMergesSecrets:
|
||||||
|
def test_secrets_deep_merged_into_config(self, tmp_path):
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={"weather": {"city": "Austin"}, "timezone": "UTC"},
|
||||||
|
secrets={"weather": {"api_key": "s3cret"}},
|
||||||
|
)
|
||||||
|
loaded = manager.load_config()
|
||||||
|
assert loaded["weather"] == {"city": "Austin", "api_key": "s3cret"}
|
||||||
|
assert loaded["timezone"] == "UTC"
|
||||||
|
|
||||||
|
def test_secret_scalar_overrides_config_value(self, tmp_path):
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={"weather": {"api_key": "YOUR_API_KEY"}},
|
||||||
|
secrets={"weather": {"api_key": "real-key"}},
|
||||||
|
)
|
||||||
|
assert manager.load_config()["weather"]["api_key"] == "real-key"
|
||||||
|
|
||||||
|
def test_missing_secrets_file_loads_config_fine(self, tmp_path):
|
||||||
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||||
|
assert manager.load_config() == {"timezone": "UTC"}
|
||||||
|
|
||||||
|
def test_corrupt_secrets_file_loads_config_without_secrets(self, tmp_path):
|
||||||
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||||
|
(tmp_path / "config_secrets.json").write_text("{not json")
|
||||||
|
loaded = manager.load_config()
|
||||||
|
assert loaded["timezone"] == "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveStripsSecrets:
|
||||||
|
def test_round_trip_keeps_secrets_out_of_config_json(self, tmp_path):
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={"weather": {"city": "Austin"}},
|
||||||
|
secrets={"weather": {"api_key": "s3cret"}},
|
||||||
|
)
|
||||||
|
loaded = manager.load_config()
|
||||||
|
assert loaded["weather"]["api_key"] == "s3cret" # merged in memory
|
||||||
|
|
||||||
|
manager.save_config(loaded)
|
||||||
|
|
||||||
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||||
|
assert "api_key" not in on_disk.get("weather", {})
|
||||||
|
assert on_disk["weather"]["city"] == "Austin"
|
||||||
|
# In-memory config still carries the secret for runtime use.
|
||||||
|
assert manager.config["weather"]["api_key"] == "s3cret"
|
||||||
|
|
||||||
|
def test_group_dropped_when_only_secrets_remain(self, tmp_path):
|
||||||
|
# _strip_secrets_recursive drops a group entirely when nothing
|
||||||
|
# non-secret is left in it.
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={},
|
||||||
|
secrets={"weather": {"api_key": "s3cret"}},
|
||||||
|
)
|
||||||
|
manager.save_config({"weather": {"api_key": "s3cret"}, "timezone": "UTC"})
|
||||||
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||||
|
assert on_disk == {"timezone": "UTC"}
|
||||||
|
|
||||||
|
def test_scalar_secret_key_stripped_at_top_level(self, tmp_path):
|
||||||
|
manager = make_manager(tmp_path, config={}, secrets={"token": "t"})
|
||||||
|
manager.save_config({"token": "t", "timezone": "UTC"})
|
||||||
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||||
|
assert on_disk == {"timezone": "UTC"}
|
||||||
|
|
||||||
|
def test_unreadable_secrets_file_writes_secrets_to_config_json(self, tmp_path):
|
||||||
|
# SUSPECTED BUG (characterized, not fixed): when the secrets file is
|
||||||
|
# unreadable/corrupt at save time, save_config proceeds without
|
||||||
|
# stripping — writing the merged secrets into config.json in
|
||||||
|
# plaintext. The code comments acknowledge the tradeoff (it prevents
|
||||||
|
# data loss); this test pins the behavior so any future change to it
|
||||||
|
# is deliberate.
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={"weather": {"city": "Austin"}},
|
||||||
|
secrets={"weather": {"api_key": "s3cret"}},
|
||||||
|
)
|
||||||
|
loaded = manager.load_config()
|
||||||
|
(tmp_path / "config_secrets.json").write_text("{corrupt")
|
||||||
|
|
||||||
|
manager.save_config(loaded)
|
||||||
|
|
||||||
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||||
|
assert on_disk["weather"].get("api_key") == "s3cret" # leaked
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadFastPath:
|
||||||
|
def test_unchanged_files_return_cached_dict(self, tmp_path):
|
||||||
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||||
|
first = manager.load_config()
|
||||||
|
second = manager.load_config()
|
||||||
|
assert second is first # same aliased dict, no re-read
|
||||||
|
|
||||||
|
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
||||||
|
manager = make_manager(
|
||||||
|
tmp_path,
|
||||||
|
config={"weather": {}},
|
||||||
|
secrets={"weather": {"api_key": "old"}},
|
||||||
|
)
|
||||||
|
assert manager.load_config()["weather"]["api_key"] == "old"
|
||||||
|
|
||||||
|
secrets_file = tmp_path / "config_secrets.json"
|
||||||
|
secrets_file.write_text(json.dumps({"weather": {"api_key": "new"}}))
|
||||||
|
# Force a different mtime_ns in case the write landed within the
|
||||||
|
# filesystem's timestamp granularity.
|
||||||
|
os.utime(secrets_file, ns=(1, 1))
|
||||||
|
|
||||||
|
assert manager.load_config()["weather"]["api_key"] == "new"
|
||||||
|
|
||||||
|
def test_same_mtime_same_size_change_served_stale(self, tmp_path):
|
||||||
|
# Characterized fast-path blind spot: the signature is (mtime_ns,
|
||||||
|
# size) only, so a same-length content swap with a forged identical
|
||||||
|
# mtime is not detected. Real writes bump mtime_ns, so this is
|
||||||
|
# acceptable — but it is a contract worth pinning.
|
||||||
|
manager = make_manager(tmp_path, config={"timezone": "AAA"})
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
manager._loaded_sig = None
|
||||||
|
first = manager.load_config()
|
||||||
|
assert first["timezone"] == "AAA"
|
||||||
|
|
||||||
|
config_file.write_text(json.dumps({"timezone": "BBB"})) # same length
|
||||||
|
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
|
||||||
|
assert manager.load_config()["timezone"] == "AAA" # stale, by design
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Tests for SchemaManager.merge_with_defaults — the merge every plugin config
|
||||||
|
passes through at load time (schema defaults + user config, with None
|
||||||
|
replacement). A regression here silently changes every plugin's effective
|
||||||
|
config, so the exact branch behavior is pinned, including the
|
||||||
|
characterized type-mismatch cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.schema_manager import SchemaManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sm(tmp_path):
|
||||||
|
return SchemaManager(plugins_dir=str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicMerge:
|
||||||
|
def test_missing_keys_filled_from_defaults(self, sm):
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"city": "Austin"}, {"city": "NYC", "units": "metric"})
|
||||||
|
assert merged == {"city": "Austin", "units": "metric"}
|
||||||
|
|
||||||
|
def test_present_keys_preserved(self, sm):
|
||||||
|
merged = sm.merge_with_defaults({"enabled": False}, {"enabled": True})
|
||||||
|
assert merged["enabled"] is False
|
||||||
|
|
||||||
|
def test_nested_three_level_merge(self, sm):
|
||||||
|
config = {"a": {"b": {"c": 1}}}
|
||||||
|
defaults = {"a": {"b": {"c": 0, "d": 2}, "e": 3}}
|
||||||
|
merged = sm.merge_with_defaults(config, defaults)
|
||||||
|
assert merged == {"a": {"b": {"c": 1, "d": 2}, "e": 3}}
|
||||||
|
|
||||||
|
def test_inputs_not_mutated(self, sm):
|
||||||
|
config = {"a": {"b": 1}}
|
||||||
|
defaults = {"a": {"b": 0, "c": 2}, "d": 3}
|
||||||
|
sm.merge_with_defaults(config, defaults)
|
||||||
|
assert config == {"a": {"b": 1}}
|
||||||
|
assert defaults == {"a": {"b": 0, "c": 2}, "d": 3}
|
||||||
|
|
||||||
|
def test_merged_values_are_copies_not_aliases(self, sm):
|
||||||
|
config = {"teams": ["DAL"]}
|
||||||
|
merged = sm.merge_with_defaults(config, {"teams": []})
|
||||||
|
merged["teams"].append("HOU")
|
||||||
|
assert config["teams"] == ["DAL"] # user's list untouched
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoneReplacement:
|
||||||
|
def test_none_replaced_by_default(self, sm):
|
||||||
|
merged = sm.merge_with_defaults({"units": None}, {"units": "metric"})
|
||||||
|
assert merged["units"] == "metric"
|
||||||
|
|
||||||
|
def test_falsey_non_none_values_kept(self, sm):
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"enabled": False, "count": 0, "label": ""},
|
||||||
|
{"enabled": True, "count": 5, "label": "x"},
|
||||||
|
)
|
||||||
|
assert merged == {"enabled": False, "count": 0, "label": ""}
|
||||||
|
|
||||||
|
def test_nested_none_replaced(self, sm):
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"style": {"color": None}}, {"style": {"color": "red"}})
|
||||||
|
assert merged["style"]["color"] == "red"
|
||||||
|
|
||||||
|
def test_none_with_no_default_stays_none(self, sm):
|
||||||
|
merged = sm.merge_with_defaults({"extra": None}, {})
|
||||||
|
assert merged["extra"] is None
|
||||||
|
|
||||||
|
def test_none_replaced_by_dict_default_is_a_copy(self, sm):
|
||||||
|
defaults = {"style": {"color": "red"}}
|
||||||
|
merged = sm.merge_with_defaults({"style": None}, defaults)
|
||||||
|
assert merged["style"] == {"color": "red"}
|
||||||
|
merged["style"]["color"] = "blue"
|
||||||
|
assert defaults["style"]["color"] == "red"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeMismatches:
|
||||||
|
def test_user_scalar_over_dict_default_wins(self, sm):
|
||||||
|
# Characterized: a scalar user value replaces a dict default outright.
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"style": "compact"}, {"style": {"color": "red"}})
|
||||||
|
assert merged["style"] == "compact"
|
||||||
|
|
||||||
|
def test_user_dict_over_scalar_default_wins(self, sm):
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"style": {"color": "red"}}, {"style": "compact"})
|
||||||
|
assert merged["style"] == {"color": "red"}
|
||||||
|
|
||||||
|
def test_arrays_replaced_wholesale_not_merged(self, sm):
|
||||||
|
# Pinned contract: arrays never element-merge — the user's array is
|
||||||
|
# the whole answer, even when shorter than the default.
|
||||||
|
merged = sm.merge_with_defaults(
|
||||||
|
{"teams": ["DAL"]}, {"teams": ["NYG", "PHI", "WAS"]})
|
||||||
|
assert merged["teams"] == ["DAL"]
|
||||||
|
|
||||||
|
def test_empty_user_array_beats_default(self, sm):
|
||||||
|
merged = sm.merge_with_defaults({"teams": []}, {"teams": ["NYG"]})
|
||||||
|
assert merged["teams"] == []
|
||||||
|
|
||||||
|
def test_extra_user_keys_survive(self, sm):
|
||||||
|
# Keys with no schema default pass through untouched.
|
||||||
|
merged = sm.merge_with_defaults({"custom_flag": 7}, {"known": 1})
|
||||||
|
assert merged == {"known": 1, "custom_flag": 7}
|
||||||
@@ -450,3 +450,112 @@ class TestExampleSkin:
|
|||||||
ctx = skin_runtime.build_context(host, game, size=size)
|
ctx = skin_runtime.build_context(host, game, size=size)
|
||||||
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
||||||
assert ctx.canvas.convert("L").getbbox() is not None
|
assert ctx.canvas.convert("L").getbbox() is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderSkinCard:
|
||||||
|
"""render_skin_card (vegas cards) shares _render_game's 3-strike counter.
|
||||||
|
|
||||||
|
The asymmetry pinned here: _render_game resets the counter on success,
|
||||||
|
render_skin_card does NOT — card successes never clear strikes, so
|
||||||
|
failures accumulated across card renders (however far apart) still
|
||||||
|
disable the skin for the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _probe(self, skin):
|
||||||
|
from src.base_classes.sports import SportsCore
|
||||||
|
probe = _FallbackProbe(skin)
|
||||||
|
probe.render_skin_card = (
|
||||||
|
lambda game, size: SportsCore.render_skin_card(probe, game, size))
|
||||||
|
return probe
|
||||||
|
|
||||||
|
def test_vegas_card_returned_when_skin_provides_one(self):
|
||||||
|
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
||||||
|
|
||||||
|
class CardSkin(ScoreboardSkin):
|
||||||
|
def render_vegas_card(self, ctx, game):
|
||||||
|
return card_img
|
||||||
|
|
||||||
|
probe = self._probe(CardSkin({}, {}))
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is card_img
|
||||||
|
|
||||||
|
def test_vegas_card_none_falls_through_to_mode_renderer(self):
|
||||||
|
class ModeOnlySkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx, game):
|
||||||
|
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
|
||||||
|
return True
|
||||||
|
|
||||||
|
probe = self._probe(ModeOnlySkin({}, {}))
|
||||||
|
card = probe.render_skin_card({}, (96, 32))
|
||||||
|
assert card is not None
|
||||||
|
assert card.size == (96, 32)
|
||||||
|
assert card.convert("L").getbbox() is not None
|
||||||
|
|
||||||
|
def test_skin_declining_returns_none(self):
|
||||||
|
probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is None
|
||||||
|
assert probe._skin_failures == 0 # declining is not a failure
|
||||||
|
|
||||||
|
def test_no_skin_returns_none(self):
|
||||||
|
probe = self._probe(None)
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is None
|
||||||
|
|
||||||
|
def test_card_failures_count_toward_shared_disable(self):
|
||||||
|
class BrokenCardSkin(ScoreboardSkin):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def render_vegas_card(self, ctx, game):
|
||||||
|
BrokenCardSkin.calls += 1
|
||||||
|
raise ValueError("kaboom")
|
||||||
|
|
||||||
|
probe = self._probe(BrokenCardSkin({}, {}))
|
||||||
|
for _ in range(5):
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is None
|
||||||
|
# Skin stopped being consulted after the 3rd failure...
|
||||||
|
assert BrokenCardSkin.calls == 3
|
||||||
|
assert probe._skin_failures == 3
|
||||||
|
# ...and the shared counter also disables _render_game's skin path.
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
assert probe.builtin_calls == 1
|
||||||
|
assert BrokenCardSkin.calls == 3 # not consulted again
|
||||||
|
|
||||||
|
def test_card_success_does_not_reset_strikes(self):
|
||||||
|
"""Characterized asymmetry: unlike _render_game (which resets the
|
||||||
|
counter on success, core.py _render_game), a successful card render
|
||||||
|
leaves accumulated strikes in place — 2 failures + N successes + 1
|
||||||
|
failure still disables the skin."""
|
||||||
|
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
||||||
|
|
||||||
|
class FlakyCardSkin(ScoreboardSkin):
|
||||||
|
fail = True
|
||||||
|
|
||||||
|
def render_vegas_card(self, ctx, game):
|
||||||
|
if FlakyCardSkin.fail:
|
||||||
|
raise ValueError("kaboom")
|
||||||
|
return card_img
|
||||||
|
|
||||||
|
probe = self._probe(FlakyCardSkin({}, {}))
|
||||||
|
FlakyCardSkin.fail = True
|
||||||
|
probe.render_skin_card({}, (96, 32))
|
||||||
|
probe.render_skin_card({}, (96, 32))
|
||||||
|
assert probe._skin_failures == 2
|
||||||
|
|
||||||
|
FlakyCardSkin.fail = False
|
||||||
|
for _ in range(10):
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is card_img
|
||||||
|
assert probe._skin_failures == 2 # successes did NOT clear strikes
|
||||||
|
|
||||||
|
FlakyCardSkin.fail = True
|
||||||
|
probe.render_skin_card({}, (96, 32))
|
||||||
|
assert probe._skin_failures == 3
|
||||||
|
assert probe.render_skin_card({}, (96, 32)) is None # disabled
|
||||||
|
|
||||||
|
def test_render_game_success_does_reset_strikes(self):
|
||||||
|
"""The other half of the asymmetry, for contrast with the above."""
|
||||||
|
class GoodSkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx, game):
|
||||||
|
return True
|
||||||
|
|
||||||
|
probe = _FallbackProbe(GoodSkin({}, {}))
|
||||||
|
probe._skin_failures = 2
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
assert probe._skin_failures == 0
|
||||||
|
|||||||
@@ -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