CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m43s
A failing CI test printed a test key through SSHKey's default repr. Push credentials, AgentConfig's tokens and MatrixConfig's token are now excluded from their reprs, so no failure or log line can print them. In CI's container killed ssh-agents linger as zombies, so the tests count only live ones. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
262 lines
9.1 KiB
Python
262 lines
9.1 KiB
Python
"""Git on the build VM (D4, L13): here, a throwaway sshd on this machine."""
|
|
|
|
import shlex
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.execution import SSHHost
|
|
from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
|
|
r = GitRepo(ssh_host, tmp_path / "work")
|
|
r.init()
|
|
r.configure_identity("nisse-dev-testing", "nisse@example.test")
|
|
return r
|
|
|
|
|
|
def bare_remote(path: Path) -> str:
|
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(path)], check=True)
|
|
return str(path)
|
|
|
|
|
|
def test_init_creates_repo_on_default_branch(repo: GitRepo) -> None:
|
|
assert repo.current_branch() == "main"
|
|
|
|
|
|
def test_commit_all_returns_sha_and_branch_switching(repo: GitRepo) -> None:
|
|
(repo.root / "a.txt").write_text("a")
|
|
sha = repo.commit_all("first")
|
|
assert sha is not None
|
|
assert sha == repo.head_sha()
|
|
|
|
repo.checkout_new_branch("milestone-1")
|
|
assert repo.current_branch() == "milestone-1"
|
|
|
|
|
|
def test_commit_with_nothing_staged_returns_none(repo: GitRepo) -> None:
|
|
(repo.root / "a.txt").write_text("a")
|
|
repo.commit_all("first")
|
|
# An empty milestone is a fact to report, not an error to raise.
|
|
assert repo.commit_all("nothing changed") is None
|
|
|
|
|
|
def test_has_changes_tracks_the_worktree(repo: GitRepo) -> None:
|
|
assert not repo.has_changes()
|
|
(repo.root / "a.txt").write_text("a")
|
|
assert repo.has_changes()
|
|
|
|
|
|
def test_git_error_on_bad_command(ssh_host: SSHHost, tmp_path: Path) -> None:
|
|
repo = GitRepo(ssh_host, tmp_path / "not-a-repo")
|
|
repo.root.mkdir()
|
|
with pytest.raises(GitError):
|
|
repo.head_sha()
|
|
|
|
|
|
def test_commits_since_and_remote_sha_against_a_local_remote(
|
|
repo: GitRepo, tmp_path: Path
|
|
) -> None:
|
|
remote = bare_remote(tmp_path / "remote.git")
|
|
(repo.root / "a.txt").write_text("a")
|
|
base = repo.commit_all("base")
|
|
repo.push(remote, "main")
|
|
repo._git("update-ref", "refs/remotes/origin/main", "HEAD")
|
|
assert repo.remote_sha(remote, "main") == base
|
|
assert repo.remote_sha(remote, "absent") is None
|
|
(repo.root / "b.txt").write_text("b")
|
|
repo.commit_all("ahead")
|
|
assert repo.commits_since("origin/main") == 1
|
|
|
|
|
|
# --- the push key (#89) ------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, SSHKey]:
|
|
"""A bare repository reached by git over SSH, and the key that may push
|
|
to it: the forge, as the build VM sees it."""
|
|
remote = bare_remote(tmp_path / "forge.git")
|
|
url = f"ssh://{ssh_host.user}@127.0.0.1:{ssh_host.port}{remote}"
|
|
return url, SSHKey(ssh_host._key.read_text())
|
|
|
|
|
|
def test_clone_and_push_with_the_key_store_no_credential(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
url, key = ssh_remote
|
|
seed = GitRepo(ssh_host, tmp_path / "seed", known_hosts=str(tmp_path / "kh"))
|
|
seed.init()
|
|
seed.configure_identity("t", "t@example.test")
|
|
(seed.root / "a.txt").write_text("a")
|
|
seed.commit_all("first")
|
|
seed.push(url, "main", credential=key)
|
|
|
|
clone = GitRepo(ssh_host, tmp_path / "clone", known_hosts=str(tmp_path / "kh"))
|
|
clone.clone(url, credential=key)
|
|
assert (clone.root / "a.txt").read_text() == "a"
|
|
config = (clone.root / ".git" / "config").read_text()
|
|
assert "PRIVATE KEY" not in config
|
|
# The URL recorded is the credential-free one we gave.
|
|
assert url in config
|
|
|
|
|
|
def test_the_key_is_gone_when_the_command_ends(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
url, key = ssh_remote
|
|
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
|
repo.init()
|
|
repo.configure_identity("t", "t@example.test")
|
|
(repo.root / "a.txt").write_text("a")
|
|
repo.commit_all("first")
|
|
before = _agents()
|
|
repo.push(url, "main", credential=key)
|
|
assert _agents() == before # the throwaway ssh-agent was killed
|
|
|
|
|
|
def test_a_failed_push_still_kills_its_agent(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
_, key = ssh_remote
|
|
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
|
repo.init()
|
|
before = _agents()
|
|
with pytest.raises(GitError):
|
|
repo.push(
|
|
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git",
|
|
"main",
|
|
credential=key,
|
|
)
|
|
assert _agents() == before
|
|
|
|
|
|
def _agents() -> set[str]:
|
|
"""Live ssh-agents. A killed one lingers as a zombie in a CI container,
|
|
where nothing reaps orphans, so zombies don't count."""
|
|
out = subprocess.run(
|
|
["pgrep", "-x", "ssh-agent"], capture_output=True, text=True, check=False
|
|
).stdout
|
|
live = set()
|
|
for pid in out.split():
|
|
try:
|
|
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
except FileNotFoundError:
|
|
continue
|
|
if stat.rsplit(")", 1)[1].split()[0] != "Z":
|
|
live.add(pid)
|
|
return live
|
|
|
|
|
|
def test_credentials_never_appear_in_a_repr() -> None:
|
|
assert "s3cr3t" not in repr(HTTPSToken("pusher", "s3cr3t"))
|
|
assert "PRIVATE" not in repr(SSHKey("-----BEGIN OPENSSH PRIVATE KEY-----"))
|
|
|
|
|
|
def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -> None:
|
|
marker = tmp_path / "pwned"
|
|
with pytest.raises(GitError):
|
|
repo.push(f"x; touch {shlex.quote(str(marker))}", "main")
|
|
assert not marker.exists()
|
|
|
|
|
|
class RecordingHost:
|
|
"""Captures what would be sent to the build VM."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
def execute(self, command: str, **kwargs):
|
|
from nisse_dev.execution import Result
|
|
|
|
self.calls.append({"command": command, **kwargs})
|
|
return Result(0, "", "")
|
|
|
|
|
|
def test_an_https_token_travels_only_as_a_secret(tmp_path: Path) -> None:
|
|
host = RecordingHost()
|
|
repo = GitRepo(host, tmp_path) # type: ignore[arg-type]
|
|
token = "tok-" + "9f3a" * 6
|
|
repo.push(
|
|
"https://gitea.test/o/r.git", "m1", credential=HTTPSToken("pusher", token)
|
|
)
|
|
[call] = host.calls
|
|
assert token not in call["command"] # never on a command line
|
|
assert "pusher" not in call["command"]
|
|
assert call["secrets"] == {"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": token}
|
|
# Git asks a helper that reads the environment, and any helper configured
|
|
# on the VM is cleared first.
|
|
assert "credential.helper= " in call["command"]
|
|
assert "$NISSE_PUSH_TOKEN" in call["command"]
|
|
assert "GIT_TERMINAL_PROMPT=0" in call["command"]
|
|
|
|
|
|
def test_an_https_token_answers_git_through_the_helper(
|
|
ssh_host: SSHHost, tmp_path: Path
|
|
) -> None:
|
|
# The helper as git runs it, on the build host: asked to "get", it
|
|
# answers from the environment; asked anything else, nothing.
|
|
from nisse_dev.git import _HELPER
|
|
|
|
body = _HELPER.removeprefix("!")
|
|
asked = ssh_host.execute(
|
|
f"{body} get",
|
|
secrets={"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": "tok-1234567"},
|
|
)
|
|
# Both travel as secrets, so both are masked in what comes back.
|
|
assert asked.stdout == "username=[redacted]\npassword=[redacted]\n"
|
|
stored = ssh_host.execute(f"{body} store", secrets={"NISSE_PUSH_TOKEN": "x"})
|
|
assert stored.stdout == ""
|
|
|
|
|
|
# --- build artifacts are never the work (#80, D4) ----------------------------
|
|
|
|
|
|
def test_artifacts_are_left_out_even_when_the_project_tracks_them(
|
|
repo: GitRepo,
|
|
) -> None:
|
|
# The sandbox's main tracks .pyc files (an earlier run published them).
|
|
cache = repo.root / "pkg" / "__pycache__"
|
|
cache.mkdir(parents=True)
|
|
(repo.root / "pkg" / "m.py").write_text("x = 1\n")
|
|
(cache / "m.cpython-312.pyc").write_bytes(b"old")
|
|
repo._git("add", "-A")
|
|
repo._git("commit", "-qm", "a project that tracks its caches")
|
|
|
|
# A test run: the tracked .pyc changes, new caches appear, and the work.
|
|
(cache / "m.cpython-312.pyc").write_bytes(b"new")
|
|
(cache / "m.cpython-310.pyc").write_bytes(b"x")
|
|
(repo.root / ".pytest_cache").mkdir()
|
|
(repo.root / ".pytest_cache" / "v").write_text("x")
|
|
(repo.root / "pkg" / "m.py").write_text("x = 2\n")
|
|
|
|
assert repo.commit_all("the work") is not None
|
|
committed = repo._git("show", "--name-only", "--format=", "HEAD").split()
|
|
assert committed == ["pkg/m.py"]
|
|
assert repo.left_out() == [
|
|
".pytest_cache/v",
|
|
"pkg/__pycache__/m.cpython-310.pyc",
|
|
"pkg/__pycache__/m.cpython-312.pyc",
|
|
]
|
|
|
|
|
|
def test_only_artifacts_changed_means_nothing_to_commit(repo: GitRepo) -> None:
|
|
(repo.root / "__pycache__").mkdir()
|
|
(repo.root / "__pycache__" / "a.pyc").write_bytes(b"x")
|
|
assert repo.commit_all("nothing real") is None
|
|
|
|
|
|
def test_excludes_hide_new_artifacts_and_install_once(repo: GitRepo) -> None:
|
|
repo.install_excludes()
|
|
repo.install_excludes()
|
|
exclude = (repo.root / ".git" / "info" / "exclude").read_text()
|
|
assert exclude.count("__pycache__/") == 1
|
|
(repo.root / "node_modules").mkdir()
|
|
(repo.root / "node_modules" / "x.js").write_text("x")
|
|
(repo.root / "real.py").write_text("x")
|
|
# Even the model's own `git add -A` now skips them.
|
|
assert repo._git("status", "--porcelain", "--untracked-files=all") == "?? real.py"
|