Files
brain-of-reese/tests/integration/test_doc_drafts_api.py
T

537 lines
19 KiB
Python

"""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
from app.config import Settings, get_settings
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
# ---------- 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
# 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 <branch>:<path>``, ``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 <bor@local>"
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_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"