- 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
181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
"""FlowDeck — v5.2.0 (versioned migrations) & v5.0.0 (search / command palette API).
|
|
|
|
Covers the ``schema_version`` runner, the FTS5 full-text index (with triggers),
|
|
and the ``GET /api/search`` endpoint powering the Ctrl+K command palette.
|
|
"""
|
|
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-tests"
|
|
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
|
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
|
|
|
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
|
|
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))
|
|
|
|
os.unlink(db_path)
|
|
|
|
|
|
def _create_page(title, content="", content_format="blocks"):
|
|
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')",
|
|
(title, content, content_format),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def _create_collection(name, description="", icon="📋"):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO collections (name, description, icon, schema_json) "
|
|
"VALUES (?, ?, ?, '[]')",
|
|
(name, description, icon),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
# ── v5.2.0: versioned migrations ──
|
|
|
|
def test_schema_version_table_tracks_migrations(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT version, name FROM schema_version ORDER BY version"
|
|
).fetchall()
|
|
versions = [r["version"] for r in rows]
|
|
assert 1 in versions # baseline
|
|
assert max(versions) >= 2 # versioned migrations applied
|
|
|
|
|
|
def test_missing_indexes_created(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
idx = {r["name"] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='index'"
|
|
).fetchall()}
|
|
assert "idx_users_email" in idx
|
|
assert "idx_user_oauth_tokens_user" in idx
|
|
|
|
|
|
def test_fts5_virtual_table_and_triggers(client):
|
|
from app.db import get_conn
|
|
from app.migrations import fts5_available
|
|
if not fts5_available():
|
|
pytest.skip("FTS5 not available in this SQLite build")
|
|
with get_conn() as conn:
|
|
tbl = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
|
|
).fetchone()
|
|
assert tbl is not None
|
|
trigs = {r["name"] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger'"
|
|
).fetchall()}
|
|
assert {"pages_fts_ai", "pages_fts_ad", "pages_fts_au"} <= trigs
|
|
|
|
|
|
def test_fts_index_stays_in_sync_with_pages(client):
|
|
from app.db import get_conn
|
|
from app.migrations import fts5_available
|
|
if not fts5_available():
|
|
pytest.skip("FTS5 not available in this SQLite build")
|
|
|
|
pid = _create_page("SyncCheck", "alpha beta gamma")
|
|
with get_conn() as conn:
|
|
hits = conn.execute(
|
|
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'"
|
|
).fetchone()["c"]
|
|
assert hits == 1
|
|
# updating a page refreshes the index
|
|
conn.execute("UPDATE pages SET title='SyncCheck2', content='delta epsilon' WHERE id=?", (pid,))
|
|
conn.commit()
|
|
with get_conn() as conn:
|
|
hits = conn.execute(
|
|
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'"
|
|
).fetchone()["c"]
|
|
assert hits == 1
|
|
old = conn.execute(
|
|
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'"
|
|
).fetchone()["c"]
|
|
assert old == 0
|
|
# deleting a page removes it from the index
|
|
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
|
conn.commit()
|
|
with get_conn() as conn:
|
|
gone = conn.execute(
|
|
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'"
|
|
).fetchone()["c"]
|
|
assert gone == 0
|
|
|
|
|
|
# ── v5.0.0: search API ──
|
|
|
|
def test_search_returns_pages_and_collections(client):
|
|
_create_page("Roadmap 2026", '{"blocks":[{"type":"paragraph","content":"build the future"}]}')
|
|
_create_collection("Projects Database", "track our roadmap projects", icon="🚀")
|
|
|
|
resp = client.get("/api/search", params={"q": "roadmap"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
titles = [p["title"] for p in data["pages"]] + [c["title"] for c in data["collections"]]
|
|
assert any("Roadmap 2026" in t for t in titles)
|
|
assert any("Projects Database" in t for t in titles)
|
|
|
|
|
|
def test_search_empty_query_returns_no_results(client):
|
|
_create_page("Anything")
|
|
resp = client.get("/api/search", params={"q": ""})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["pages"] == []
|
|
assert data["collections"] == []
|
|
|
|
|
|
def test_search_excludes_deleted_pages(client):
|
|
from app.db import get_conn
|
|
pid = _create_page("SecretDraft", "very private content")
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
|
|
conn.commit()
|
|
|
|
resp = client.get("/api/search", params={"q": "SecretDraft"})
|
|
assert resp.status_code == 200
|
|
assert not [p for p in resp.json()["pages"] if p["id"] == pid]
|
|
|
|
|
|
def test_search_no_match_returns_empty(client):
|
|
_create_page("Alpha")
|
|
resp = client.get("/api/search", params={"q": "zzzz_nonexistent_qqqq"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["pages"] == []
|
|
assert data["collections"] == []
|
|
assert data["total"] == 0
|