test: add drift guards for cross-file contracts

Three guard suites that pin contracts spanning multiple files, where one
side changing unilaterally breaks the other silently:

- test_version_comparison_consistency.py: the repo's four version
  comparators (compatibility.parse_semver, api_v3's packaging-based
  _is_plugin_update_available, store_manager update_plugin's raw string
  equality, skin_runtime._major) answer differently on the same inputs.
  A table pins each one's verdict; update_plugin is driven through its
  real code path to show the SUSPECTED BUGs: 'v1.2.0' vs '1.2.0'
  triggers a full reinstall the UI calls unnecessary, and a locally-ahead
  plugin gets downgraded. A pairwise-ordering check keeps parse_semver
  agreeing with packaging on plain X.Y.Z.
- test/web_interface/test_secret_separation_parity.py: api_v3.py carries
  three inline copies of find_secret_fields/separate_secrets that lack
  the canonical module's array-item support. The copy count is asserted
  exact (it may only go down; new copies must import
  src/web_interface/secret_helpers), the missing-array-support gap is
  asserted so it can't grow silently, and the canonical behavior that
  migration will adopt is documented executably.
- test_discovery_path_contract.py: the three 'where is plugin X'
  resolvers (PluginManager discovery, StoreManager._find_plugin_path,
  SchemaManager.get_schema_path) agree on the configured directory, and
  their divergent fallback chains are characterized. Also pins the
  .standalone-backup- naming contract shared by store rollback and
  discovery, and _resolve_skin_target's path-traversal rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
Claude
2026-08-06 22:31:03 +00:00
parent dfba88b599
commit b8b0c8e2e2
3 changed files with 433 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
"""
Drift guard: the repo has FOUR version-comparison implementations, and they
do not agree. This file pins each one's answer on the same inputs so any
future change to one of them (or a fifth copy appearing) surfaces here.
The four:
1. src/plugin_system/compatibility.py — parse_semver / tuple comparison
(install gate).
2. web_interface/blueprints/api_v3.py — _is_plugin_update_available, uses
packaging.version (update badge in the UI).
3. src/plugin_system/store_manager.py — update_plugin's raw STRING EQUALITY
for monorepo plugins ("local_version == remote_version").
4. src/skin_system/skin_runtime.py — _major, int(major) gate for the
skin API.
SUSPECTED BUG (characterized here, not fixed): #3 disagrees with #2. For
"v1.2.0" vs "1.2.0" the UI says "no update available" while update_plugin
performs a full reinstall; for a locally-ahead plugin ("2.0.0" installed,
registry "1.9.0") the UI says no update but update_plugin DOWNGRADES via
reinstall. Unifying on one comparator is tracked follow-up work; when that
lands, the expectations in TestStoreManagerStringEquality flip and this
file is the reminder to update them deliberately.
"""
import json
from unittest.mock import patch
import pytest
from packaging.version import parse as pkg_parse
from src.plugin_system.compatibility import parse_semver
from src.skin_system.skin_runtime import _major
from src.plugin_system.store_manager import PluginStoreManager
from web_interface.blueprints.api_v3 import _is_plugin_update_available
# (installed, registry) pairs and what each comparator concludes.
CASES = [
# pair parse_semver equal? api_v3 update? store equal-string?
(("1.2.0", "1.2.0"), True, False, True),
(("v1.2.0", "1.2.0"), True, False, False),
(("1.2", "1.2.0"), True, False, False),
(("1.2.0", "1.2.0-rc1"), True, False, False),
(("1.2.0", "1.3.0"), False, True, False),
(("2.0.0", "1.9.0"), False, False, False),
]
class TestComparatorMatrix:
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES)
def test_parse_semver_equality(self, pair, semver_equal, api_update, store_equal):
a, b = pair
assert (parse_semver(a) == parse_semver(b)) is semver_equal
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES)
def test_api_v3_update_available(self, pair, semver_equal, api_update, store_equal):
installed, latest = pair
assert _is_plugin_update_available(installed, latest) is api_update
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES)
def test_store_manager_string_equality(self, pair, semver_equal, api_update, store_equal):
# The literal comparison update_plugin performs at its
# "already at latest version" check.
a, b = pair
assert (a == b) is store_equal
class TestStoreManagerStringEquality:
"""Drive update_plugin's real code path to its version check."""
def _store(self, tmp_path, local_version, registry_version):
plugin_dir = tmp_path / "plugins" / "demo-plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "demo-plugin", "version": local_version,
}))
store = PluginStoreManager(
plugins_dir=str(tmp_path / "plugins"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"),
)
registry_info = {
"id": "demo-plugin",
"repo": "https://github.com/example/ledmatrix-plugins",
"latest_version": registry_version,
}
return store, registry_info
def _run_update(self, store, registry_info):
with patch.object(store, "fetch_registry", return_value={"plugins": [registry_info]}), \
patch.object(store, "get_plugin_info", return_value=registry_info), \
patch.object(store, "_reinstall_with_rollback", return_value=True) as reinstall:
result = store.update_plugin("demo-plugin")
return result, reinstall
def test_equal_strings_skip_reinstall(self, tmp_path):
store, info = self._store(tmp_path, "1.2.0", "1.2.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_v_prefix_triggers_reinstall_despite_semantic_equality(self, tmp_path):
# SUSPECTED BUG: packaging (and api_v3) treat these as equal; the
# string comparison does not, so the user gets a full reinstall.
store, info = self._store(tmp_path, "v1.2.0", "1.2.0")
result, reinstall = self._run_update(store, info)
reinstall.assert_called_once()
assert result is True
def test_locally_ahead_version_triggers_downgrade_reinstall(self, tmp_path):
# SUSPECTED BUG: a plugin ahead of the registry (local dev build) is
# "updated" — i.e. downgraded — because inequality is the only test.
store, info = self._store(tmp_path, "2.0.0", "1.9.0")
result, reinstall = self._run_update(store, info)
reinstall.assert_called_once()
assert result is True
class TestSkinRuntimeMajor:
def test_plain_versions(self):
assert _major("1.0.0") == 1
assert _major("2.1") == 2
def test_int_input_tolerated(self):
assert _major(2) == 2
def test_garbage_returns_none(self):
assert _major("garbage") is None
assert _major(None) is None
def test_v_prefix_not_tolerated(self):
# Unlike parse_semver, _major does NOT strip a leading 'v' —
# a skin.json declaring "v1.0.0" fails the API gate. Characterized
# so a manifest-format loosening elsewhere doesn't silently diverge.
assert _major("v1.0.0") is None
class TestParseSemverAgreesWithPackaging:
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
the region where the two ecosystems overlap and must never diverge."""
PLAIN = ["0.1.0", "1.0.0", "1.2.0", "1.2.3", "1.10.0", "2.0.0", "10.0.1"]
def test_pairwise_ordering_matches(self):
for a in self.PLAIN:
for b in self.PLAIN:
ours = parse_semver(a) < parse_semver(b)
theirs = pkg_parse(a) < pkg_parse(b)
assert ours == theirs, f"ordering diverges on ({a}, {b})"