538+570+296+216 lines of change-detectors → 4 files of contract tests: seed catalog valid, bad entries skipped, kill list name-or-repo, live fallback+union; real-git pinned install + sidecar + re-pin; kill list blocks CLI/dashboard/TUI with only the CLI bypass; dashboard merge via sidecar; probe get_config default; extractor live document.
62 lines
2.9 KiB
Python
62 lines
2.9 KiB
Python
"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are
|
|
skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import yaml
|
|
|
|
from hermes_cli import plugin_catalog as pc
|
|
|
|
SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
|
|
|
|
|
|
def _entry(name="good-plugin", **over):
|
|
data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d",
|
|
"maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}}
|
|
data.update(over)
|
|
return data
|
|
|
|
|
|
def test_shipped_catalog_entries_are_all_valid_and_pinned():
|
|
"""Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent
|
|
shipping regression the admission CI only catches on changed files."""
|
|
root = pc.get_catalog_dir()
|
|
files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"]
|
|
entries = pc.load_catalog()
|
|
assert len(entries) == len(files) >= 1
|
|
assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries)
|
|
assert all(e.install_identifier.startswith(e.repo) for e in entries)
|
|
|
|
|
|
def test_invalid_entries_are_skipped_not_raised(tmp_path):
|
|
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok")))
|
|
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
|
|
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y")))
|
|
(tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name")))
|
|
(tmp_path / "e.yaml").write_text("- not\n- a mapping\n")
|
|
assert [e.name for e in pc.load_catalog(tmp_path)] == ["ok"]
|
|
|
|
|
|
def test_find_removed_matches_name_or_normalized_repo(tmp_path):
|
|
(tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [
|
|
{"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]}))
|
|
assert pc.find_removed("evil", tmp_path).reason == "malware"
|
|
assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None
|
|
assert pc.find_removed("https://github.com/x/fine", tmp_path) is None
|
|
|
|
|
|
def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch):
|
|
"""Network failure → in-tree entries; a cached live doc contributes entries AND removals."""
|
|
cache = tmp_path / "cache" / "plugin-catalog.json"
|
|
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
|
|
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable
|
|
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
|
|
|
|
cache.parent.mkdir(parents=True)
|
|
cache.write_text(json.dumps({"entries": [_entry("live-only")],
|
|
"removed": [{"name": "pulled-live", "reason": "cve"}]}))
|
|
assert [e.name for e in pc.load_catalog_live()] == ["live-only"]
|
|
assert pc.find_removed("pulled-live").reason == "cve"
|