"""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_plugin_names 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(): assert find_orphaned_plugin_names( ["docker", "traefik", "ancient"], ["docker", "traefik"] ) == ["ancient"] def test_no_orphans_when_everything_is_installed(): assert find_orphaned_plugin_names(["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_plugin_names([], ["docker"]) == [] def test_orphans_are_sorted_and_deduped_of_empties(): assert find_orphaned_plugin_names(["zeta", "", "alpha"], []) == ["alpha", "zeta"] def test_accepts_arbitrary_iterables(): # Callers pass a generator of discovered dirs, not a list. assert find_orphaned_plugin_names( (n for n in ["gone"]), (n for n in ["here"]) ) == ["gone"]