From 5365a7a85252ea48660910210c3e212b66424599 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 25 Jun 2026 22:11:19 -0400 Subject: [PATCH] test(git-gate): ratchet git_gate coverage to >=90% Fourth per-module ratchet under ADR 0004. Cover the pure `git_gate_render_gitconfig` renderer (empty entries, insteadOf URL, scheme override, RemoteKey ssh alias with/without non-default port, newline-injection rejection) and the dynamic gitea deploy-key lifecycle with the forge provisioner mocked: - `_provision_dynamic_key`: writes key + key-id files, strips `.git` from owner/repo, builds the proposal title; missing token raises. - `revoke_git_gate_provisioned_keys`: revokes a gitea key when the id-file is present, skips static-provider entries and missing id-files, raises on a missing token. bot_bottle/git_gate.py: 70% -> 99% (unit only). Two remaining partial branches are inner conditionals on the alias/owner-repo paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9 --- tests/unit/test_git_gate_render_provision.py | 174 +++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/unit/test_git_gate_render_provision.py diff --git a/tests/unit/test_git_gate_render_provision.py b/tests/unit/test_git_gate_render_provision.py new file mode 100644 index 0000000..124eb44 --- /dev/null +++ b/tests/unit/test_git_gate_render_provision.py @@ -0,0 +1,174 @@ +"""Unit: git_gate gitconfig rendering + deploy-key provision/revoke +(coverage ratchet, ADR 0004). + +Covers the pure `git_gate_render_gitconfig` renderer and the dynamic +(gitea) deploy-key lifecycle, with the forge provisioner mocked.""" + +from __future__ import annotations + +import tempfile +import types +import unittest +from pathlib import Path +from typing import Any, cast +from unittest.mock import patch + +from bot_bottle.git_gate import ( + _gitconfig_validate_value, + _provision_dynamic_key, + git_gate_render_gitconfig, + revoke_git_gate_provisioned_keys, +) +from bot_bottle.manifest_git import ManifestGitEntry, ManifestKeyConfig + + +def _entry(**kw: Any) -> ManifestGitEntry: + base: dict[str, Any] = { + "Name": "repo", + "Upstream": "git@github.com:o/r.git", + "UpstreamHost": "github.com", + "UpstreamUser": "git", + "UpstreamPath": "o/r.git", + "UpstreamPort": "22", + } + base.update(kw) + return ManifestGitEntry(**base) + + +def _gitea_entry(**kw: Any) -> ManifestGitEntry: + return _entry( + Key=ManifestKeyConfig(provider="gitea", forge_token_env="GITEA_TOK"), + **kw, + ) + + +class _FakeProvisioner: + def __init__(self) -> None: + self.created: list[tuple[str, str]] = [] + self.deleted: list[tuple[str, str]] = [] + + def create(self, owner_repo: str, title: str) -> tuple[str, bytes]: + self.created.append((owner_repo, title)) + return "kid123", b"PRIVATE-KEY-BYTES" + + def delete(self, owner_repo: str, key_id: str) -> None: + self.deleted.append((owner_repo, key_id)) + + +# --------------------------------------------------------------------------- +# git_gate_render_gitconfig +# --------------------------------------------------------------------------- + + +class TestRenderGitconfig(unittest.TestCase): + def test_empty_entries_returns_empty_string(self) -> None: + self.assertEqual("", git_gate_render_gitconfig((), "git-gate")) + + def test_single_entry_renders_insteadof(self) -> None: + out = git_gate_render_gitconfig((_entry(),), "git-gate") + self.assertIn('[url "git://git-gate/repo.git"]', out) + self.assertIn("insteadOf = git@github.com:o/r.git", out) + + def test_scheme_override(self) -> None: + out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http") + self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out) + + def test_remote_key_alias_with_nondefault_port(self) -> None: + out = git_gate_render_gitconfig( + (_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate", + ) + self.assertIn("insteadOf = ssh://git@10.0.0.5:2222/o/r.git", out) + + def test_remote_key_alias_default_port_omits_port(self) -> None: + out = git_gate_render_gitconfig( + (_entry(RemoteKey="10.0.0.5", UpstreamPort="22"),), "git-gate", + ) + self.assertIn("insteadOf = ssh://git@10.0.0.5/o/r.git", out) + self.assertNotIn(":22/", out) + + def test_validate_rejects_newline(self) -> None: + with self.assertRaises(ValueError): + _gitconfig_validate_value("field", "line1\nline2") + + def test_render_rejects_newline_in_upstream(self) -> None: + with self.assertRaises(ValueError): + git_gate_render_gitconfig((_entry(Upstream="a\nb"),), "git-gate") + + +# --------------------------------------------------------------------------- +# _provision_dynamic_key +# --------------------------------------------------------------------------- + + +class TestProvisionDynamicKey(unittest.TestCase): + def test_happy_path_writes_key_and_id(self) -> None: + fake = _FakeProvisioner() + with tempfile.TemporaryDirectory() as d, \ + patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \ + patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \ + patch("sys.stderr"): + path = _provision_dynamic_key(_gitea_entry(), "myslug", Path(d)) + key_file = Path(path) + self.assertEqual(b"PRIVATE-KEY-BYTES", key_file.read_bytes()) + id_file = Path(d) / "repo-deploy-key-id" + self.assertEqual("kid123", id_file.read_text()) + # owner_repo had .git stripped; title carries slug + name + self.assertEqual([("o/r", "bot-bottle:myslug:repo")], fake.created) + + def test_missing_token_raises(self) -> None: + with tempfile.TemporaryDirectory() as d, \ + patch.dict("os.environ", {}, clear=False): + import os + os.environ.pop("GITEA_TOK", None) + with self.assertRaises(RuntimeError): + _provision_dynamic_key(_gitea_entry(), "s", Path(d)) + + +# --------------------------------------------------------------------------- +# revoke_git_gate_provisioned_keys +# --------------------------------------------------------------------------- + + +def _bottle(*entries: ManifestGitEntry) -> Any: + return cast(Any, types.SimpleNamespace(git=entries)) + + +class TestRevokeProvisionedKeys(unittest.TestCase): + def test_revokes_gitea_key_when_id_present(self) -> None: + fake = _FakeProvisioner() + with tempfile.TemporaryDirectory() as d, \ + patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \ + patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \ + patch("sys.stderr"): + (Path(d) / "repo-deploy-key-id").write_text("kid123") + revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d)) + self.assertEqual([("o/r", "kid123")], fake.deleted) + + def test_skips_non_gitea_entry(self) -> None: + fake = _FakeProvisioner() + static_entry = _entry(Key=ManifestKeyConfig(provider="static", path="/k")) + with tempfile.TemporaryDirectory() as d, \ + patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake): + revoke_git_gate_provisioned_keys(_bottle(static_entry), Path(d)) + self.assertEqual([], fake.deleted) + + def test_skips_when_id_file_missing(self) -> None: + fake = _FakeProvisioner() + with tempfile.TemporaryDirectory() as d, \ + patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake): + # no id file written -> entry skipped + revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d)) + self.assertEqual([], fake.deleted) + + def test_missing_token_raises(self) -> None: + with tempfile.TemporaryDirectory() as d, \ + patch.dict("os.environ", {}, clear=False): + import os + os.environ.pop("GITEA_TOK", None) + (Path(d) / "repo-deploy-key-id").write_text("kid123") + with self.assertRaises(RuntimeError): + revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d)) + + +if __name__ == "__main__": + unittest.main()