feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user