Files
hermes-agent/tests/hermes_cli/test_plugins_check_updates.py

550 lines
18 KiB
Python

"""Tests: check-updates — address resolution, saved-tag security, feeds.
All network seams injected (fetch, ls-remote, PyPI probe) — hermetic.
The local bare-repo fixture exercises the REAL git ls-remote command
shape without network.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
from hermes_cli.plugins_updates import (
CheckResult,
check_pip_plugins,
check_provenanced,
parse_feed_yml,
run_checks,
)
def _prov(
klass=ProvenanceClass.GIT,
row=None,
path=None,
):
return Provenance(
name="plug",
klass=klass,
path=path or Path("/x/plug"),
row=row or {},
)
def _git_prov(**row):
row.setdefault("pinned", False)
row.setdefault("revision", "a" * 40)
row.setdefault("source", "https://example/o/r")
return _prov(ProvenanceClass.GIT, row=row)
# ── local git, one resolver for every repo-spawning test ────────────
def _git_exe():
"""Conventional git from PATH — tests run with Git for Windows' cmd
dir leading PATH, never the MSIX payload (spawn denial, WinError 5)."""
return shutil.which("git") or "git"
def _git_env():
return {k: v for k, v in os.environ.items() if k != "GIT_DIR"}
def _local_repo(path: Path, *init_args):
"""A real local git repo; returns (run, head) bound to it."""
git = _git_exe()
env = _git_env()
def run(*args):
subprocess.run(
[git, *args], cwd=path, check=True, capture_output=True, env=env
)
def head():
return subprocess.run(
[git, "rev-parse", "HEAD"], cwd=path, check=True,
capture_output=True, text=True, env=env,
).stdout.strip()
path.mkdir(parents=True)
run("init", "-q", *init_args)
run("-c", "user.email=t@e", "-c", "user.name=t",
"commit", "--allow-empty", "-qm", "one")
return run, head
# ── provenance-class short-circuits ────────────────────────────────
def test_manual_reports_not_updatable():
r = check_provenanced(_prov(ProvenanceClass.MANUAL), fetch=_no, ls_remote=_no)
assert r.update_available is None
assert "not auto-updatable" in r.reason
def test_drift_reports_reinstall_remedy():
r = check_provenanced(
_prov(ProvenanceClass.DRIFT, row={"source": "https://x/y"}),
fetch=_no, ls_remote=_no,
)
assert "reinstall" in r.reason
def test_self_cloned_reports_adopt():
r = check_provenanced(_prov(ProvenanceClass.SELF_CLONED), fetch=_no, ls_remote=_no)
assert "adopt" in r.reason
def _no(*a, **k):
raise AssertionError("should not be called")
# ── pinned ──────────────────────────────────────────────────────────
def test_pinned_never_auto_moves():
r = check_provenanced(_git_prov(pinned=True), fetch=_no, ls_remote=_no)
assert r.update_available is False
assert "pinned" in r.reason
# ── the saved-tag security heart ────────────────────────────────────
def test_url_appearing_where_none_saved_is_needs_fixing(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://evil.example/feed.yml\n",
encoding="utf-8",
)
prov = _git_prov() # no update_url in the row
prov.path = plug
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
assert r.needs_fixing and "trust-update-url" in r.needs_fixing
assert r.update_available is None # refused, not unknown
def test_url_mismatch_is_needs_fixing(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://new.example/feed.yml\n",
encoding="utf-8",
)
prov = _git_prov(update_url="https://old.example/feed.yml")
prov.path = plug
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
assert "mismatch" in r.needs_fixing
assert "trust-update-url" in r.needs_fixing
# ── feed path (matching tag) ────────────────────────────────────────
FEED = """\
version: 1.2.0
released: 2026-09-03T00:00:00Z
min_hermes: 0.27.0
artifacts:
git: https://example/o/r
bundle: https://example/o/r/plug-1.2.0.zip
bundle_sha256: abc123
"""
def test_matching_tag_fetches_feed(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nversion: 1.0.0\nupdate_url: https://feed.example/f.yml\n",
encoding="utf-8",
)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
fetched = []
def fetch(url):
fetched.append(url)
return FEED
r = check_provenanced(prov, fetch=fetch, ls_remote=_no)
assert fetched == ["https://feed.example/f.yml"]
assert r.latest == "1.2.0"
assert r.current == "1.0.0"
assert r.min_hermes == "0.27.0"
assert r.update_available is True # installed 1.0.0 vs feed 1.2.0
def test_feed_version_with_no_installed_version_is_unknown(tmp_path):
"""A semantic feed vs a versionless manifest cannot be compared — report
unknown, never a revision-vs-version inequality."""
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: FEED, ls_remote=_no)
assert r.update_available is None
assert r.latest == "1.2.0"
def test_feed_git_sha_artifact_compares_sha_to_sha(tmp_path):
"""C17 like-for-like: a full git SHA in the feed's artifacts.git is
compared against the recorded revision, never to a semantic version —
and both reported fields are the shas actually compared."""
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
encoding="utf-8",
)
feed = "version: 1.2.0\nartifacts:\n git: %s\n" % ("c" * 40)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
assert r.update_available is True # c*40 != a*40
assert r.current == "a" * 40
assert r.latest == "c" * 40
prov2 = _git_prov(update_url="https://feed.example/f.yml", revision="c" * 40)
prov2.path = plug
r2 = check_provenanced(prov2, fetch=lambda u: feed, ls_remote=_no)
assert r2.update_available is False # SHA vs SHA: converged
assert r2.current == "c" * 40
assert r2.latest == "c" * 40
def test_feed_git_sha_case_equivalent_is_not_an_update(tmp_path):
"""Hex identity is case-insensitive — but only after format validation."""
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
feed = "version: 1.2.0\nartifacts:\n git: %s\n" % ("C" * 40)
prov = _git_prov(update_url="https://feed.example/f.yml", revision="c" * 40)
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
assert r.update_available is False
def test_feed_git_sha_with_no_recorded_revision_is_unknown(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
encoding="utf-8",
)
feed = "version: 1.2.0\nartifacts:\n git: %s\n" % ("c" * 40)
prov = _git_prov(update_url="https://feed.example/f.yml", revision="")
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
assert r.update_available is None
assert "no full revision sha" in r.reason
def test_feed_git_sha_against_tagged_revision_is_unknown(tmp_path):
"""A recorded tag is not a full sha — never compared by inequality."""
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
encoding="utf-8",
)
feed = "version: 1.2.0\nartifacts:\n git: %s\n" % ("c" * 40)
prov = _git_prov(
update_url="https://feed.example/f.yml", revision="v1.2.0"
)
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
assert r.update_available is None
def test_malformed_yaml_feed_reports_unknown_row(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text("update_url: https://feed.example/f.yml\n", encoding="utf-8")
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
result = check_provenanced(prov, fetch=lambda _: "version: [", ls_remote=_no)
assert result.update_available is None
assert "unparseable" in result.reason
def test_parse_feed_rejects_non_string_git_artifact():
feed = "version: 1.2.0\nartifacts:\n git:\n url: x\n"
with pytest.raises(ValueError):
parse_feed_yml(feed)
def test_ls_remote_lifecycle_current_available_applied(tmp_path, monkeypatch):
"""The git-path gate, end to end against a real local repo: current ->
ls-remote sees a new HEAD -> revision recorded -> current. No network,
no manual manifest rewriting — every comparison is the real command."""
repo = tmp_path / "repo"
run, head = _local_repo(repo, "-b", "main")
sha1 = head()
from hermes_cli.plugins_updates import default_ls_remote
monkeypatch.setattr('hermes_cli.plugins_cmd._resolve_git_executable', _git_exe)
ls_remote = default_ls_remote
plug = tmp_path / "plug"
plug.mkdir()
(plug / ".git").mkdir()
prov = _git_prov(revision=sha1, source=str(repo))
prov.path = plug
def check():
return check_provenanced(prov, fetch=_no, ls_remote=ls_remote)
assert check().update_available is False # current
run("-c", "user.email=t@e", "-c", "user.name=t",
"commit", "--allow-empty", "-qm", "two")
sha2 = head()
assert sha2 != sha1
r = check()
assert r.update_available is True # available
assert r.latest == sha2
# once the update pipeline has recorded the new revision, converged
prov3 = _git_prov(revision=sha2, source=str(repo))
prov3.path = plug
assert check_provenanced(prov3, fetch=_no, ls_remote=ls_remote).update_available is False
def test_feed_fetch_failure_is_row_level_reason(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
def boom(url):
raise OSError("timeout")
r = check_provenanced(prov, fetch=boom, ls_remote=_no)
assert r.reason.startswith("feed fetch failed")
assert r.update_available is None
# ── ls-remote fallback (no update_url anywhere) ─────────────────────
def test_ls_remote_fallback(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
prov = _git_prov() # no update_url in row or manifest
prov.path = plug
calls = []
def ls(source):
calls.append(source)
return "b" * 40
r = check_provenanced(prov, fetch=_no, ls_remote=ls)
assert calls == ["https://example/o/r"]
assert r.update_available is True # b != a
# ── the real git ls-remote, against a local bare repo ───────────────
def test_real_ls_remote_against_bare_repo(tmp_path, monkeypatch):
source = tmp_path / "bare.git"
git = _git_exe()
env = _git_env()
subprocess.run(
[git, "init", "--bare", "-q", str(source)],
check=True, capture_output=True, env=env,
)
from hermes_cli.plugins_updates import default_ls_remote
monkeypatch.setattr('hermes_cli.plugins_cmd._resolve_git_executable', _git_exe)
assert default_ls_remote(str(source)) == ""
# ── feed parsing ────────────────────────────────────────────────────
def test_parse_feed_requires_version():
with pytest.raises(ValueError):
parse_feed_yml("released: 2026-01-01\n")
assert parse_feed_yml("version: 2.0.0\n")["version"] == "2.0.0"
# ── pip world ───────────────────────────────────────────────────────
class _EP:
def __init__(self, name, value, dist_name):
self.name = name
self.value = value
self.dist_name = dist_name
def test_pip_not_on_pypi_reports_unknown():
eps = [_EP("local-only", "x:y", "x")]
rs = check_pip_plugins(
installed_version=lambda d: "1.0",
pypi_latest=lambda d: None,
entry_points=eps,
)
assert rs[0].update_available is None
assert "not on PyPI" in rs[0].reason
class _Dist:
def __init__(self, name):
self._name = name
@property
def metadata(self):
return {"Name": self._name}
def test_pip_uses_owning_distribution_not_import_root():
"""C17: the dist name comes from the entry point's owning distribution,
never guessed from the value's first import module."""
ep = _EP("mnemosyne", "some.module:register", None)
ep.dist = _Dist("mnemosyne-hermes")
seen = []
rs = check_pip_plugins(
installed_version=lambda d: (seen.append(d), "0.5.0")[1],
pypi_latest=lambda d: None,
entry_points=[ep],
)
assert seen == ["mnemosyne-hermes"]
assert rs[0].current == "0.5.0"
def test_pip_entry_point_without_distribution_is_unknown_not_guessed():
ep = _EP("orphan", "some.module:register", None)
rs = check_pip_plugins(
installed_version=lambda d: pytest.fail(f"guessed dist {d!r}"),
pypi_latest=lambda d: pytest.fail("no fetch without a dist"),
entry_points=[ep],
)
assert rs[0].update_available is None
assert rs[0].latest is None
assert "distribution" in rs[0].reason
# ── run_checks composition ───────────────────────────────────────────
def test_run_checks_never_mutates(tmp_path):
plugins = tmp_path / "plugins"
plug = plugins / "plug"
(plug / ".git").mkdir(parents=True) # git-class, not drift
(plugins / ".install-metadata.json").write_text(
json.dumps({"plug": {"pinned": False, "revision": "a" * 40,
"source": "https://example/o/r"}}),
encoding="utf-8",
)
before = (plugins / ".install-metadata.json").read_text(encoding="utf-8")
results = run_checks(
plugins,
fetch=_no,
ls_remote=lambda s: "b" * 40,
include_pip=False,
)
assert results[0].update_available is True
assert (plugins / ".install-metadata.json").read_text(encoding="utf-8") == before
@pytest.mark.parametrize("url", ["http://feed.example/f.yml", "file:///etc/passwd", "ftp://x/f.yml", ""])
def test_default_fetch_refuses_non_https_feeds_before_any_request(monkeypatch, url):
"""Rows saved before the https rule (or hand-edited) still reach the real fetcher from the
gateway tick; the sink refuses them instead of opening the URL."""
import urllib.request
from hermes_cli.plugins_updates import default_fetch
def never(*a, **k):
raise AssertionError("urlopen must not be reached")
monkeypatch.setattr(urllib.request, "urlopen", never)
with pytest.raises(ValueError, match="https://"):
default_fetch(url)
@pytest.fixture
def feed_redirect_server(monkeypatch):
"""Exercise urllib's redirect machinery without requiring a TLS certificate."""
import http.client
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
visited = []
class FeedHandler(BaseHTTPRequestHandler):
def do_GET(self):
visited.append(self.path)
if self.path == "/start":
target = f"http://127.0.0.1:{self.server.server_port}/middle"
elif self.path == "/middle":
target = f"https://127.0.0.1:{self.server.server_port}/feed"
elif self.path == "/secure":
target = f"https://127.0.0.1:{self.server.server_port}/feed"
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b"version: 1.2.0\n")
return
self.send_response(302)
self.send_header("Location", target)
self.end_headers()
def log_message(self, *_args):
pass
# Only the HTTPS transport is replaced; the redirect handler and HTTP
# transport stay real, so the server can observe a forbidden HTTP hop.
monkeypatch.setattr(
urllib.request.HTTPSHandler, "https_open",
lambda self, req: self.do_open(http.client.HTTPConnection, req),
)
server = ThreadingHTTPServer(("127.0.0.1", 0), FeedHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"https://127.0.0.1:{server.server_port}", visited
finally:
server.shutdown()
thread.join()
server.server_close()
def test_default_fetch_refuses_intermediate_plaintext_redirect(feed_redirect_server):
from hermes_cli.plugins_updates import default_fetch
base, visited = feed_redirect_server
with pytest.raises(ValueError, match="https://"):
default_fetch(base + "/start")
assert visited == ["/start"]
def test_default_fetch_follows_https_redirect(feed_redirect_server):
from hermes_cli.plugins_updates import default_fetch
base, visited = feed_redirect_server
assert default_fetch(base + "/secure") == "version: 1.2.0\n"
assert visited == ["/secure", "/feed"]