All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified. **Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report** - Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed. - **Completion criteria:** 1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass. 2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes. 3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green. 4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors. 5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree). - No defects found; no deviations. - Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
656 lines
24 KiB
Python
656 lines
24 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
|
|
|
|
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 <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_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"
|