"""Unit tests: docs-push service (phase 59, task 03). ``push_document`` is exercised against a **real local git repo** — a bare origin in ``tmp_path`` plus the working clones the service creates itself — and every result assertion reads the bare repo's state directly (``git show :``, ``git rev-list``), not the return value alone. The remote is a plain local path, so no network is ever involved. The module skips (``pytest.skip``) when ``git --version`` fails — a machine without git must not see hard failures. Phase 173 (C2/C5) adds a mocked-``run_git`` section: the FULL 10-step sequence is exercised against a fake git (no real repo) to pin the credential transport — no token → every step gets ``env=None`` (byte-identical to the pre-phase call); a token → every step gets ``GIT_CURL_USERNAME`` / ``GIT_CURL_PASSWORD`` / ``GIT_TERMINAL_PROMPT=0`` merged over a controlled ``os.environ`` (``PATH`` survives the subprocess env-replacement trap); a whitespace-only token = empty; and a failing step still raises :class:`DocsPushError` with git's stderr, credential-agnostic. """ from __future__ import annotations import subprocess from pathlib import Path from typing import TypedDict import pytest import app.core.docs_push as docs_push_mod from app.core.docs_push import DocsPushError, push_document from scripts.git_sync import GitSyncError BASE = "main" BRANCH = "bor-docs" REL = "docs/note.md" CLEAN_REPO = "https://git.example.com/docs.git" # phase 173: the credential-free form IDENTITY = ("-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com") def _git_available() -> bool: try: proc = subprocess.run(["git", "--version"], capture_output=True, check=False) return proc.returncode == 0 except (FileNotFoundError, OSError): return False @pytest.fixture(scope="module", autouse=True) def _require_git() -> None: """Skip the whole module when the git CLI is missing.""" if not _git_available(): pytest.skip("git is not available on this machine") def _git(cwd: Path, *argv: str) -> str: """Run git for the tests themselves (setup + assertions); loud on failure.""" proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False) assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}" return proc.stdout def _push(bare: Path, work: Path, content: str, message: str = "docs: note") -> tuple[str, str]: """push_document against the fixture bare repo (plain local path).""" return push_document( repo=str(bare), base_branch=BASE, branch=BRANCH, work_dir=str(work), rel_path=REL, content=content, commit_message=message, ) @pytest.fixture() def bare_repo(tmp_path: Path) -> Path: """A bare origin seeded with one commit on ``main`` (``README.md``).""" bare = tmp_path / "bare.git" _git(tmp_path, "init", "--bare", str(bare)) seed = tmp_path / "seed" _git(tmp_path, "clone", str(bare), str(seed)) (seed / "README.md").write_text("# docs\n", encoding="utf-8") _git(seed, "checkout", "-B", BASE) _git(seed, *IDENTITY, "add", "README.md") _git(seed, *IDENTITY, "commit", "-m", "seed README") _git(seed, "push", "origin", BASE) return bare def test_first_push_creates_branch_and_returns_sha(bare_repo: Path, tmp_path: Path) -> None: """First push: clones the base, creates the branch, lands the file.""" work = tmp_path / "work" # absent — push_document clones it branch, sha = _push(bare_repo, work, "# Note\n\nbody one\n") assert branch == BRANCH assert (work / ".git").is_dir() assert (work / REL).read_text(encoding="utf-8") == "# Note\n\nbody one\n" # The file lands on the branch of the BARE repo, at the returned sha. assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "# Note\n\nbody one\n" assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha assert len(sha) == 40 # Exactly one commit beyond main. assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "1" # Fixed per-invocation identity + message (no global git config reliance). ident = _git(bare_repo, "log", "-1", BRANCH, "--format=%an <%ae>").strip() assert ident == "Brain of Reese " assert _git(bare_repo, "log", "-1", BRANCH, "--format=%s").strip() == "docs: note" def test_second_push_fast_forwards_same_branch(bare_repo: Path, tmp_path: Path) -> None: """Second push (edited content, same path): fast-forward, 2 commits.""" work = tmp_path / "work" sha1 = _push(bare_repo, work, "v1\n")[1] sha2 = _push(bare_repo, work, "v2 edited\n")[1] assert sha1 != sha2 assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "v2 edited\n" assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2" # Fast-forward, no force: the first commit is still an ancestor. _git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2) def test_fresh_checkout_reattaches_onto_remote_branch(bare_repo: Path, tmp_path: Path) -> None: """An absent checkout re-attaches onto the existing remote branch (its history) so the push still fast-forwards.""" work1 = tmp_path / "work1" sha1 = _push(bare_repo, work1, "v1\n")[1] work2 = tmp_path / "work2" # different dir — push_document clones anew branch, sha2 = _push(bare_repo, work2, "v2\n") assert branch == BRANCH assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2" assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha2 # work2's commit sits on work1's commit (re-attach, not a fork). _git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2) def test_concurrently_advanced_remote_fails_loudly(bare_repo: Path, tmp_path: Path) -> None: """Remote advanced by a second clone → the first clone's push is a non-fast-forward: DocsPushError carrying git's stderr, remote kept.""" work_a = tmp_path / "work_a" _push(bare_repo, work_a, "from A\n") # A second clone advances the branch on the bare repo. work_b = tmp_path / "work_b" _git(tmp_path, "clone", "--depth", "1", "--branch", BRANCH, str(bare_repo), str(work_b)) (work_b / "docs" / "other.md").write_text("from B\n", encoding="utf-8") _git(work_b, *IDENTITY, "add", "docs/other.md") _git(work_b, *IDENTITY, "commit", "-m", "docs: other") _git(work_b, "push", "origin", BRANCH) remote_tip_before = _git(bare_repo, "rev-parse", BRANCH).strip() with pytest.raises(DocsPushError) as excinfo: _push(bare_repo, work_a, "from A again\n") msg = str(excinfo.value) # git's stderr is surfaced (the non-fast-forward refusal). assert "non-fast-forward" in msg assert "rejected" in msg # The remote branch was NOT touched (no force-push, no merge). assert _git(bare_repo, "rev-parse", BRANCH).strip() == remote_tip_before assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "from A\n" def test_missing_repo_path_fails_loudly(tmp_path: Path) -> None: """No such repo → DocsPushError naming the failed git step.""" with pytest.raises(DocsPushError, match="git clone .* failed"): push_document( repo=str(tmp_path / "no-such-repo"), base_branch=BASE, branch=BRANCH, work_dir=str(tmp_path / "w"), rel_path=REL, content="x\n", commit_message="docs: x", ) # No fake checkout is left behind. assert not (tmp_path / "w" / ".git").exists() def test_non_repo_dir_fails_loudly(tmp_path: Path) -> None: """A plain directory (not a git repo) as the remote → DocsPushError.""" plain = tmp_path / "plain" plain.mkdir() (plain / "file.txt").write_text("not a repo\n", encoding="utf-8") with pytest.raises(DocsPushError, match="failed"): push_document( repo=str(plain), base_branch=BASE, branch=BRANCH, work_dir=str(tmp_path / "w"), rel_path=REL, content="x\n", commit_message="docs: x", ) def test_unsafe_rel_path_is_refused_before_any_git(bare_repo: Path, tmp_path: Path) -> None: """The defensive parts re-assertion refuses traversal paths.""" for bad in ("../evil.md", "/etc/passwd", "a/b/../c.md"): with pytest.raises(DocsPushError, match="unsafe rel_path"): push_document( repo=str(bare_repo), base_branch=BASE, branch=BRANCH, work_dir=str(tmp_path / "w"), rel_path=bad, content="x\n", commit_message="docs: x", ) # No checkout was even attempted. assert not (tmp_path / "w").exists() # --- phase 173: credentials on the wire (C2/C5, mocked git) --------------- class _GitCall(TypedDict): """One recorded ``run_git`` call (argv / cwd / env).""" argv: list[str] cwd: Path env: dict[str, str] | None def _fake_run_git( monkeypatch: pytest.MonkeyPatch, push_stderr: str = "" ) -> list[_GitCall]: """Monkeypatch ``app.core.docs_push.run_git``; record each call (argv / cwd / env). The FULL 10-step sequence runs: clone (the work dir is absent) → the base fetch → the ``FETCH_HEAD`` capture → the branch fetch (ALWAYS raises — the branch does not exist on the remote yet, the swallowed path) → the branch probe → ``checkout -B`` → add → commit → push (raises when *push_stderr* is set — the un-swallowed failure, task 03 5d) → the ``HEAD`` capture. Returns ``(BRANCH, "b" * 40)`` on success. """ calls: list[_GitCall] = [] def fake_run( argv: list[str], cwd: Path, env: dict[str, str] | None = None ) -> str: calls.append({"argv": list(argv), "cwd": cwd, "env": env}) key = tuple(argv) if key == ("git", "fetch", "--depth", "100", "origin", BRANCH): raise GitSyncError( f"git fetch --depth 100 origin {BRANCH} failed (exit 128): " f"fatal: couldn't find remote ref {BRANCH}" ) if push_stderr and key == ("git", "push", "origin", BRANCH): raise GitSyncError( f"git push origin {BRANCH} failed (exit 128): {push_stderr}" ) if key[:2] == ("git", "rev-parse"): return "a" * 40 if key[-1] == "FETCH_HEAD" else "b" * 40 return "" monkeypatch.setattr(docs_push_mod, "run_git", fake_run) return calls def _args(work: Path) -> dict[str, str]: return dict( repo=CLEAN_REPO, base_branch=BASE, branch=BRANCH, work_dir=str(work), rel_path=REL, content="# Note\n", commit_message="docs: note", ) def test_no_token_env_is_none_on_every_step( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Task 03 5a (C5 byte-identity pin): default args (no token) → EVERY git step of the full sequence gets ``env=None`` — the pre-phase ``subprocess.run`` shape, unchanged.""" work = tmp_path / "work" # absent — the clone step runs too calls = _fake_run_git(monkeypatch) branch, sha = push_document(**_args(work)) assert (branch, sha) == (BRANCH, "b" * 40) assert len(calls) == 10 # clone, fetch×2, rev-parse×2, branch, checkout, add, commit, push for call in calls: assert call["env"] is None # The CLEAN URL stays in the argv (the credential never appears # because it is not passed at all). assert calls[0]["argv"][-2] == CLEAN_REPO def test_token_env_on_every_step_merged_over_environ( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Task 03 5b (C2): token set → EVERY git step of a successful push gets the 3-var credential env MERGED OVER a controlled ``os.environ`` — ``PATH`` survives the subprocess env-replacement trap, the credential never lands in the argv, and the clean URL stays in it.""" monkeypatch.setenv("PATH", "/usr/bin") monkeypatch.setenv("BOR_TEST_ENV", "sentinel") work = tmp_path / "work" calls = _fake_run_git(monkeypatch) push_document(**_args(work), user="alice", token="s3cret") assert len(calls) == 10 for call in calls: env = call["env"] assert isinstance(env, dict) assert env["GIT_CURL_USERNAME"] == "alice" assert env["GIT_CURL_PASSWORD"] == "s3cret" assert env["GIT_TERMINAL_PROMPT"] == "0" assert env["PATH"] == "/usr/bin" # the merge-over-os.environ pin assert env["BOR_TEST_ENV"] == "sentinel" # the parent env survives # C2: the credential travels ONLY in the environment. assert "alice" not in call["argv"] assert "s3cret" not in call["argv"] # The clean URL is in the argv, untouched. assert CLEAN_REPO in calls[0]["argv"] def test_whitespace_only_token_is_treated_as_empty( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Task 03 5c (the ``.strip()`` rule): a whitespace-only token is the no-credentials state → every step gets ``env=None``.""" calls = _fake_run_git(monkeypatch) push_document(**_args(tmp_path / "work"), user="alice", token=" ") assert len(calls) == 10 for call in calls: assert call["env"] is None def test_failing_step_raises_docs_push_error_with_and_without_credentials( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Task 03 5d: a failing step (the push) still raises :class:`DocsPushError` with git's stderr — the error path is credential-agnostic (with and without the credential env); the failing step itself still carries the credential env when a token is set.""" stderr = "fatal: rejected (simulated non-fast-forward)" # without credentials (C5 — the pre-phase error path) calls = _fake_run_git(monkeypatch, push_stderr=stderr) with pytest.raises(DocsPushError) as excinfo: push_document(**_args(tmp_path / "w1")) msg = str(excinfo.value) assert f"git push origin {BRANCH} failed (exit 128): {stderr}" in msg assert calls[-1]["env"] is None # the failed push step — no env # with credentials (the error carries the full stderr for logs; # the API layer's sanitize_error stays the defense-in-depth) calls = _fake_run_git(monkeypatch, push_stderr=stderr) with pytest.raises(DocsPushError) as excinfo: push_document(**_args(tmp_path / "w2"), user="alice", token="s3cret") msg = str(excinfo.value) assert f"git push origin {BRANCH} failed (exit 128): {stderr}" in msg assert isinstance(calls[-1]["env"], dict) assert calls[-1]["env"]["GIT_CURL_PASSWORD"] == "s3cret"