a5cea7cd74
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex greenlit for this narrow, admin-only surface). * markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text. Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong, i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles (content kept as escaped text) and discards script/style content entirely. * API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html, derives plain text → stored message + the email text/plain part. Unchanged: multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422. * Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List), execCommand with styleWithCSS=false for semantic tags, font size wraps the selection in a fixed-px span, paste intercepted as plain text. No links yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
6.6 KiB
Python
131 lines
6.6 KiB
Python
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _app(tmp_path, monkeypatch, admin=""):
|
|
db = tmp_path / "t.sqlite3"
|
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
|
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", admin)
|
|
import importlib
|
|
import goodnews.api as api
|
|
importlib.reload(api)
|
|
from goodnews.db import connect, init_db
|
|
connect(str(db)).close()
|
|
c = connect(str(db)); init_db(c); c.close()
|
|
return api.create_app(), api, db
|
|
|
|
|
|
def _count(db):
|
|
from goodnews.db import connect
|
|
c = connect(str(db)); n = c.execute("SELECT COUNT(*) FROM feedback").fetchone()[0]; c.close()
|
|
return n
|
|
|
|
|
|
def test_feedback_stored_and_validated(tmp_path, monkeypatch):
|
|
app, api, db = _app(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
|
tc = TestClient(app)
|
|
assert tc.post("/api/feedback", json={"message": " "}).status_code == 422 # empty
|
|
assert tc.post("/api/feedback", json={"category": "idea", "message": "Love it", "visitor": "v"}).json() == {"ok": True}
|
|
assert _count(db) == 1
|
|
# honeypot tripped → accepted but NOT stored
|
|
tc.post("/api/feedback", json={"message": "spam", "hp": "i am a bot", "visitor": "v"})
|
|
assert _count(db) == 1
|
|
|
|
|
|
def test_admin_feedback_gated(tmp_path, monkeypatch):
|
|
app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com")
|
|
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
|
TestClient(app).post("/api/feedback", json={"message": "hi", "visitor": "v"})
|
|
assert TestClient(app).get("/api/admin/feedback").status_code == 401 # anon
|
|
|
|
# sign in as admin
|
|
tc = TestClient(app); sent = {}
|
|
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(link=link))
|
|
tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
|
|
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
|
fb = tc.get("/api/admin/feedback").json()
|
|
assert len(fb) == 1 and fb[0]["message"] == "hi"
|
|
stats = tc.get("/api/admin/stats").json()
|
|
assert {"funnel", "accounts", "retention", "emotional_mix", "paywall", "replace"} <= set(stats)
|
|
assert stats["accounts"]["total"] >= 1
|
|
|
|
|
|
def _admin_client(tmp_path, monkeypatch):
|
|
app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com")
|
|
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
|
TestClient(app).post("/api/feedback", json={"message": "hello there", "visitor": "v"})
|
|
tc = TestClient(app); sent = {}
|
|
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(link=link))
|
|
tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
|
|
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
|
return tc
|
|
|
|
|
|
def test_feedback_read_unread_and_delete(tmp_path, monkeypatch):
|
|
tc = _admin_client(tmp_path, monkeypatch)
|
|
fb = tc.get("/api/admin/feedback").json()
|
|
fid = fb[0]["id"]
|
|
assert fb[0]["read_at"] is None # starts unread
|
|
assert tc.get("/api/admin/stats").json()["feedback_unread"] == 1
|
|
|
|
tc.post(f"/api/admin/feedback/{fid}/read", json={"read": True})
|
|
assert tc.get("/api/admin/feedback").json()[0]["read_at"] is not None
|
|
assert tc.get("/api/admin/stats").json()["feedback_unread"] == 0
|
|
|
|
tc.post(f"/api/admin/feedback/{fid}/read", json={"read": False}) # back to unread
|
|
assert tc.get("/api/admin/stats").json()["feedback_unread"] == 1
|
|
|
|
assert tc.delete(f"/api/admin/feedback/{fid}").json() == {"ok": True}
|
|
assert tc.get("/api/admin/feedback").json() == []
|
|
|
|
|
|
def test_feedback_admin_actions_gated(tmp_path, monkeypatch):
|
|
app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com")
|
|
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
|
TestClient(app).post("/api/feedback", json={"message": "hi", "visitor": "v"})
|
|
anon = TestClient(app)
|
|
assert anon.post("/api/admin/feedback/1/read", json={"read": True}).status_code == 401
|
|
assert anon.delete("/api/admin/feedback/1").status_code == 401
|
|
assert anon.post("/api/admin/feedback/1/reply", json={"html": "hi"}).status_code == 401
|
|
|
|
|
|
def test_feedback_actions_404_on_missing_id(tmp_path, monkeypatch):
|
|
tc = _admin_client(tmp_path, monkeypatch)
|
|
assert tc.post("/api/admin/feedback/9999/read", json={"read": True}).status_code == 404
|
|
assert tc.delete("/api/admin/feedback/9999").status_code == 404
|
|
|
|
|
|
def test_feedback_reply_sends_stores_marks_read(tmp_path, monkeypatch):
|
|
app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com")
|
|
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
|
sent = {}
|
|
monkeypatch.setattr(api.email_send, "send_feedback_reply",
|
|
lambda to, msg, html, orig: sent.update(to=to, msg=msg, html=html, orig=orig))
|
|
TestClient(app).post("/api/feedback", json={"message": "is there an app?", "email": "reader@x.com", "visitor": "v"})
|
|
tc = TestClient(app); link = {}
|
|
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, l: link.update(l=l))
|
|
tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
|
|
tc.post("/api/auth/email/verify", json={"token": link["l"].split("token=")[1]})
|
|
fid = tc.get("/api/admin/feedback").json()[0]["id"]
|
|
r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "Yes — a <b>companion app</b> is coming."})
|
|
assert r.status_code == 200
|
|
assert sent["to"] == "reader@x.com" and "companion app" in sent["msg"] and "is there an app?" in sent["orig"]
|
|
assert "<strong>companion app</strong>" in sent["html"] # sanitized editor HTML (b→strong)
|
|
fb = tc.get("/api/admin/feedback").json()[0]
|
|
assert fb["read_at"] is not None # marked read on reply
|
|
rep = fb["replies"][0]
|
|
assert len(fb["replies"]) == 1 and rep["sent_to"] == "reader@x.com"
|
|
assert "<strong>companion app</strong>" in rep["message_html"] # stored HTML
|
|
|
|
|
|
def test_feedback_reply_requires_address_and_message(tmp_path, monkeypatch):
|
|
tc = _admin_client(tmp_path, monkeypatch) # posts anonymous feedback (no email)
|
|
monkeypatch.setattr("goodnews.email_send.send_feedback_reply",
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
|
|
fid = tc.get("/api/admin/feedback").json()[0]["id"]
|
|
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": " "}).status_code == 422
|
|
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "hi"}).status_code == 400 # no address
|
|
assert tc.post("/api/admin/feedback/9999/reply", json={"html": "hi"}).status_code == 404
|