- A12 — `og_fetcher` : GET sans `follow_redirects`, `_is_public_host` revérifié à chaque saut (max 5) ; `POST /board/api/og/metadata` → 400 sur hôte privé/loopback - A13 — router automations sous `Depends(_require_session)` (CRUD, run, press-button) + `created_by` sans fallback ; action `webhook` validée par `_is_public_host` avant POST (SSRF) - A15 — webhooks sortants : `_require_admin` sur GET/POST/DELETE + `_is_public_host` sur l'URL en création - A17 — router legacy `/api` sous `Depends(_require_session_or_bearer)` (session ou Bearer `/api/v1`), allowlist explicite `/api/health` + `/api/frontend-error` - A22 — les 2 uploads locales : session exigée (`_require_user_id`) + `validate_upload` branché (taille + extension) + `FLOWDECK_DATA_DIR` au lieu de `/data` codé en dur - A23 — N+1 : COUNT→`GROUP BY` (dashboard), cards→`executemany` (board sync), duplicata de propriétés→`executemany` + remap des ids par SELECT (collections) - A24 — 2 routes écrasées supprimées : `GET /api/projects` (api.py) et `GET /workspace` (workspace.py) + test « aucun doublon méthode+chemin » - Tests : +9 dans `tests/test_audit_p0_fixes.py` (SSRF, 401s, validate_upload, doublons de routes) ; tests OG sur hôtes résolubles (la garde fait du DNS) - suite **1025/1025** · `ruff check app tests` OK
617 lines
26 KiB
Python
617 lines
26 KiB
Python
"""FlowDeck — v5.5.0 "Embeds & Média riche" test suite.
|
|
|
|
Covers the five roadmap points:
|
|
1. Universal embed block → app/services/embeds.py + /board/api/embed/resolve
|
|
2. Bookmark cards → app/services/og_fetcher.py + /board/api/og/metadata
|
|
3. Image lightbox → editor + public page markup
|
|
4. Inline previews → PDF / video / audio rendering
|
|
5. Cover & page icon → /board/api/pages/{id}/cover|icon + public page
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import secrets
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _login(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
login = f"testv55_{secrets.token_hex(4)}"
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
|
|
"VALUES (?, 'Test V55', ?, '', 1)",
|
|
(login, f"{login}@test.com"),
|
|
)
|
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
|
conn.commit()
|
|
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
|
|
r = client.get("/api/csrf-token", cookies={"flowdeck_session": session})
|
|
return session, r.json()["csrf_token"]
|
|
|
|
|
|
def _workspace(client, cookie, name="V55 WS"):
|
|
r = client.post("/workspace", json={"name": name}, cookies={"flowdeck_session": cookie})
|
|
assert r.status_code == 200
|
|
ws = r.json()
|
|
client.cookies.set("flowdeck_workspace", str(ws["id"]))
|
|
return ws["id"]
|
|
|
|
|
|
def _page(client, cookie, csrf, title="V55 Page"):
|
|
r = client.post(
|
|
f"/board/api/pages?title={title}§ion=Private&parent_id=0",
|
|
headers={"X-CSRF-Token": csrf},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
return r.json()["id"]
|
|
|
|
|
|
def _save_blocks(client, cookie, csrf, page_id, blocks, title="V55 Page"):
|
|
r = client.post(
|
|
f"/board/api/pages/{page_id}/blocks",
|
|
json={"title": title, "blocks": blocks},
|
|
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
return r.json()
|
|
|
|
|
|
def _cookie_headers(cookie, csrf=None):
|
|
h = {}
|
|
if csrf:
|
|
h["X-CSRF-Token"] = csrf
|
|
return h
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# 1. Universal embed
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestEmbedService:
|
|
def test_youtube_variants(self):
|
|
from app.services.embeds import embed_src
|
|
|
|
assert embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ") == (
|
|
"https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
)
|
|
assert embed_src("https://youtu.be/dQw4w9WgXcQ") == (
|
|
"https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
)
|
|
assert embed_src("https://www.youtube.com/shorts/abcdef123") == (
|
|
"https://www.youtube.com/embed/abcdef123"
|
|
)
|
|
assert "start=42" in embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42")
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,needle",
|
|
[
|
|
("https://vimeo.com/123456789", "player.vimeo.com/video/123456789"),
|
|
("https://www.figma.com/file/abc123/Doc", "figma.com/embed"),
|
|
("https://www.google.com/maps/place/Eiffel", "maps.google.com/maps?q="),
|
|
("https://docs.google.com/document/d/ABC123/edit", "docs.google.com/document/d/ABC123/preview"),
|
|
("https://docs.google.com/spreadsheets/d/SHEET1/edit", "docs.google.com/spreadsheets/d/SHEET1/preview"),
|
|
("https://www.loom.com/share/" + "a" * 32, "loom.com/embed/" + "a" * 32),
|
|
("https://codepen.io/user/pen/abc123", "codepen.io/user/embed/abc123"),
|
|
("https://miro.com/app/board/uXjV=", "miro.com/app/live-embed/uXjV="),
|
|
("https://open.spotify.com/track/abc123", "open.spotify.com/embed/track/abc123"),
|
|
("https://soundcloud.com/artist/track", "w.soundcloud.com/player/"),
|
|
("https://www.twitch.tv/somestreamer", "player.twitch.tv/?channel=somestreamer"),
|
|
("https://twitter.com/user/status/123456", "Tweet.html?id=123456"),
|
|
("https://x.com/user/status/123456", "Tweet.html?id=123456"),
|
|
("https://www.pinterest.com/pin/12345/", "pinterest.com/pin/embed"),
|
|
("https://example.com/report.docx", "view.officeapps.live.com/op/embed.aspx"),
|
|
],
|
|
)
|
|
def test_providers(self, url, needle):
|
|
from app.services.embeds import embed_src
|
|
|
|
out = embed_src(url)
|
|
assert out and needle in out
|
|
|
|
def test_twitch_parent_is_used(self):
|
|
from app.services.embeds import embed_src
|
|
|
|
out = embed_src("https://www.twitch.tv/chan", parent="flowdeck.example.com")
|
|
assert out.endswith("parent=flowdeck.example.com")
|
|
|
|
def test_unknown_http_falls_back_to_iframe(self):
|
|
from app.services.embeds import embed_src
|
|
|
|
assert embed_src("https://example.com/page") == "https://example.com/page"
|
|
|
|
def test_bare_domain_is_upgraded_to_https(self):
|
|
from app.services.embeds import embed_src
|
|
|
|
assert embed_src("example.com/page") == "https://example.com/page"
|
|
|
|
def test_non_http_is_rejected(self):
|
|
from app.services.embeds import embed_src
|
|
|
|
assert embed_src("mailto:[email protected]") is None
|
|
assert embed_src("") is None
|
|
|
|
def test_inline_kind(self):
|
|
from app.services.embeds import inline_kind
|
|
|
|
assert inline_kind("https://ex.com/a.png") == "image"
|
|
assert inline_kind("https://ex.com/a.pdf") == "pdf"
|
|
assert inline_kind("https://ex.com/a.mp4") == "video"
|
|
assert inline_kind("https://ex.com/a.mp3") == "audio"
|
|
assert inline_kind("https://ex.com/page") == "iframe"
|
|
assert inline_kind("nonsense") is None
|
|
|
|
def test_resolve_embed(self):
|
|
from app.services.embeds import resolve_embed
|
|
|
|
out = resolve_embed("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
assert out["provider"] == "youtube"
|
|
assert out["kind"] == "iframe"
|
|
assert out["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
|
|
def test_embed_html_is_responsive(self):
|
|
from app.services.embeds import embed_html
|
|
|
|
html = embed_html("https://ex.com/embed", height=360)
|
|
assert "<iframe" in html
|
|
assert "height:360px" in html
|
|
|
|
|
|
class TestEmbedResolveEndpoint:
|
|
def test_resolve_endpoint(self, client):
|
|
cookie, csrf = _login(client)
|
|
r = client.post(
|
|
"/board/api/embed/resolve",
|
|
json={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["ok"] is True
|
|
assert data["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
assert data["provider"] == "youtube"
|
|
|
|
def test_resolve_requires_url(self, client):
|
|
cookie, csrf = _login(client)
|
|
r = client.post(
|
|
"/board/api/embed/resolve",
|
|
json={},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# 2. Bookmark cards / Open Graph
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
_OG_HTML = """<!doctype html><html><head>
|
|
<title>Fallback title</title>
|
|
<meta content="FlowDeck — Notion clone" property="og:title">
|
|
<meta property="og:description" content="Plan, write and ship.">
|
|
<meta name="twitter:image" content="/static/preview.png">
|
|
<meta property="og:site_name" content="FlowDeck">
|
|
<link rel="shortcut icon" href="/favicon.ico">
|
|
</head><body>hi</body></html>"""
|
|
|
|
|
|
class TestOGParser:
|
|
def test_parse_og_handles_attribute_order(self):
|
|
from app.services.og_fetcher import parse_og
|
|
|
|
data = parse_og(_OG_HTML, "https://flowdeck.example.com/page")
|
|
assert data["title"] == "FlowDeck — Notion clone"
|
|
assert data["description"] == "Plan, write and ship."
|
|
assert data["site_name"] == "FlowDeck"
|
|
assert data["image"] == "https://flowdeck.example.com/static/preview.png"
|
|
assert data["favicon"] == "https://flowdeck.example.com/favicon.ico"
|
|
|
|
def test_parse_og_title_fallback(self):
|
|
from app.services.og_fetcher import parse_og
|
|
|
|
data = parse_og("<html><head><title>Plain title</title></head></html>", "https://ex.com")
|
|
assert data["title"] == "Plain title"
|
|
|
|
def test_fetch_og_metadata_success(self):
|
|
from app.services.og_fetcher import fetch_og_metadata
|
|
|
|
def handler(request):
|
|
return httpx.Response(200, headers={"content-type": "text/html"}, text=_OG_HTML)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
data = asyncio.run(fetch_og_metadata("https://example.com/page", transport=transport))
|
|
assert data["title"] == "FlowDeck — Notion clone"
|
|
assert data["site_name"] == "FlowDeck"
|
|
|
|
def test_fetch_og_metadata_non_html(self):
|
|
from app.services.og_fetcher import fetch_og_metadata
|
|
|
|
def handler(request):
|
|
return httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF")
|
|
|
|
data = asyncio.run(
|
|
fetch_og_metadata("https://example.com/file.pdf", transport=httpx.MockTransport(handler))
|
|
)
|
|
assert data["title"] == "example.com"
|
|
|
|
def test_fetch_og_metadata_network_error_is_safe(self):
|
|
from app.services.og_fetcher import fetch_og_metadata
|
|
|
|
def handler(request):
|
|
raise httpx.ConnectError("boom")
|
|
|
|
data = asyncio.run(
|
|
fetch_og_metadata("https://example.com/unreachable", transport=httpx.MockTransport(handler))
|
|
)
|
|
assert data["url"].startswith("https://example.com/unreachable")
|
|
assert data["title"] == "example.com"
|
|
|
|
|
|
class TestOGMetadataEndpoint:
|
|
def test_endpoint_returns_payload(self, client, monkeypatch):
|
|
cookie, csrf = _login(client)
|
|
|
|
async def fake_fetch(url, timeout=6.0, transport=None):
|
|
return {
|
|
"url": url,
|
|
"title": "Mocked title",
|
|
"description": "Mocked description",
|
|
"image": "https://ex.com/img.png",
|
|
"site_name": "Ex",
|
|
"favicon": "",
|
|
}
|
|
|
|
monkeypatch.setattr("app.services.og_fetcher.fetch_og_metadata", fake_fetch)
|
|
r = client.post(
|
|
"/board/api/og/metadata",
|
|
json={"url": "https://ex.com"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["ok"] is True
|
|
assert data["title"] == "Mocked title"
|
|
|
|
|
|
class TestBookmarkPersistence:
|
|
def test_bookmark_roundtrip(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Bookmark")
|
|
blocks = [{
|
|
"type": "bookmark",
|
|
"url": "https://example.com",
|
|
"title": "Example",
|
|
"description": "A test site",
|
|
"image": "https://example.com/og.png",
|
|
"site_name": "Example",
|
|
}]
|
|
_save_blocks(client, cookie, csrf, page, blocks, "Bookmark")
|
|
saved = json.loads(
|
|
client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"]
|
|
)
|
|
assert saved[0]["type"] == "bookmark"
|
|
assert saved[0]["image"] == "https://example.com/og.png"
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# 3. Image lightbox
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestLightbox:
|
|
def test_editor_lightbox_supports_keyboard_navigation(self):
|
|
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
|
assert "_openLightbox" in tpl
|
|
assert "fd-lightbox-nav" in tpl
|
|
assert "ArrowRight" in tpl and "ArrowLeft" in tpl
|
|
assert "Escape" in tpl
|
|
|
|
def test_public_page_lightbox_markup(self):
|
|
tpl = Path("app/templates/public_page.html").read_text(encoding="utf-8")
|
|
assert "fd-lightbox" in tpl
|
|
assert "data-full" in tpl
|
|
assert "ArrowRight" in tpl and "ArrowLeft" in tpl
|
|
|
|
def test_public_image_has_zoom_cursor(self):
|
|
from app.routers.dashboard import _render_blocks_public
|
|
|
|
html = _render_blocks_public([{"type": "image", "src": "https://ex.com/a.png", "alt": "A"}])
|
|
assert 'data-full="https://ex.com/a.png"' in html
|
|
assert "cursor:zoom-in" in html
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# 4. Inline previews (PDF / video / audio)
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestInlinePreviews:
|
|
def test_editor_renders_video_audio_pdf(self):
|
|
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
|
assert "<video controls" in tpl
|
|
assert "<audio controls" in tpl
|
|
assert "embed_type==='pdf'" in tpl
|
|
|
|
def test_public_renders_video_audio_pdf(self):
|
|
from app.routers.dashboard import _render_blocks_public
|
|
|
|
blocks = [
|
|
{"type": "video", "src": "https://ex.com/v.mp4"},
|
|
{"type": "audio", "src": "https://ex.com/a.mp3"},
|
|
{"type": "embed", "src": "https://ex.com/doc.pdf", "embed_type": "pdf"},
|
|
]
|
|
html = _render_blocks_public(blocks)
|
|
assert "<video" in html
|
|
assert "<audio" in html
|
|
assert "doc.pdf" in html and "<iframe" in html
|
|
|
|
def test_public_embed_uses_resolved_src(self):
|
|
from app.routers.dashboard import _render_blocks_public
|
|
|
|
html = _render_blocks_public([{
|
|
"type": "embed",
|
|
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
}])
|
|
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
|
|
|
def test_public_embed_resolves_when_no_cached_src(self):
|
|
from app.routers.dashboard import _render_blocks_public
|
|
|
|
html = _render_blocks_public([{
|
|
"type": "embed",
|
|
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
}])
|
|
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
|
|
|
def test_embed_src_field_persists(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Embed persist")
|
|
blocks = [{
|
|
"type": "embed",
|
|
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
"embed_provider": "youtube",
|
|
"height": 480,
|
|
}]
|
|
_save_blocks(client, cookie, csrf, page, blocks, "Embed persist")
|
|
saved = json.loads(
|
|
client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"]
|
|
)
|
|
assert saved[0]["embed_src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
assert saved[0]["embed_provider"] == "youtube"
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# 5. Cover & page icon
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestCoverIcon:
|
|
def test_set_and_remove_cover_url(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Cover")
|
|
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/cover",
|
|
json={"cover_url": "https://example.com/cover.png"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["cover_url"] == "https://example.com/cover.png"
|
|
|
|
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
|
assert r2.json()["cover_url"] == "https://example.com/cover.png"
|
|
|
|
r3 = client.delete(
|
|
f"/board/api/pages/{page}/cover",
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r3.status_code == 200
|
|
r4 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
|
assert r4.json()["cover_url"] in ("", None)
|
|
|
|
def test_upload_cover_file(self, client, monkeypatch, tmp_path):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Cover upload")
|
|
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
|
|
|
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/cover",
|
|
files={"file": ("banner.png", png, "image/png")},
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
url = r.json()["cover_url"]
|
|
assert url.startswith("/api/files/")
|
|
assert url.endswith(".png")
|
|
assert (tmp_path / "uploads").exists()
|
|
|
|
def test_upload_rejects_non_image(self, client, monkeypatch, tmp_path):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Bad upload")
|
|
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/cover",
|
|
files={"file": ("evil.sh", b"#!/bin/sh", "text/plain")},
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
def test_set_page_icon(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Icon")
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/icon",
|
|
json={"icon": "🚀"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["icon"] == "🚀"
|
|
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
|
assert r2.json()["page_icon"] == "🚀"
|
|
|
|
def test_icon_too_long_rejected(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Icon long")
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/icon",
|
|
json={"icon": "x" * 600},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
def test_custom_emoji_url_accepted_as_icon(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Custom icon")
|
|
url = "/api/files/1/emoji_123_cat.png"
|
|
r = client.post(
|
|
f"/board/api/pages/{page}/icon",
|
|
json={"icon": url},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
|
assert r2.json()["page_icon"] == url
|
|
|
|
def test_public_page_renders_cover_and_icon(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
page = _page(client, cookie, csrf, "Public")
|
|
_save_blocks(client, cookie, csrf, page, [{"type": "paragraph", "content": "Hi"}], "Public")
|
|
client.post(
|
|
f"/board/api/pages/{page}/cover",
|
|
json={"cover_url": "https://example.com/c.jpg"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
client.post(
|
|
f"/board/api/pages/{page}/icon",
|
|
json={"icon": "🌟"},
|
|
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
r = client.post(
|
|
f"/api/pages/{page}/publish",
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
slug = r.json()["publish_slug"]
|
|
html = client.get(f"/p/{slug}").text
|
|
assert "c.jpg" in html
|
|
assert "🌟" in html
|
|
assert "fd-lightbox" in html
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# Regression: the editor must call the board-owned endpoints (v5.5.0 bug)
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestEditorEndpointPaths:
|
|
def test_editor_uses_board_prefixed_endpoints(self):
|
|
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
|
for endpoint in (
|
|
"/board/api/pages/'+this.pid+'/cover",
|
|
"/board/api/pages/'+this.pid+'/icon",
|
|
"/board/api/pages/'+this.pid+'/versions",
|
|
"/board/api/pages/'+this.pid+'/backlinks",
|
|
"/board/api/og/metadata",
|
|
"/board/api/embed/resolve",
|
|
):
|
|
assert endpoint in tpl, f"missing editor endpoint: {endpoint}"
|
|
# the old, broken paths must be gone
|
|
assert "fetch('/api/pages/'+this.pid+'/cover" not in tpl
|
|
assert "fetch('/api/pages/'+this.pid+'/icon" not in tpl
|
|
assert "fetch('/api/pages/'+this.pid+'/versions" not in tpl
|
|
assert "fetch('/api/pages/'+this.pid+'/backlinks" not in tpl
|
|
assert "fetch('/api/og/metadata" not in tpl
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# v5.6.0: custom workspace emojis
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestCustomEmojis:
|
|
def test_list_empty(self, client):
|
|
cookie, _ = _login(client)
|
|
_workspace(client, cookie)
|
|
r = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie})
|
|
assert r.status_code == 200
|
|
assert r.json()["emojis"] == []
|
|
|
|
def test_upload_and_delete(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
png = b"\x89PNG\r\n\x1a\n" + b"0" * 16
|
|
r = client.post(
|
|
"/api/custom-emojis",
|
|
data={"name": "pushup"},
|
|
files={"file": ("pushup.png", png, "image/png")},
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 200
|
|
emoji = r.json()["emoji"]
|
|
assert emoji["name"] == "pushup"
|
|
assert emoji["url"].startswith("/api/files/")
|
|
|
|
listed = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie}).json()["emojis"]
|
|
assert any(e["id"] == emoji["id"] for e in listed)
|
|
|
|
d = client.request(
|
|
"DELETE",
|
|
f"/api/custom-emojis/{emoji['id']}",
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert d.status_code == 200
|
|
after = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie}).json()["emojis"]
|
|
assert all(e["id"] != emoji["id"] for e in after)
|
|
|
|
def test_reject_non_image(self, client):
|
|
cookie, csrf = _login(client)
|
|
_workspace(client, cookie)
|
|
r = client.post(
|
|
"/api/custom-emojis",
|
|
data={"name": "bad"},
|
|
files={"file": ("evil.sh", b"#!/bin/sh", "text/plain")},
|
|
headers=_cookie_headers(cookie, csrf),
|
|
cookies={"flowdeck_session": cookie},
|
|
)
|
|
assert r.status_code == 400
|
|
|