5365a7a852
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
175 lines
6.8 KiB
Python
175 lines
6.8 KiB
Python
"""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()
|