Promote-candidate UI: add-a-source pipeline in the admin console
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.
* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
http(s) only, every redirect hop re-validated via enrich._host_is_public,
body size-capped, bounded redirects, no cookies. preview_feed gains a
`fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
before the outbound fetch, no DB conn held during network); /{id}/preview
(explicit re-preview); /{id}/promote (paused by default, returns the new
source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
preview (pass rate, recent count, example headlines) with Promote (as paused,
or Activate immediately) / Re-preview / Reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,3 +123,40 @@ def test_admin_stats_days_param_clamped(tmp_path, monkeypatch):
|
||||
assert tc.get("/api/admin/stats?days=90").json()["days"] == 90
|
||||
assert tc.get("/api/admin/stats?days=999").json()["days"] == 30 # clamped
|
||||
assert tc.get("/api/admin/stats").json()["days"] == 30 # default
|
||||
|
||||
|
||||
def test_candidate_suggest_promote_paused(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
monkeypatch.setattr(api.feeds, "preview_feed",
|
||||
lambda url, **k: {"url": url, "sampled": 5, "accepted": 4, "examples_accepted": ["A", "B"]})
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
cand = tc.post("/api/admin/candidates", json={"feed_url": "http://good/feed", "name": "Good Feed"}).json()
|
||||
assert cand["status"] == "suggested" and cand["preview"]["accepted"] == 4
|
||||
cid = cand["id"]
|
||||
assert any(c["id"] == cid for c in tc.get("/api/admin/candidates").json())
|
||||
# promote defaults to paused (active-on-approval off) — no mirror drift
|
||||
res = tc.post(f"/api/admin/candidates/{cid}/promote", json={}).json()
|
||||
assert res["source"]["status"] == "paused" and res["source"]["active"] == 0
|
||||
assert res["candidate"]["status"] == "promoted"
|
||||
assert any(s["name"] == "Good Feed" for s in tc.get("/api/admin/stats").json()["sources"])
|
||||
|
||||
|
||||
def test_candidate_reject_and_gating(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 1, "accepted": 0})
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
cand = tc.post("/api/admin/candidates", json={"feed_url": "http://meh/feed"}).json()
|
||||
assert tc.post(f"/api/admin/candidates/{cand['id']}/reject").json()["status"] == "rejected"
|
||||
anon = TestClient(app)
|
||||
assert anon.get("/api/admin/candidates").status_code == 401
|
||||
assert anon.post("/api/admin/candidates", json={"feed_url": "http://x/f"}).status_code == 401
|
||||
assert anon.post("/api/admin/candidates/1/promote", json={}).status_code == 401
|
||||
|
||||
|
||||
def test_safe_fetch_feed_blocks_ssrf():
|
||||
import pytest
|
||||
from goodnews.feeds import safe_fetch_feed
|
||||
for bad in ("http://127.0.0.1/x", "http://localhost/x", "file:///etc/passwd",
|
||||
"http://169.254.169.254/latest", "ftp://x/y"):
|
||||
with pytest.raises(RuntimeError):
|
||||
safe_fetch_feed(bad, timeout=2)
|
||||
|
||||
Reference in New Issue
Block a user