Files
brain-of-reese/tests/unit/test_docs_push.py
T
ducoterra a86caec821
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 173_docs_push_creds_split
All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
2026-10-10 16:47:39 -04:00

370 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 <branch>:<path>``, ``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 <bor@local>"
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"