From c4ea329f9b577fc914e3b508b6fbeb4f1da3d41f Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 21:55:38 -0400 Subject: [PATCH] Candidate rename hardening (Codex): pending-only + length cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small server-side tweaks so the endpoint matches the UI policy: - Rename is refused (409) for promoted/rejected candidates — they're settled history; the UI already hides Rename for them, now the server enforces it too. - Name is capped at 160 chars before save, so an accidental pasted paragraph can't wreck the queue layout. Tests extended: 300-char name truncates to 160; renaming a promoted candidate → 409. 225 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 --- goodnews/api.py | 11 ++++++++--- tests/test_admin.py | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/goodnews/api.py b/goodnews/api.py index a72add4..2b82025 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1205,10 +1205,15 @@ def create_app() -> FastAPI: def admin_candidate_rename(cid: int, body: CandidateRenameBody, request: Request) -> dict: with get_conn() as conn: _require_admin(conn, request) - try: - row = sources.rename_candidate(conn, cid, (body.name or "").strip() or None) - except ValueError: + cand = conn.execute("SELECT status FROM source_candidates WHERE id = ?", (cid,)).fetchone() + if not cand: raise HTTPException(status_code=404, detail="candidate not found") + # Match the UI policy server-side: a promoted/rejected candidate is + # settled history — rename only while it's pending review. + if cand["status"] in ("promoted", "rejected"): + raise HTTPException(status_code=409, detail=f"Can't rename a {cand['status']} candidate.") + name = (body.name or "").strip()[:160] or None # cap so a pasted paragraph can't wreck the queue + row = sources.rename_candidate(conn, cid, name) return _candidate_dict(row) @app.post("/api/admin/candidates/{cid}/promote") diff --git a/tests/test_admin.py b/tests/test_admin.py index aea1fe7..66e3264 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -179,9 +179,15 @@ def test_candidate_rename(tmp_path, monkeypatch): fixed = tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Right Name"}).json() assert fixed["name"] == "Right Name" and fixed["preview"]["accepted"] == 2 assert any(c["id"] == cid and c["name"] == "Right Name" for c in tc.get("/api/admin/candidates").json()) + # Length is capped so an accidental pasted paragraph can't wreck the queue. + capped = tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "z" * 300}).json() + assert len(capped["name"]) == 160 + tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Right Name"}) # restore # The fixed name carries through to promotion. res = tc.post(f"/api/admin/candidates/{cid}/promote", json={}).json() assert res["source"]["name"] == "Right Name" + # Settled candidates (promoted/rejected) can't be renamed server-side, not just hidden in the UI. + assert tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Nope"}).status_code == 409 # Gated; unknown id → 404. assert TestClient(app).post(f"/api/admin/candidates/{cid}/rename", json={"name": "x"}).status_code == 401 assert tc.post("/api/admin/candidates/99999/rename", json={"name": "x"}).status_code == 404