"""Guards against plugin settings outliving the plugin they configure. The `http` plugin was folded into the unified Monitor entity, but its `plugin.http` DEFAULTS entry survived — so the operator saw a plugin that no longer exists reported as enabled, with no way to clear it (deleting the row did nothing; the default reasserted it). These tests make that class of drift a CI failure instead of a support question. """ from pathlib import Path from steward.core.settings import DEFAULTS, find_orphaned_plugins from steward.settings.routes import CAPABILITY_PLUGINS BUNDLED_PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins" def _bundled_plugin_names() -> set[str]: """Plugin dirs shipped inside the image, identified by their plugin.yaml.""" return { entry.name for entry in BUNDLED_PLUGINS_DIR.iterdir() if entry.is_dir() and (entry / "plugin.yaml").exists() } def _default_plugin_names() -> set[str]: prefix = "plugin." return {k[len(prefix):] for k in DEFAULTS if k.startswith(prefix)} def test_bundled_plugins_dir_is_discoverable(): # Guards the test itself: a wrong path would make everything below vacuous. assert _bundled_plugin_names(), f"no bundled plugins found under {BUNDLED_PLUGINS_DIR}" def test_every_default_plugin_key_has_a_real_bundled_plugin(): """A plugin.* default naming a non-existent plugin is always a bug. Bundled plugins ship in the image and version atomically with core, so unlike external plugins they cannot be transiently missing — there is no benign reason for this to fail. """ missing = _default_plugin_names() - _bundled_plugin_names() assert not missing, ( f"DEFAULTS declares plugin(s) with no bundled directory: {sorted(missing)}. " f"If the plugin was removed, delete its plugin. key from DEFAULTS " f"and add a migration dropping the stored row." ) def test_http_plugin_default_is_gone(): # Explicit regression: this exact key is what the operator hit. assert "plugin.http" not in DEFAULTS def test_capability_plugins_all_exist(): """CAPABILITY_PLUGINS classifies discovered plugins; stale names are dead weight.""" missing = CAPABILITY_PLUGINS - _bundled_plugin_names() assert not missing, f"CAPABILITY_PLUGINS names non-existent plugin(s): {sorted(missing)}" # ── orphan detection ───────────────────────────────────────────────────────── def test_orphans_are_stored_names_with_no_installed_plugin(): rows = find_orphaned_plugins(["docker", "traefik", "ancient"], ["docker", "traefik"]) assert [r["name"] for r in rows] == ["ancient"] assert rows[0]["removable"] is True assert rows[0]["reason"] is None def test_no_orphans_when_everything_is_installed(): assert find_orphaned_plugins(["docker", "snmp"], ["docker", "snmp"]) == [] def test_installed_plugin_without_stored_settings_is_not_an_orphan(): # Never configured is not the same as left behind. assert find_orphaned_plugins([], ["docker"]) == [] def test_orphans_are_sorted_and_skip_empty_names(): rows = find_orphaned_plugins(["zeta", "", "alpha"], []) assert [r["name"] for r in rows] == ["alpha", "zeta"] def test_accepts_arbitrary_iterables(): # Callers pass a generator of discovered dirs, not a list. rows = find_orphaned_plugins((n for n in ["gone"]), (n for n in ["here"])) assert [r["name"] for r in rows] == ["gone"] # ── load failures folded in (issue #2638) ──────────────────────────────────── def test_failure_reason_is_attached_to_its_row(): rows = find_orphaned_plugins( ["ancient"], [], {"ancient": "Plugin directory not found in: ['/app/plugins']"}) assert rows[0]["reason"] == "Plugin directory not found in: ['/app/plugins']" def test_failure_with_no_stored_row_still_gets_a_row(): """The banner counts it, so the page must show it — that was the dead end. A plugin enabled by a DEFAULTS entry has no stored row, so a stored-only view would leave the banner pointing at a page with nothing on it. """ rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"}) assert [r["name"] for r in rows] == ["http"] assert rows[0]["reason"] == "Plugin directory not found" def test_failure_with_no_stored_row_is_not_removable(): # Offering Remove would delete nothing while the default reasserts it. rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"}) assert rows[0]["removable"] is False def test_failure_of_an_installed_plugin_is_not_listed_here(): # A discovered plugin that failed already shows the reason on its own card. assert find_orphaned_plugins( ["docker"], ["docker"], {"docker": "boom"}) == [] def test_stored_orphan_and_failure_are_not_duplicated(): rows = find_orphaned_plugins(["gone"], [], {"gone": "Plugin directory not found"}) assert len(rows) == 1 assert rows[0]["removable"] is True and rows[0]["reason"] def test_every_undiscovered_failure_is_represented(): # The invariant that keeps the banner count and this list in agreement. failures = {"a": "x", "b": "y", "docker": "z"} rows = find_orphaned_plugins([], ["docker"], failures) listed = {r["name"] for r in rows} assert listed == {"a", "b"}