"""End-to-end HTTP tests for the Phase 4 admin CMS. Exercises the real :class:`FastAPI` app constructed by :func:`app.main.create_app` against a temp-file SQLite database plus a temp media directory. Uses the email-capture trick from ``tests/test_admin_routes.py`` to complete the magic-link login so we have a real authenticated session + CSRF cookie. """ from __future__ import annotations import importlib import io import re from pathlib import Path from typing import Iterator import pytest from fastapi.testclient import TestClient from PIL import Image from sqlalchemy import text _CSRF_META_RE = re.compile( r' Iterator[tuple[TestClient, dict, Path]]: """Build a fresh authed app + temp media dir + captured email URL.""" media_root = tmp_path / "media" media_root.mkdir() monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/cms.db") monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com") monkeypatch.setenv("APP_ENV", "development") monkeypatch.setenv( "SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ" ) monkeypatch.setenv("RESEND_API_KEY", "") monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver") monkeypatch.setenv("MEDIA_ROOT", str(media_root)) from app import config as _config _config.get_settings.cache_clear() import app.main as main_module importlib.reload(main_module) app = main_module.app captured: dict[str, str] = {} def _capture(**kw): captured["url"] = kw["url"] captured["to"] = kw["to"] app.state.email_service.send_magic_link = _capture # type: ignore[assignment] from app.services.rate_limit import limiter limiter.reset() with TestClient(app) as client: yield client, captured, media_root _config.get_settings.cache_clear() def _login(client: TestClient, captured: dict) -> None: """Walk the magic-link flow so subsequent requests are authed.""" client.post("/admin/login", data={"email": "headhen@example.com"}) assert "url" in captured consume_path = captured["url"].replace("http://testserver", "") resp = client.get(consume_path, follow_redirects=False) assert resp.status_code == 303 def _dashboard_csrf(client: TestClient) -> str: """GET /admin and extract the csrf-token meta tag.""" resp = client.get("/admin") assert resp.status_code == 200 match = _CSRF_META_RE.search(resp.text) assert match, "csrf-token meta tag missing" return match.group(1) # --------------------------------------------------------------------------- # unauthed # --------------------------------------------------------------------------- def test_dashboard_redirects_unauthed_to_login(cms_app) -> None: """An unauthenticated GET /admin yields 303 to /admin/login.""" client, _, _ = cms_app resp = client.get("/admin", follow_redirects=False) assert resp.status_code == 303 assert resp.headers["location"] == "/admin/login" def test_post_create_without_auth_is_redirected(cms_app) -> None: """Create POST is gated behind require_admin.""" client, _, _ = cms_app resp = client.post( "/admin/posts", data={"title": "x", "body_md": "y", "status": "draft", "csrf_token": ""}, follow_redirects=False, ) # Unauthenticated -> require_admin raises 303. assert resp.status_code == 303 # --------------------------------------------------------------------------- # dashboard # --------------------------------------------------------------------------- def test_dashboard_renders_with_seeded_post(cms_app) -> None: """The seeded welcome post shows up in the admin table.""" client, captured, _ = cms_app _login(client, captured) resp = client.get("/admin") assert resp.status_code == 200 assert "Dashboard" in resp.text assert "Welcome to the Farm" in resp.text assert "New post" in resp.text assert "Edit About" in resp.text # --------------------------------------------------------------------------- # create + edit + delete # --------------------------------------------------------------------------- def test_create_post_happy_path(cms_app) -> None: """POST /admin/posts with CSRF creates a row and 303s to the dashboard.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) resp = client.post( "/admin/posts", data={ "title": "First Admin Post", "body_md": "Hello **world**.", "status": "draft", "csrf_token": csrf, }, follow_redirects=False, ) assert resp.status_code == 303 assert resp.headers["location"].startswith("/admin?msg=created") # The new post is visible on the dashboard. dash = client.get("/admin") assert "First Admin Post" in dash.text def test_create_post_without_csrf_is_forbidden(cms_app) -> None: """Missing csrf_token on create returns 403.""" client, captured, _ = cms_app _login(client, captured) # Touch /admin to materialize the cb_csrf cookie. client.get("/admin") resp = client.post( "/admin/posts", data={ "title": "Sneaky", "body_md": "x", "status": "draft", "csrf_token": "", # wrong }, follow_redirects=False, ) assert resp.status_code == 403 def test_update_and_publish_and_delete(cms_app) -> None: """Full CRUD + publish toggle + delete cycle.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) # create client.post( "/admin/posts", data={ "title": "Toggle Target", "body_md": "body-v1", "status": "draft", "csrf_token": csrf, }, ) # look up the post id via the engine with client.app.state.engine.connect() as conn: row = conn.execute( text("SELECT id FROM posts WHERE title = :t"), {"t": "Toggle Target"}, ).mappings().first() assert row is not None post_id = int(row["id"]) # edit form renders edit_resp = client.get(f"/admin/posts/{post_id}/edit") assert edit_resp.status_code == 200 assert "Toggle Target" in edit_resp.text # CSRF for subsequent POSTs should come off the edit page. match = _CSRF_META_RE.search(edit_resp.text) assert match csrf = match.group(1) # update upd = client.post( f"/admin/posts/{post_id}", data={ "title": "Toggle Target Edited", "body_md": "body-v2", "csrf_token": csrf, }, follow_redirects=False, ) assert upd.status_code == 303 # publish pub = client.post( f"/admin/posts/{post_id}/publish", data={"csrf_token": csrf}, follow_redirects=False, ) assert pub.status_code == 303 assert pub.headers["location"] == "/admin?msg=published" # unpublish unpub = client.post( f"/admin/posts/{post_id}/publish", data={"csrf_token": csrf}, follow_redirects=False, ) assert unpub.status_code == 303 assert unpub.headers["location"] == "/admin?msg=unpublished" # delete delete = client.post( f"/admin/posts/{post_id}/delete", data={"csrf_token": csrf}, follow_redirects=False, ) assert delete.status_code == 303 assert delete.headers["location"] == "/admin?msg=deleted" # row is gone with client.app.state.engine.connect() as conn: gone = conn.execute( text("SELECT id FROM posts WHERE id = :i"), {"i": post_id}, ).first() assert gone is None def test_create_post_rejects_blank_title(cms_app) -> None: """Blank title re-renders the form at 400.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) resp = client.post( "/admin/posts", data={ "title": " ", "body_md": "x", "status": "draft", "csrf_token": csrf, }, ) assert resp.status_code == 400 assert "Title is required" in resp.text # --------------------------------------------------------------------------- # About page # --------------------------------------------------------------------------- def test_about_update(cms_app) -> None: """Editing the About page updates the stored copy.""" client, captured, _ = cms_app _login(client, captured) form_resp = client.get("/admin/pages/about/edit") assert form_resp.status_code == 200 match = _CSRF_META_RE.search(form_resp.text) assert match csrf = match.group(1) resp = client.post( "/admin/pages/about", data={ "title": "Re-titled About", "body_md": "Edited **copy**.", "csrf_token": csrf, }, follow_redirects=False, ) assert resp.status_code == 303 assert resp.headers["location"] == "/admin?msg=saved" # Public site reflects the change (cache was invalidated). public = client.get("/about") assert public.status_code == 200 assert "Re-titled About" in public.text assert "copy" in public.text # --------------------------------------------------------------------------- # media upload # --------------------------------------------------------------------------- def _jpeg_bytes() -> bytes: img = Image.new("RGB", (32, 32), "blue") buf = io.BytesIO() img.save(buf, format="JPEG") return buf.getvalue() def test_media_upload_happy_path(cms_app) -> None: """Valid JPEG upload with header CSRF returns JSON with a /media URL.""" client, captured, media_root = cms_app _login(client, captured) csrf = _dashboard_csrf(client) data = _jpeg_bytes() files = {"file": ("photo.jpg", data, "image/jpeg")} resp = client.post( "/admin/media/upload", files=files, headers={"X-CSRF-Token": csrf}, ) assert resp.status_code == 200, resp.text payload = resp.json() assert payload["url"].startswith("/media/") assert payload["filename"].endswith(".jpg") # The file actually exists under the media root. stored = Path(media_root) assert any(stored.rglob("*.jpg")) def test_media_upload_missing_csrf_is_forbidden(cms_app) -> None: """Without the X-CSRF-Token header the upload is 403.""" client, captured, _ = cms_app _login(client, captured) # Warm cookie. client.get("/admin") files = {"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")} resp = client.post("/admin/media/upload", files=files) assert resp.status_code == 403 def test_media_upload_rejects_non_image(cms_app) -> None: """Plain text rejected with a JSON error payload.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) files = {"file": ("fake.jpg", b"not an image at all, really not", "image/jpeg")} resp = client.post( "/admin/media/upload", files=files, headers={"X-CSRF-Token": csrf}, ) assert resp.status_code == 400 assert "error" in resp.json() # --------------------------------------------------------------------------- # preview # --------------------------------------------------------------------------- def test_preview_renders_sanitized_html(cms_app) -> None: """Preview endpoint returns the same sanitized HTML as server-side.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) resp = client.post( "/admin/preview", data={"markdown": "Hello **there**."}, headers={"X-CSRF-Token": csrf}, ) assert resp.status_code == 200 assert "there" in resp.text def test_preview_strips_raw_html(cms_app) -> None: """Raw HTML in the markdown input is sanitized away.""" client, captured, _ = cms_app _login(client, captured) csrf = _dashboard_csrf(client) resp = client.post( "/admin/preview", data={"markdown": "\n\nOK."}, headers={"X-CSRF-Token": csrf}, ) assert resp.status_code == 200 assert "