- `_page_editor_scripts.html` : le gros bloc interpolé (2 516 L) part vers
`static/js/page_editor_scripts.js` — recette « config JSON » : les 8
interpolations Jinja lisent `PD = JSON.parse(#page-data)`, bloc JSON qui
EXISTAIT DÉJÀ juste avant le script (même ordre d'exécution), garde
`__fdEditorScriptsLoaded` préservée, node --check vert.
- Route `view_page_root` : page_data enrichi de updated_at, created_at,
user_id, is_shared (dérivé HOISTÉ : une seule expression sert le ctx ET le
JSON) et clip_icon (macro fd_icon rendue côté serveur). workspace_key reste
vide comme avant (jamais défini dans ce ctx → parité stricte).
8 tests adaptés à l'extraction (ils lisaient le template SOURCE) :
- test_ai_writing ×2 (+ helper _read_js), test_pwa_offline,
test_v511 front_end_wired, test_v55 ×3 → lisent le JS extrait
- test_page_editor_renders_page_is_shared → parsing du JSON #page-data
(`is_shared is True`) — la valeur sert toujours à la page
Cumul A27 : 6 759 L extraites (13 904 → 7 145 inline). Reste : local_workspace
2 031, base 1 523 (structurel {% for %}/{% block %}), database_table 1 323,
settings 1 093, realtime 531, board 146 ≈ 6 653 L + 120 warnings eslint.
suite **1089/1089** · ruff OK · docs à jour
403 lines
18 KiB
Python
403 lines
18 KiB
Python
"""FlowDeck — v5.11.0 Wiki-links & mentions + v5.12.0 Templates & page lock.
|
|
|
|
Covers: migrations 13, the wiki token helpers, the picker / titles endpoints,
|
|
rename propagation, backlinks via tokens, the page lock (423 guard, unlock
|
|
permissions), page layout options, global page templates (list/create/use)
|
|
and the public renderer wiki chips.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import tempfile
|
|
|
|
import pytest
|
|
from conftest import login_test_client
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
db_path = db_file.name
|
|
db_file.close()
|
|
|
|
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
|
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
|
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
|
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
|
|
|
from app.config import settings
|
|
settings.database_url = f"sqlite:///{db_path}"
|
|
settings.rate_limit_enabled = False
|
|
settings.public_api_insecure_ok = True
|
|
|
|
from app.db import init_db
|
|
from app.main import app
|
|
init_db()
|
|
|
|
yield login_test_client(TestClient(app))
|
|
|
|
try:
|
|
os.unlink(db_path)
|
|
except PermissionError:
|
|
pass
|
|
|
|
|
|
def _login(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
login = f"v511u_{secrets.token_hex(4)}"
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
|
|
"VALUES (?, 'V511 User', ?, ?, 0)",
|
|
(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": 0})
|
|
# Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
|
|
# appel, un token rendu par un login précédent deviendrait invalide (403).
|
|
csrf = client.cookies.get("csrf_token")
|
|
if not csrf:
|
|
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
|
return session, csrf, uid
|
|
|
|
|
|
def _login_admin(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
login = f"v511a_{secrets.token_hex(4)}"
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
|
|
"VALUES (?, 'V511 Admin', ?, ?, 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éutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
|
|
# appel, un token rendu par un login précédent deviendrait invalide (403).
|
|
csrf = client.cookies.get("csrf_token")
|
|
if not csrf:
|
|
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
|
return session, csrf, uid
|
|
|
|
|
|
def _mk_page(client, session, title, blocks=None):
|
|
r = client.post("/board/api/pages", params={"title": title, "section": "Private"},
|
|
cookies={"flowdeck_session": session})
|
|
assert r.status_code == 200
|
|
pid = r.json()["id"]
|
|
if blocks is not None:
|
|
r = client.post(f"/board/api/pages/{pid}/blocks",
|
|
json={"title": title, "blocks": blocks},
|
|
cookies={"flowdeck_session": session})
|
|
assert r.status_code == 200, r.text
|
|
return pid
|
|
|
|
|
|
def _blocks_b(text):
|
|
return [{"id": "b1", "type": "paragraph", "content": text}]
|
|
|
|
|
|
# ── Migration ──
|
|
|
|
def test_migration_v513_schema(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
|
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
for col in ("is_locked", "locked_by", "full_width", "font_small"):
|
|
assert col in pcols
|
|
assert "page_global_templates" in tables
|
|
assert version >= 13
|
|
|
|
|
|
# ── wiki_links service ──
|
|
|
|
def test_wiki_token_helpers():
|
|
from app.services.wiki_links import extract_page_ids, resolve_tokens_html
|
|
|
|
assert extract_page_ids("see [[fdpage:12]] and [[fdpage:7]] plus [[fdpage:12]]") == [7, 12]
|
|
html = resolve_tokens_html(
|
|
"Hi [[fdpage:7]] on [[fddate:2026-10-01]]",
|
|
{"[[fdpage:7]]": "Project X", "[[fddate:2026-10-01]]": "Thu 1 Oct 2026"},
|
|
)
|
|
assert '<a class="fd-wiki-link" href="/pages/7"' in html and "Project X" in html
|
|
assert 'class="fd-wiki-date"' in html and "Thu 1 Oct 2026" in html
|
|
# escaping: raw HTML in label is escaped
|
|
html2 = resolve_tokens_html("[[fdpage:1]]", {"[[fdpage:1]]": "<script>x</script>"})
|
|
assert "<script>" not in html2 and "<script>" in html2
|
|
|
|
|
|
# ── Picker + titles endpoints ──
|
|
|
|
def test_wiki_pages_picker(client):
|
|
s, c, uid = _login(client)
|
|
_mk_page(client, s, "Project Aurora")
|
|
_mk_page(client, s, "Meeting notes")
|
|
r = client.get("/board/api/wiki/pages", params={"q": "aurora"},
|
|
cookies={"flowdeck_session": s})
|
|
assert r.status_code == 200
|
|
titles = [p["title"] for p in r.json()["pages"]]
|
|
assert "Project Aurora" in titles and "Meeting notes" not in titles
|
|
# subsequence fuzzy: "mtg" matches "Meeting notes"? m-t-g not in order → try "mnt"
|
|
r2 = client.get("/board/api/wiki/pages", params={"q": "mnt"},
|
|
cookies={"flowdeck_session": s}).json()["pages"]
|
|
assert any(p["title"] == "Meeting notes" for p in r2)
|
|
|
|
|
|
def test_wiki_titles_rename_propagation(client):
|
|
s, c, uid = _login(client)
|
|
target = _mk_page(client, s, "Old Title")
|
|
r = client.get("/board/api/wiki/titles", params={"ids": str(target)},
|
|
cookies={"flowdeck_session": s})
|
|
assert r.json()["titles"][str(target)].strip().endswith("Old Title")
|
|
# rename
|
|
client.put(f"/board/api/pages/{target}", params={"title": "New Title"},
|
|
cookies={"flowdeck_session": s})
|
|
r2 = client.get("/board/api/wiki/titles", params={"ids": f"{target},99999"},
|
|
cookies={"flowdeck_session": s})
|
|
assert "New Title" in r2.json()["titles"][str(target)]
|
|
assert r2.json()["titles"].get("99999") is None # unknown ids simply absent
|
|
|
|
|
|
# ── Tokens persist + backlinks ──
|
|
|
|
def test_wiki_token_in_blocks_and_backlink(client):
|
|
s, c, uid = _login(client)
|
|
target = _mk_page(client, s, "Linked Page")
|
|
src = _mk_page(client, s, "Source", _blocks_b(f"Check [[fdpage:{target}]] for details"))
|
|
# token survives the save
|
|
page = client.get(f"/board/api/pages/{src}").json()
|
|
assert f"[[fdpage:{target}]]" in page["content"]
|
|
# backlink scanner finds the token reference
|
|
bl = client.get(f"/board/api/pages/{target}/backlinks",
|
|
cookies={"flowdeck_session": s}).json()["backlinks"]
|
|
assert any(b["id"] == src for b in bl)
|
|
|
|
|
|
def test_public_page_renders_wiki_chips(client):
|
|
s, c, uid = _login(client)
|
|
target = _mk_page(client, s, "Target Doc")
|
|
pid = _mk_page(client, s, "Public Doc", _blocks_b(f"Hello [[fdpage:{target}]] today [[fddate:2026-12-25]]"))
|
|
r = client.post(f"/board/api/pages/{pid}/publish", cookies={"flowdeck_session": s})
|
|
assert r.status_code == 200, r.text
|
|
slug = r.json()["publish_slug"]
|
|
html = client.get(f"/p/{slug}").text
|
|
assert f'href="/pages/{target}"' in html
|
|
assert "Target Doc" in html
|
|
assert "fd-wiki-date" in html and "Fri 25 Dec 2026" in html
|
|
|
|
|
|
# ── Page lock ──
|
|
|
|
def test_page_lock_blocks_non_owner(client):
|
|
owner_s, owner_c, owner_uid = _login(client)
|
|
other_s, other_c, other_uid = _login(client)
|
|
pid = _mk_page(client, owner_s, "Locked", _blocks_b("keep me"))
|
|
r = client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
|
|
cookies={"flowdeck_session": owner_s}, headers={"X-CSRF-Token": owner_c})
|
|
assert r.status_code == 200 and r.json()["is_locked"] == 1
|
|
# non-owner edit → 423
|
|
r2 = client.post(f"/board/api/pages/{pid}/blocks",
|
|
json={"title": "hacked", "blocks": _blocks_b("hacked")},
|
|
cookies={"flowdeck_session": other_s})
|
|
assert r2.status_code == 423
|
|
# the owner can still edit
|
|
r3 = client.post(f"/board/api/pages/{pid}/blocks",
|
|
json={"title": "Locked", "blocks": _blocks_b("still fine")},
|
|
cookies={"flowdeck_session": owner_s})
|
|
assert r3.status_code == 200
|
|
# non-owner cannot unlock
|
|
r4 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
|
|
cookies={"flowdeck_session": other_s}, headers={"X-CSRF-Token": other_c})
|
|
assert r4.status_code == 403
|
|
# admin can force-unlock
|
|
a_s, a_c, a_uid = _login_admin(client)
|
|
r5 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
|
|
cookies={"flowdeck_session": a_s}, headers={"X-CSRF-Token": a_c})
|
|
assert r5.status_code == 200 and r5.json()["is_locked"] == 0
|
|
# after unlock, everyone edits again
|
|
r6 = client.post(f"/board/api/pages/{pid}/blocks",
|
|
json={"title": "Locked", "blocks": _blocks_b("open again")},
|
|
cookies={"flowdeck_session": other_s})
|
|
assert r6.status_code == 200
|
|
|
|
|
|
def test_page_lock_put_and_get_page_data(client):
|
|
s, c, uid = _login(client)
|
|
pid = _mk_page(client, s, "QL", _blocks_b("x"))
|
|
r = client.put(f"/board/api/pages/{pid}", params={"title": "T"},
|
|
cookies={"flowdeck_session": s})
|
|
assert r.status_code == 200
|
|
client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
other_s, other_c, _ = _login(client)
|
|
r2 = client.put(f"/board/api/pages/{pid}", params={"title": "nope"},
|
|
cookies={"flowdeck_session": other_s})
|
|
assert r2.status_code == 423
|
|
# rendered page context carries lock state
|
|
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": other_s}).text
|
|
assert '"is_locked": true' in html and '"can_edit": false' in html
|
|
|
|
|
|
def test_page_options(client):
|
|
s, c, uid = _login(client)
|
|
pid = _mk_page(client, s, "Opt")
|
|
r = client.post(f"/board/api/pages/{pid}/options",
|
|
json={"full_width": True, "font_small": False},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 200 and r.json()["full_width"] is True
|
|
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": s}).text
|
|
assert '"full_width": true' in html
|
|
r2 = client.post(f"/board/api/pages/{pid}/options", json={},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r2.status_code == 400
|
|
|
|
|
|
# ── Global page templates ──
|
|
|
|
def test_builtin_templates_seeded_in_api(client):
|
|
s, c, uid = _login(client)
|
|
r = client.get("/board/api/page-templates", cookies={"flowdeck_session": s})
|
|
assert r.status_code == 200
|
|
tpls = r.json()["templates"]
|
|
names = [t["name"] for t in tpls]
|
|
for expected in ("Empty", "Meeting notes", "Weekly report", "To-do list", "Project doc"):
|
|
assert expected in names
|
|
assert all(t["builtin"] for t in tpls)
|
|
|
|
|
|
def test_use_builtin_template_creates_page(client):
|
|
s, c, uid = _login(client)
|
|
r = client.post("/board/api/page-templates/0/use", json={"key": "meeting_notes"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 200
|
|
pid = r.json()["id"]
|
|
page = client.get(f"/board/api/pages/{pid}").json()
|
|
assert page["content_format"] == "blocks"
|
|
blocks = json.loads(page["content"])
|
|
types = [b["type"] for b in blocks]
|
|
assert "heading_1" in types and "to_do" in types
|
|
texts = " ".join(b.get("content", "") for b in blocks)
|
|
assert "Action items" in texts
|
|
# unknown key → 404
|
|
assert client.post("/board/api/page-templates/0/use", json={"key": "nope"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404
|
|
|
|
|
|
def test_use_builtin_template_assigns_block_ids(client):
|
|
"""Template blocks must be persisted with unique ids (v5.13.1).
|
|
|
|
Without ids the realtime room and the editor disagree on block identity,
|
|
which duplicated/shuffled lines when editing a template page.
|
|
"""
|
|
s, c, uid = _login(client)
|
|
r = client.post("/board/api/page-templates/0/use", json={"key": "weekly_report"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 200
|
|
page = client.get(f"/board/api/pages/{r.json()['id']}").json()
|
|
blocks = json.loads(page["content"])
|
|
ids = [b.get("id") for b in blocks]
|
|
assert all(ids), f"missing ids: {ids}"
|
|
assert len(set(ids)) == len(ids), f"duplicate ids: {ids}"
|
|
|
|
|
|
def test_use_template_persists_workspace_id(client):
|
|
s, c, uid = _login(client)
|
|
# Create a local workspace owned by this user
|
|
r = client.post("/api/workspaces", json={"name": "Tpl WS"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 200
|
|
ws_id = r.json()["id"]
|
|
client.post(f"/api/workspaces/{ws_id}/select", cookies={"flowdeck_session": s})
|
|
# built-in template use with workspace context
|
|
r2 = client.post("/board/api/page-templates/0/use", json={"key": "meeting_notes", "workspace_id": ws_id},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r2.status_code == 200
|
|
pid = r2.json()["id"]
|
|
page = client.get(f"/board/api/pages/{pid}").json()
|
|
assert page["workspace_id"] == ws_id
|
|
assert page["workspace"] == "Tpl WS"
|
|
# user template use with workspace context
|
|
r3 = client.post("/board/api/page-templates",
|
|
json={"name": "Tpl WS template", "blocks": _blocks_b("ws body")},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
tid = r3.json()["id"]
|
|
r4 = client.post(f"/board/api/page-templates/{tid}/use", json={"workspace_id": ws_id},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r4.status_code == 200
|
|
page2 = client.get(f"/board/api/pages/{r4.json()['id']}").json()
|
|
assert page2["workspace_id"] == ws_id
|
|
assert page2["workspace"] == "Tpl WS"
|
|
# routing: template-created page must appear in the workspace tree
|
|
tree = client.get("/api/local-workspace/tree", cookies={"flowdeck_session": s}).json()
|
|
ids = [n["id"] for n in tree["tree"]]
|
|
assert pid in ids
|
|
|
|
|
|
def test_user_template_roundtrip(client):
|
|
s, c, uid = _login(client)
|
|
pid = _mk_page(client, s, "My Weekly", _blocks_b("body text"))
|
|
r = client.post("/board/api/page-templates",
|
|
json={"name": "My Weekly template", "page_id": pid, "icon": "⭐"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 200
|
|
tid = r.json()["id"]
|
|
lst = client.get("/board/api/page-templates", cookies={"flowdeck_session": s}).json()["templates"]
|
|
mine = [t for t in lst if t["name"] == "My Weekly template"]
|
|
assert mine and mine[0]["builtin"] is False and mine[0]["icon"] == "⭐"
|
|
# instantiate
|
|
r2 = client.post(f"/board/api/page-templates/{tid}/use", json={"title": "New instance"},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r2.status_code == 200 and r2.json()["title"] == "New instance"
|
|
npage = client.get(f"/board/api/pages/{r2.json()['id']}").json()
|
|
assert "body text" in npage["content"]
|
|
# templates are personal: another user can neither list nor use it
|
|
s2, c2, _ = _login(client)
|
|
lst2 = client.get("/board/api/page-templates", cookies={"flowdeck_session": s2}).json()["templates"]
|
|
assert all(t["name"] != "My Weekly template" for t in lst2)
|
|
assert client.post(f"/board/api/page-templates/{tid}/use", cookies={"flowdeck_session": s2},
|
|
headers={"X-CSRF-Token": c2}).status_code == 404
|
|
|
|
|
|
def test_template_requires_name(client):
|
|
s, c, uid = _login(client)
|
|
r = client.post("/board/api/page-templates", json={"blocks": _blocks_b("x")},
|
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_block_templates_service_shapes():
|
|
from app.services.block_templates import blocks_json_for, template_list
|
|
assert blocks_json_for("empty") is not None
|
|
assert blocks_json_for("nope") is None
|
|
meeting = json.loads(blocks_json_for("meeting_notes"))
|
|
assert any(b["type"] == "heading_2" and b["content"] == "Agenda" for b in meeting)
|
|
assert any(b["type"] == "to_do" for b in meeting)
|
|
for t in template_list():
|
|
assert set(t) >= {"key", "name", "icon", "description", "builtin"}
|
|
|
|
|
|
# ── Front-end wiring (rendered template markers) ──
|
|
|
|
def test_editor_front_end_wired(client):
|
|
import pathlib
|
|
src = pathlib.Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
|
|
base = pathlib.Path("app/templates/base.html").read_text(encoding="utf-8")
|
|
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
|
|
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
|
|
assert "WM={" in src and "fdpage:" in src # [[ picker module
|
|
assert "_openTemplatePicker" in base and "page-templates" in base
|
|
assert "toggleLock" in src and "saveAsTemplate" in src
|
|
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
|
|
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css
|