"""Tests for catalog_console.py's non-Qt git plumbing and ref-resolution seam (issue #68 rotation-completability fix). catalog_console.py is importable here WITHOUT PySide6 -- its Qt import is guarded (`_PYSIDE6_AVAILABLE`) precisely so `keygen`, `show-seed-b64`, `keys`, and this git plumbing stay usable (and testable) wherever PySide6 isn't installed, including this CI test job, which never installs it. If PySide6 genuinely isn't importable in this environment, that itself exercises the guard path -- see test_module_imports_without_pyside6. """ from __future__ import annotations import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import catalog_console as cc import catalog_review as review _SEED_CATALOG = b'{"schema": 1, "version": 1, "servers": []}' _SEED_SIG = b"\x00" * 64 def _run(*args: str, cwd: Path) -> None: subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) def _init_bare_and_clone(tmp_path: Path) -> tuple[Path, Path]: """A bare "origin" repo with `main` and `rotation-branch` both seeded with a catalog + (dummy) signature, plus a working clone with `origin` already configured -- mirroring the tokened-remote clone catalog_console.py's git plumbing is always run against.""" origin = tmp_path / "origin.git" _run("init", "--bare", str(origin), cwd=tmp_path) seed = tmp_path / "seed" _run("clone", str(origin), str(seed), cwd=tmp_path) _run("config", "user.email", "test@example.com", cwd=seed) _run("config", "user.name", "Test", cwd=seed) (seed / "data").mkdir() (seed / "data" / "catalog.json").write_bytes(_SEED_CATALOG) (seed / "data" / "catalog.json.sig").write_bytes(_SEED_SIG) _run("add", "-A", cwd=seed) _run("commit", "-m", "seed", cwd=seed) _run("push", "origin", "HEAD:refs/heads/main", cwd=seed) _run("checkout", "-b", "rotation-branch", cwd=seed) _run("push", "origin", "HEAD:refs/heads/rotation-branch", cwd=seed) clone = tmp_path / "work" _run("clone", str(origin), str(clone), cwd=tmp_path) _run("config", "user.email", "test@example.com", cwd=clone) _run("config", "user.name", "Test", cwd=clone) return origin, clone # --------------------------------------------------------------------------- # # The module must stay importable without PySide6 -- this IS the fix that # lets `keys`/`keygen`/`show-seed-b64` (and this whole test file) run # somewhere PySide6 isn't installed. # --------------------------------------------------------------------------- # def test_module_imports_without_pyside6(): assert hasattr(cc, "_PYSIDE6_AVAILABLE") # This CI test job never installs PySide6 (see .github/workflows/ci.yml # "Install test dependencies": pytest + cryptography only) -- so on CI, # this assertion is itself proof the guard is doing its job. Locally, # where a maintainer's env DOES have PySide6, it's fine either way; the # only real assertion this test needs is "importing the module never # raises", which happened just by getting this far. assert cc._PYSIDE6_AVAILABLE in (True, False) def test_cmd_gui_fails_soft_without_pyside6(monkeypatch, capsys): if cc._PYSIDE6_AVAILABLE: return # nothing to prove where PySide6 IS available import argparse args = argparse.Namespace(repo=".", ref=None) assert cc.cmd_gui(args) == 1 assert "PySide6" in capsys.readouterr().err # --------------------------------------------------------------------------- # # compute_own_refs: the PURE ref-resolution seam. No git, no Qt. # --------------------------------------------------------------------------- # def test_compute_own_refs_defaults_to_main_only(): assert cc.compute_own_refs(None, None) == ["main"] def test_compute_own_refs_adds_detected_branch(): assert cc.compute_own_refs(None, "chore/68-key-rotation") == [ "main", "chore/68-key-rotation", ] def test_compute_own_refs_explicit_ref_overrides_detected_branch(): assert cc.compute_own_refs("explicit-branch", "detected-branch") == [ "main", "explicit-branch", ] def test_compute_own_refs_does_not_duplicate_main(): assert cc.compute_own_refs(None, "main") == ["main"] assert cc.compute_own_refs("main", "some-other-branch") == ["main"] # --------------------------------------------------------------------------- # # current_branch: git plumbing, no Qt. # --------------------------------------------------------------------------- # def test_current_branch_detects_checked_out_branch(tmp_path): _origin, clone = _init_bare_and_clone(tmp_path) _run("fetch", "origin", "rotation-branch", cwd=clone) _run("checkout", "-B", "rotation-branch", "origin/rotation-branch", cwd=clone) assert cc.current_branch(clone) == "rotation-branch" def test_current_branch_none_on_detached_head(tmp_path): _origin, clone = _init_bare_and_clone(tmp_path) commit = cc.fetch_ref(clone, "main") _run("checkout", commit, cwd=clone) assert cc.current_branch(clone) is None # --------------------------------------------------------------------------- # # commit_and_push_signed_catalog: MUST target the given branch, never a # hardcoded "main" -- issue #68's completability fix. This is exactly the # bug that, before the fix, would have made ReviewWindow._on_sign push a # PR/branch review's signature straight to main regardless of what was # actually reviewed. # --------------------------------------------------------------------------- # def test_commit_and_push_signed_catalog_targets_the_given_branch_not_main(tmp_path): _origin, clone = _init_bare_and_clone(tmp_path) new_raw = b'{"schema": 1, "version": 2, "servers": []}' new_sig = b"\x01" * 64 cc.commit_and_push_signed_catalog(clone, new_raw, new_sig, branch="rotation-branch") rotation_commit = cc.fetch_ref(clone, "rotation-branch") rotation_raw, _sha = cc.read_catalog_at_commit(clone, rotation_commit) assert rotation_raw == new_raw # main on the shared origin must be COMPLETELY untouched by a sign that # was reviewed and pushed against rotation-branch. main_commit = cc.fetch_ref(clone, "main") main_raw, _sha = cc.read_catalog_at_commit(clone, main_commit) assert main_raw == _SEED_CATALOG def test_commit_and_push_signed_catalog_still_defaults_to_main(tmp_path): """Backward-compatible default: callers that don't pass `branch` (there are none left in catalog_console.py itself, but the signature keeps the default for any other caller / test fixture) still push to main.""" _origin, clone = _init_bare_and_clone(tmp_path) new_raw = b'{"schema": 1, "version": 2, "servers": []}' new_sig = b"\x01" * 64 cc.commit_and_push_signed_catalog(clone, new_raw, new_sig) main_commit = cc.fetch_ref(clone, "main") main_raw, _sha = cc.read_catalog_at_commit(clone, main_commit) assert main_raw == new_raw rotation_commit = cc.fetch_ref(clone, "rotation-branch") rotation_raw, _sha = cc.read_catalog_at_commit(clone, rotation_commit) assert rotation_raw == _SEED_CATALOG # untouched # --------------------------------------------------------------------------- # # catalog_sig_status_on_disk: the check behind `keys`' "does catalog.json.sig # currently verify?" line -- this is precisely the check that would have # caught the current chore/68-key-rotation state (bcc_core.CATALOG_PUBKEYS # rotated, data/catalog.json.sig still signed by the retired key). # --------------------------------------------------------------------------- # def test_catalog_sig_status_on_disk_valid(tmp_path): seed, pub = review.generate_keypair() raw = b'{"schema": 1, "version": 1, "servers": []}' sig = review.sign_catalog_bytes(raw, seed) (tmp_path / "data").mkdir() (tmp_path / "data" / "catalog.json").write_bytes(raw) (tmp_path / "data" / "catalog.json.sig").write_bytes(sig) assert cc.catalog_sig_status_on_disk(tmp_path, [pub]) == "valid" def test_catalog_sig_status_on_disk_invalid_when_pubkey_rotated(tmp_path): """The exact chore/68-key-rotation scenario: signed by an OLD key, but the committed pubkey list now only has the NEW key.""" old_seed, _old_pub = review.generate_keypair() _new_seed, new_pub = review.generate_keypair() raw = b'{"schema": 1, "version": 1, "servers": []}' sig = review.sign_catalog_bytes(raw, old_seed) (tmp_path / "data").mkdir() (tmp_path / "data" / "catalog.json").write_bytes(raw) (tmp_path / "data" / "catalog.json.sig").write_bytes(sig) assert cc.catalog_sig_status_on_disk(tmp_path, [new_pub]) == "invalid" def test_catalog_sig_status_on_disk_missing_when_no_sig_file(tmp_path): (tmp_path / "data").mkdir() (tmp_path / "data" / "catalog.json").write_bytes(b"{}") assert cc.catalog_sig_status_on_disk(tmp_path, []) == "missing" def test_catalog_sig_status_on_disk_missing_when_no_catalog_file(tmp_path): (tmp_path / "data").mkdir() (tmp_path / "data" / "catalog.json.sig").write_bytes(b"\x00" * 64) assert cc.catalog_sig_status_on_disk(tmp_path, []) == "missing"