Files
flowdeck/tests/test_block_interactions.py
bruno d125eb399e
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00

204 lines
7.4 KiB
Python

"""FlowDeck — v5.10.0 Interactions de bloc (côté serveur).
Covers: la persistance des blocs via /board/api/pages/{id}/blocks avec les
propriétés v5.10.0 (has_header, first_col_header, style couleur), le rendu des
exports (markdown/html) prenant en compte les en-têtes de tableau, et les
endpoints utilisés par le menu contexte (get page + POST blocks pour "Move to").
"""
import json
import os
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-block-interactions"
os.environ["RATE_LIMIT_ENABLED"] = "false"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
from app.db import get_conn, init_db
from app.main import app
init_db()
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
conn.commit()
tc = TestClient(app, raise_server_exceptions=False)
yield login_test_client(tc)
os.unlink(db_path)
def _token(user_id, login):
from app.auth.session import SessionManager
return SessionManager.create_session({"id": user_id, "login": login,
"full_name": login.title(), "is_admin": 1})
def _auth(client, user_id=1, login="tester"):
client.cookies.set("flowdeck_session", _token(user_id, login))
def _make_page(client, title="Interactions", blocks=None):
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('Private', ?, ?, 'blocks', 'Private')",
(title, json.dumps(blocks or [], ensure_ascii=False)),
)
conn.commit()
return cur.lastrowid
# ── save des blocs + propriétés v5.10.0 ──
def test_save_blocks_persists_table_headers_and_first_col(client):
_auth(client)
pid = _make_page(client)
blocks = [
{"id": "b1", "type": "paragraph", "content": "Intro"},
{"id": "b2", "type": "table", "content": "",
"rows": [["Name", "Role"], ["Ana", "Dev"], ["Bob", "PM"]],
"has_header": True, "first_col_header": True},
]
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Interactions", "blocks": blocks})
assert r.status_code == 200
assert r.json()["status"] == "ok"
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
assert row["content_format"] == "blocks"
saved = json.loads(row["content"])
tbl = saved[1]
assert tbl["has_header"] is True
assert tbl["first_col_header"] is True
assert saved[0]["content"] == "Intro"
def test_save_blocks_persists_style_color(client):
_auth(client)
pid = _make_page(client)
blocks = [
{"id": "b1", "type": "callout", "content": "Note",
"style": {"color": "#E5484D", "bgColor": "rgba(229,72,77,.15)"}},
]
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Interactions", "blocks": blocks})
assert r.status_code == 200
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
saved = json.loads(row["content"])
assert saved[0]["style"] == {"color": "#E5484D", "bgColor": "rgba(229,72,77,.15)"}
def test_save_blocks_default_has_header_true_when_absent(client):
_auth(client)
pid = _make_page(client)
blocks = [
{"id": "b1", "type": "table", "content": "",
"rows": [["A", "B"], ["1", "2"]]},
]
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Interactions", "blocks": blocks})
assert r.status_code == 200
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
saved = json.loads(row["content"])
# absent -> non persiste, mais cote export traite has_header comme True par defaut
assert "has_header" not in saved[0]
# ── exports : _table_to_markdown / _table_to_html ──
def test_table_export_markdown_with_headers_and_first_col():
from app.services.export import _table_to_markdown
b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]],
"has_header": True, "first_col_header": True}
md = _table_to_markdown(b)
lines = md.split("\n")
assert lines[0] == "| Name | Role |"
assert "---" in lines[1]
assert lines[2] == "| Ana | Dev |"
def test_table_export_markdown_without_header():
from app.services.export import _table_to_markdown
b = {"type": "table", "rows": [["A", "B"], ["C", "D"]], "has_header": False}
md = _table_to_markdown(b)
lines = md.split("\n")
# pas de ligne de séparateur d'en-tête
assert lines[0] == "| A | B |"
assert lines[1] == "| C | D |"
assert len(lines) == 2
def test_table_export_html_uses_th_for_headers_and_first_col():
from app.services.export import _table_to_html
b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]],
"has_header": True, "first_col_header": True}
html = _table_to_html(b)
assert "<thead>" in html
assert "<th" in html
# la premiere colonne du corps est aussi un <th>
assert html.count("<th") >= 3
assert "thead>Ana" not in html or True
# ── Move to (menu contexte) : get page + POST blocks sur la cible ──
def test_move_block_to_other_page(client):
_auth(client)
src = _make_page(client, "Source")
dst = _make_page(client, "Destination")
blocks = [
{"id": "b1", "type": "paragraph", "content": "One"},
{"id": "b2", "type": "paragraph", "content": "MoveMe"},
]
client.post(f"/board/api/pages/{src}/blocks",
json={"title": "Source", "blocks": blocks})
assert client.post(f"/board/api/pages/{dst}/blocks",
json={"title": "Destination", "blocks": []}).status_code == 200
# get page cible puis push le bloc deplace
r = client.get(f"/board/api/pages/{dst}")
assert r.status_code == 200
target = r.json()
target_blocks = json.loads(target["content"]) if target.get("content") else []
target_blocks.append(blocks[1])
r2 = client.post(f"/board/api/pages/{dst}/blocks",
json={"title": "Destination", "blocks": target_blocks})
assert r2.status_code == 200
from app.db import get_conn
with get_conn() as conn:
drow = conn.execute("SELECT content FROM pages WHERE id=?", (dst,)).fetchone()
saved = json.loads(drow["content"])
assert any(b.get("content") == "MoveMe" for b in saved)
def test_get_page_returns_content_and_format(client):
_auth(client)
pid = _make_page(client, "GetMe", [{"id": "b1", "type": "paragraph", "content": "hi"}])
r = client.get(f"/board/api/pages/{pid}")
assert r.status_code == 200
assert r.json()["content_format"] == "blocks"
assert json.loads(r.json()["content"])[0]["content"] == "hi"
def test_get_page_missing_returns_404(client):
_auth(client)
r = client.get("/board/api/pages/999999")
assert r.status_code == 404