"""Integration: doc-drafts API (phase 59, task 02) — create / get / update. The draft lifecycle the edit screen runs on: create (from a response), fetch by token, update (modify before push) — all admin-only (router-wide ``require_admin``), all path-guard-railed (no path that can escape the repo root). Real Postgres (``podman compose up -d db``); no LLM involved — drafts are plain rows, so the suite is deterministic without a fake. Requires: podman compose up -d db """ from __future__ import annotations import subprocess import uuid from collections.abc import Iterator from datetime import datetime from pathlib import Path from typing import Any import pytest from fastapi.testclient import TestClient from sqlalchemy import select, text import app.api.doc_drafts as doc_drafts from app.config import Settings, get_settings from app.core.docs_push import DocsPushError from app.main import app as fastapi_app from app.models import DocDraft TITLE = "How do I deploy a new service?" PATH = "docs/note.md" BODY = "# Answer\n\nSome **markdown** body." BASE_BRANCH = "main" DOCS_BRANCH = "bor-docs" def _settings(**kwargs: Any) -> Settings: """Build Settings without reading a .env file (deterministic tests).""" kwargs.setdefault("_env_file", None) return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) def _git_available() -> bool: try: proc = subprocess.run(["git", "--version"], capture_output=True, check=False) return proc.returncode == 0 except (FileNotFoundError, OSError): return False #: The push tests drive a real local git repo — skipped (not failed) on a #: machine without the git CLI (the task-03 unit-suite guard). GIT = _git_available() def _git(cwd: Path, *argv: str) -> str: """Run git for the tests themselves (fixture setup + assertions).""" 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 @pytest.fixture(autouse=True) def clean_drafts(db) -> Iterator[None]: """doc_drafts is global state: reset around every test.""" db.execute(text("TRUNCATE doc_drafts")) db.commit() yield db.execute(text("TRUNCATE doc_drafts")) db.commit() def _create(admin_client: TestClient, **overrides) -> dict: """POST a well-formed draft (201) and return the response body.""" payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides} r = admin_client.post("/api/doc-drafts", json=payload) assert r.status_code == 201, r.text return r.json() def _backdate_updated_at(db, token: uuid.UUID) -> None: """Push the row's ``updated_at`` one hour back (raw SQL — a hand- written UPDATE does not trigger the column's onupdate default), so a subsequent API write's bump is observable deterministically.""" db.execute( text("UPDATE doc_drafts SET updated_at = now() - interval '1 hour' WHERE token = :t"), {"t": token}, ) db.commit() # ---------- create (POST) ---------- def test_create_returns_201_with_all_fields_and_draft_status( admin_client: TestClient, db ) -> None: r = admin_client.post("/api/doc-drafts", json={"title": TITLE, "path": PATH, "body": BODY}) assert r.status_code == 201 body = r.json() assert body["title"] == TITLE assert body["path"] == PATH assert body["body"] == BODY assert body["status"] == "draft" # The push feedback columns are NULL while still a draft. assert body["branch"] is None assert body["commit_sha"] is None # The token: present, non-NULL, a valid (unguessable) UUID. assert body["token"] tok = uuid.UUID(body["token"]) assert body["created_at"] assert body["updated_at"] # The row is in Postgres under the same token (the URL credential). row = db.execute(select(DocDraft).where(DocDraft.token == tok)).scalars().one() assert row.title == TITLE assert row.path == PATH assert row.body == BODY assert row.status == "draft" def test_create_strips_title_body_and_path(admin_client: TestClient) -> None: body = _create( admin_client, title=f" {TITLE} ", path=f" {PATH} ", body=f"\n{BODY}\n" ) assert body["title"] == TITLE assert body["path"] == PATH assert body["body"] == BODY def test_create_rejects_blank_title_body_path(admin_client: TestClient) -> None: # Whitespace-only values: past pydantic's min_length=1, caught by the # API's non-empty-after-strip rule (422), nothing stored. for overrides in ({"title": " "}, {"body": " \t\n "}, {"path": " "}): payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides} assert admin_client.post("/api/doc-drafts", json=payload).status_code == 422 # Truly empty title/body: pydantic 422 (min_length=1). empty_title = {"title": "", "path": PATH, "body": BODY} assert admin_client.post("/api/doc-drafts", json=empty_title).status_code == 422 empty_body = {"title": TITLE, "path": PATH, "body": ""} assert admin_client.post("/api/doc-drafts", json=empty_body).status_code == 422 # ---------- path guard-rails (shared with the push endpoint) ---------- @pytest.mark.parametrize( ("bad_path", "rule_in_detail"), [ ("/etc/passwd", "absolute"), ("../x.md", "'..'"), ("a/b/../c.md", "'..'"), ("no-suffix", "suffix"), (" ", "empty"), ], ) def test_create_path_guard_rejects_each_rule_with_422( admin_client: TestClient, bad_path: str, rule_in_detail: str ) -> None: r = admin_client.post( "/api/doc-drafts", json={"title": TITLE, "path": bad_path, "body": BODY} ) assert r.status_code == 422 assert rule_in_detail in r.json()["detail"] def test_create_accepts_repo_relative_path_with_suffix(admin_client: TestClient) -> None: body = _create(admin_client, path="docs/note.md") assert body["path"] == "docs/note.md" def test_put_path_guard_rejects_traversal(admin_client: TestClient) -> None: created = _create(admin_client) for bad in ("/etc/passwd", "../x.md", "a/b/../c.md", "no-suffix"): r = admin_client.put( f"/api/doc-drafts/{created['token']}", json={"path": bad} ) assert r.status_code == 422, bad # The row is untouched by the rejected updates. body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() assert body["path"] == PATH # ---------- get (by token) ---------- def test_get_round_trips_created_draft(admin_client: TestClient) -> None: created = _create(admin_client) r = admin_client.get(f"/api/doc-drafts/{created['token']}") assert r.status_code == 200 assert r.json() == created def test_get_unknown_token_returns_404(admin_client: TestClient) -> None: r = admin_client.get(f"/api/doc-drafts/{uuid.uuid4()}") assert r.status_code == 404 assert r.json() == {"detail": "draft not found"} def test_get_malformed_token_returns_422(admin_client: TestClient) -> None: assert admin_client.get("/api/doc-drafts/not-a-uuid").status_code == 422 # ---------- update (PUT) ---------- def test_put_partial_body_only_keeps_title_and_path(admin_client: TestClient, db) -> None: created = _create(admin_client) token = uuid.UUID(created["token"]) _backdate_updated_at(db, token) r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "# v2\n\nEdited."}) assert r.status_code == 200 body = r.json() assert body["title"] == TITLE # absent → unchanged assert body["path"] == PATH # absent → unchanged assert body["body"] == "# v2\n\nEdited." assert body["status"] == "draft" assert body["created_at"] == created["created_at"] # editing does not redate creation # updated_at was bumped past the backdated value. assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat( created["updated_at"] ) def test_put_replaces_all_fields_when_supplied(admin_client: TestClient) -> None: created = _create(admin_client) r = admin_client.put( f"/api/doc-drafts/{created['token']}", json={"title": "New title", "path": "docs/other.md", "body": "New body."}, ) assert r.status_code == 200 body = r.json() assert body["title"] == "New title" assert body["path"] == "docs/other.md" assert body["body"] == "New body." assert body["status"] == "draft" def test_put_noop_still_bumps_updated_at(admin_client: TestClient, db) -> None: """A PUT whose supplied values are all identical (or empty body) changes no stored value — the ORM flushes nothing — yet the contract is that a PUT bumps ``updated_at`` (the raw-UPDATE fallback).""" created = _create(admin_client) token = uuid.UUID(created["token"]) _backdate_updated_at(db, token) r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": BODY}) # identical assert r.status_code == 200 assert r.json()["body"] == BODY assert datetime.fromisoformat(r.json()["updated_at"]) > datetime.fromisoformat( created["updated_at"] ) # And an empty partial body (no fields at all) does the same. _backdate_updated_at(db, token) r2 = admin_client.put(f"/api/doc-drafts/{token}", json={}) assert r2.status_code == 200 assert datetime.fromisoformat(r2.json()["updated_at"]) > datetime.fromisoformat( created["updated_at"] ) def test_put_resets_pushed_draft_to_draft(admin_client: TestClient, db) -> None: created = _create(admin_client) token = uuid.UUID(created["token"]) # Mark the draft pushed directly in the DB (the push endpoint's job # lands in task 04 — here we pin the edit-side consequence). row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one() row.status = "pushed" row.branch = "bor-docs" row.commit_sha = "a" * 40 db.commit() r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "Edited after push."}) assert r.status_code == 200 body = r.json() assert body["status"] == "draft" # the stored sha no longer describes the body assert body["body"] == "Edited after push." assert body["title"] == TITLE # absent → unchanged assert body["path"] == PATH # absent → unchanged # The last push stays visible until the next push overwrites it. assert body["branch"] == "bor-docs" assert body["commit_sha"] == "a" * 40 def test_put_unknown_token_returns_404(admin_client: TestClient) -> None: r = admin_client.put(f"/api/doc-drafts/{uuid.uuid4()}", json={"body": "x"}) assert r.status_code == 404 assert r.json() == {"detail": "draft not found"} def test_put_malformed_token_returns_422(admin_client: TestClient) -> None: assert admin_client.put("/api/doc-drafts/not-a-uuid", json={"body": "x"}).status_code == 422 def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestClient) -> None: created = _create(admin_client) for bad in ({"title": " "}, {"body": " \t "}, {"path": " "}): assert admin_client.put(f"/api/doc-drafts/{created['token']}", json=bad).status_code == 422 assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created # ---------- delete (by token — phase 115 Discard) ---------- def test_delete_removes_row_and_invalidates_token( admin_client: TestClient, db ) -> None: """204 No Content; the row is gone from Postgres; after a discard the token is dead — GET/PUT/push all 404.""" created = _create(admin_client) token = created["token"] r = admin_client.delete(f"/api/doc-drafts/{token}") assert r.status_code == 204 assert r.content == b"" # 204: no body (the token is a one-way credential) row = db.execute( select(DocDraft).where(DocDraft.token == uuid.UUID(token)) ).scalars().first() assert row is None # Every sibling route now 404s with the same message as an unknown token. got = admin_client.get(f"/api/doc-drafts/{token}") assert got.status_code == 404 assert got.json() == {"detail": "draft not found"} assert ( admin_client.put(f"/api/doc-drafts/{token}", json={"body": "x"}).status_code == 404 ) assert admin_client.post(f"/api/doc-drafts/{token}/push").status_code == 404 def test_delete_unknown_token_returns_404(admin_client: TestClient) -> None: r = admin_client.delete(f"/api/doc-drafts/{uuid.uuid4()}") assert r.status_code == 404 assert r.json() == {"detail": "draft not found"} def test_delete_malformed_token_returns_422(admin_client: TestClient) -> None: assert admin_client.delete("/api/doc-drafts/not-a-uuid").status_code == 422 def test_delete_works_on_pushed_draft_too(admin_client: TestClient, db) -> None: """Nothing on the push side guards the row (no FK targets, no push-side state — the git push happens only on push): a draft that was already pushed is discarding-eligible; the row goes (the already-pushed file in the repo is out of scope — locked A1).""" created = _create(admin_client) token = uuid.UUID(created["token"]) row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one() row.status = "pushed" row.branch = DOCS_BRANCH row.commit_sha = "a" * 40 db.commit() assert admin_client.delete(f"/api/doc-drafts/{token}").status_code == 204 assert db.execute(select(DocDraft)).scalars().first() is None # ---------- auth: anonymous gets 403 on every route ---------- def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None: created = _create(admin_client, body="admin-created") anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie) r = anon.post("/api/doc-drafts", json={"title": "x", "path": "docs/x.md", "body": "y"}) assert r.status_code == 403 assert r.json() == {"detail": "admin only"} assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403 assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403 assert anon.delete(f"/api/doc-drafts/{created['token']}").status_code == 403 # The anonymous attempts changed nothing: exactly the admin's draft # exists, untouched. rows = db.execute(select(DocDraft)).scalars().all() assert len(rows) == 1 assert rows[0].body == "admin-created" # ---------- push (POST /{token}/push — task 04) ---------- # # The push tests run against a **real local bare repo** (the task-03 # unit pattern) and inject the endpoint's settings via the app's # ``Depends(get_settings)`` override (the house pattern — # ``app/api/config.py`` takes ``settings: Settings = Depends( # get_settings)``). Result assertions read the bare repo's state # (``git show :``, ``git rev-parse``), not the response # alone. @pytest.fixture() def bare_docs_repo(tmp_path: Path) -> Path: """A bare origin seeded with one commit on ``main`` (``README.md``).""" if not GIT: pytest.skip("git is not available on this machine") 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_BRANCH) _git( seed, "-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com", "add", "README.md", ) _git( seed, "-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com", "commit", "-m", "seed README", ) _git(seed, "push", "origin", BASE_BRANCH) return bare @pytest.fixture() def docs_push_settings(bare_docs_repo: Path, tmp_path: Path) -> Iterator[Settings]: """Settings pointing at the fixture bare repo, injected into the endpoint's settings dependency; the override is removed after the test (no leak into other tests' settings).""" settings = _settings( docs_repo=str(bare_docs_repo), docs_branch=DOCS_BRANCH, docs_base_branch=BASE_BRANCH, docs_work_dir=str(tmp_path / "docs-workdir"), ) fastapi_app.dependency_overrides[get_settings] = lambda: settings try: yield settings finally: fastapi_app.dependency_overrides.pop(get_settings, None) def test_push_success_commits_and_records_branch_and_sha( admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path ) -> None: created = _create(admin_client) _backdate_updated_at(db, uuid.UUID(created["token"])) r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") assert r.status_code == 200, r.text body = r.json() assert body["status"] == "pushed" assert body["branch"] == DOCS_BRANCH sha = body["commit_sha"] assert len(sha) == 40 # The source of truth is the bare repo's state — not the response: # the file landed on the branch at exactly the returned sha, with # the draft's body, under the fixed per-invocation identity. assert _git(bare_docs_repo, "rev-parse", DOCS_BRANCH).strip() == sha assert _git(bare_docs_repo, "show", f"{DOCS_BRANCH}:{PATH}") == BODY ident = _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%an <%ae>").strip() assert ident == "Brain of Reese " assert _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%s").strip() == ( f"docs: {TITLE}" ) # The DB row records the outcome (status + branch + sha), and the # GET endpoint reports it. row = db.execute( select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"])) ).scalars().one() assert row.status == "pushed" assert row.branch == DOCS_BRANCH assert row.commit_sha == sha got = admin_client.get(f"/api/doc-drafts/{created['token']}").json() assert got["status"] == "pushed" assert got["branch"] == DOCS_BRANCH assert got["commit_sha"] == sha # The success bumped updated_at (past the backdated value). assert datetime.fromisoformat(got["updated_at"]) > datetime.fromisoformat( created["updated_at"] ) def test_push_unconfigured_returns_409_naming_variable(admin_client: TestClient, db) -> None: """Default settings (``docs_repo=""``) → 409 naming the variable; the row stays a draft (D3: inert by default).""" created = _create(admin_client) fastapi_app.dependency_overrides[get_settings] = lambda: _settings() # docs_repo="" try: r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") finally: fastapi_app.dependency_overrides.pop(get_settings, None) assert r.status_code == 409 assert r.json() == {"detail": "docs repo not configured (BOR_DOCS_REPO)"} body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() assert body["status"] == "draft" assert body["branch"] is None assert body["commit_sha"] is None def test_push_non_repo_dir_returns_502_with_git_stderr( admin_client: TestClient, db, tmp_path: Path ) -> None: """A configured repo that is not a git repo → 502 with git's stderr in the detail (the ``GitSyncError`` → ``detail`` mapping); the row stays a draft (only a success mutates).""" plain = tmp_path / "not-a-repo" plain.mkdir() (plain / "file.txt").write_text("not a repo\n", encoding="utf-8") fastapi_app.dependency_overrides[ get_settings ] = lambda: _settings( docs_repo=str(plain), docs_branch=DOCS_BRANCH, docs_base_branch=BASE_BRANCH, docs_work_dir=str(tmp_path / "docs-workdir"), ) created = _create(admin_client) try: r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") finally: fastapi_app.dependency_overrides.pop(get_settings, None) assert r.status_code == 502 detail = r.json()["detail"] # git's stderr is surfaced (the clone refusal of a non-repo dir). assert "failed" in detail assert "fatal: repository" in detail body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() assert body["status"] == "draft" assert body["branch"] is None assert body["commit_sha"] is None def test_push_502_detail_masks_git_stderr_credentials( admin_client: TestClient, db, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 84 (SEC-08): a ``DocsPushError`` whose git stderr carries a ``user:pass@`` remote URL (a ``BOR_DOCS_REPO`` configured with embedded credentials) → the 502 detail is the SANITIZED stderr — the token never reaches the browser or the logs, while the failing repo and git's reason stay readable; the row is untouched (only a success mutates). ``push_document`` is monkeypatched, so no real git is needed.""" def failing_push(**kwargs: Any) -> tuple[str, str]: raise DocsPushError( "git push origin bor-docs failed (exit 128): " "fatal: Authentication failed for " "'https://bot:ghp_LEAKTOKEN@github.com/owner/docs.git/'" ) monkeypatch.setattr(doc_drafts, "push_document", failing_push) fastapi_app.dependency_overrides[ get_settings ] = lambda: _settings( docs_repo="/nonexistent/docs.git", # configured — push_document is faked docs_branch=DOCS_BRANCH, docs_base_branch=BASE_BRANCH, docs_work_dir=str(tmp_path / "docs-workdir"), ) created = _create(admin_client) try: r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") finally: fastapi_app.dependency_overrides.pop(get_settings, None) assert r.status_code == 502 detail = r.json()["detail"] # The credential is masked to the sync masker's shape ... assert "*****@github.com" in detail assert "ghp_LEAKTOKEN" not in detail # ... but the failure context stays readable (the GitSyncError → # detail mapping, otherwise kept). assert "exit 128" in detail assert "fatal: Authentication failed" in detail assert "owner/docs.git" in detail # The row is untouched (status/branch/commit_sha as found). body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() assert body["status"] == "draft" assert body["branch"] is None assert body["commit_sha"] is None row = db.execute( select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"])) ).scalars().one() assert row.status == "draft" assert row.branch is None assert row.commit_sha is None def test_push_unknown_token_returns_404( admin_client: TestClient, docs_push_settings: Settings ) -> None: r = admin_client.post(f"/api/doc-drafts/{uuid.uuid4()}/push") assert r.status_code == 404 assert r.json() == {"detail": "draft not found"} def test_push_rejects_bad_stored_path_with_422( admin_client: TestClient, db, docs_push_settings: Settings ) -> None: """A row whose stored path no longer passes the guard-rails must not be pushable (422 naming the rule — re-validated on push, task 02 helper); the row is untouched.""" bad = DocDraft(token=uuid.uuid4(), title=TITLE, path="../evil.md", body=BODY) db.add(bad) db.commit() db.refresh(bad) r = admin_client.post(f"/api/doc-drafts/{bad.token}/push") assert r.status_code == 422 assert "'..'" in r.json()["detail"] row = db.get(DocDraft, bad.id) assert row is not None assert row.status == "draft" assert row.branch is None assert row.commit_sha is None def test_push_anonymous_returns_403( admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path, ) -> None: created = _create(admin_client) anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie) r = anon.post(f"/api/doc-drafts/{created['token']}/push") assert r.status_code == 403 assert r.json() == {"detail": "admin only"} # The anonymous push attempt changed nothing: no branch on the bare # repo, the row is still a draft (guest reads 403 too). assert _git(bare_docs_repo, "branch", "--list", DOCS_BRANCH).strip() == "" assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403 row = db.execute( select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"])) ).scalars().one() assert row.status == "draft"