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.
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""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"
|