- 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
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""FlowDeck — v6.9.0 semantic search + Ask AI.
|
|
|
|
Covers migration 25, chunking/hashing/cosine units, indexing (idempotent,
|
|
excluded/deleted skipped, orphans purged), vector recall on partial overlap,
|
|
hybrid RRF + ACL/workspace isolation + pagination headers, ask (offline
|
|
extractive with citations, auth, cache, rate limit, ACL) and index-status.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import secrets
|
|
import struct
|
|
|
|
from conftest import anon
|
|
|
|
from app.db import get_conn
|
|
from app.services import semantic_search as sem
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _login(client, admin: bool = False):
|
|
from app.auth.session import SessionManager
|
|
login = f"v69_{secrets.token_hex(4)}"
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V69', ?, ?)",
|
|
(login, f"{login}@test.com", 1 if admin else 0),
|
|
)
|
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
|
conn.commit()
|
|
session = SessionManager.create_session({"id": uid, "login": login})
|
|
return session, {"id": uid, "login": login}
|
|
|
|
|
|
def _cookies(session):
|
|
return {"flowdeck_session": session}
|
|
|
|
|
|
def _mkpage(title="Doc", body="hello world", workspace_id=None, fmt="blocks"):
|
|
content = (json.dumps([{"type": "paragraph", "content": body}])
|
|
if fmt == "blocks" else body)
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
("test", workspace_id, title, content, fmt),
|
|
)
|
|
pid = cur.lastrowid
|
|
conn.commit()
|
|
return pid
|
|
|
|
|
|
def _mkcollection(name="DB", description="desc"):
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO collections (name, description) VALUES (?, ?)", (name, description))
|
|
cid = cur.lastrowid
|
|
conn.commit()
|
|
return cid
|
|
|
|
|
|
def _mkworkspace(owner_id, name="Team"):
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, owner_id))
|
|
wid = cur.lastrowid
|
|
conn.commit()
|
|
return wid
|
|
|
|
|
|
# ── migration ──────────────────────────────────────────────────────────────
|
|
|
|
def test_migration_25_tables(client):
|
|
with get_conn() as conn:
|
|
tables = {r[0] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
|
assert "semantic_embeddings" in tables
|
|
assert "semantic_index_state" in tables
|
|
with get_conn() as conn:
|
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
|
assert "search_excluded" in cols
|
|
with get_conn() as conn:
|
|
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
assert v >= 25
|
|
|
|
|
|
# ── units: chunk / embed / cosine ──────────────────────────────────────────
|
|
|
|
def test_chunk_text_short_and_empty(client):
|
|
assert sem.chunk_text("") == []
|
|
assert sem.chunk_text("hello") == ["hello"]
|
|
|
|
|
|
def test_chunk_text_splits_long(client):
|
|
text = " ".join(f"word{i}" for i in range(800))
|
|
chunks = sem.chunk_text(text)
|
|
assert len(chunks) > 1
|
|
assert all(len(c) <= sem.CHUNK_SIZE for c in chunks)
|
|
# overlap: a middle word appears in two consecutive chunks
|
|
assert any(w in chunks[0] and w in chunks[1] for w in chunks[1].split()[:20])
|
|
|
|
|
|
def test_embed_deterministic_and_normalized(client):
|
|
a = sem.embed_text("hello world")
|
|
b = sem.embed_text("hello world")
|
|
assert a == b
|
|
assert len(a) == sem.DIM * 4
|
|
vals = struct.unpack(f"<{sem.DIM}f", a)
|
|
assert math.isclose(sum(v * v for v in vals), 1.0, rel_tol=1e-5)
|
|
|
|
|
|
def test_cosine_identical_and_disjoint(client):
|
|
a = sem.embed_text("alpha beta")
|
|
assert math.isclose(sem.cosine(a, a), 1.0, rel_tol=1e-5)
|
|
# disjoint single tokens collide with p≈1-(255/256)^2 ≈ tiny; use longer texts
|
|
c = sem.embed_text("alpha beta gamma delta")
|
|
d = sem.embed_text("epsilon zeta eta theta")
|
|
assert 0.0 <= sem.cosine(c, d) < sem.cosine(c, c)
|
|
|
|
|
|
# ── indexing ───────────────────────────────────────────────────────────────
|
|
|
|
def test_index_page_and_idempotent(client):
|
|
pid = _mkpage("Guide", "kubernetes deployment scaling tips")
|
|
n = sem.index_resource("page", pid)
|
|
assert n >= 1
|
|
with get_conn() as conn:
|
|
count = conn.execute(
|
|
"SELECT COUNT(*) FROM semantic_embeddings WHERE resource_type='page'"
|
|
" AND resource_id=?", (pid,)).fetchone()[0]
|
|
assert count == n
|
|
n2 = sem.index_resource("page", pid)
|
|
assert n2 == n
|
|
with get_conn() as conn:
|
|
count2 = conn.execute(
|
|
"SELECT COUNT(*) FROM semantic_embeddings WHERE resource_type='page'"
|
|
" AND resource_id=?", (pid,)).fetchone()[0]
|
|
assert count2 == n # no duplicates
|
|
|
|
|
|
def test_index_skips_excluded(client):
|
|
pid = _mkpage("Secret", "hidden content here")
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET search_excluded=1 WHERE id=?", (pid,))
|
|
conn.commit()
|
|
assert sem.index_resource("page", pid) == 0
|
|
|
|
|
|
def test_purge_removes_deleted(client):
|
|
pid = _mkpage("Gone", "bye bye content")
|
|
assert sem.index_resource("page", pid) >= 1
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at='2026-01-01 00:00:00' WHERE id=?", (pid,))
|
|
conn.commit()
|
|
assert sem.purge_orphans() >= 1
|
|
assert sem.index_resource("page", pid) == 0
|
|
|
|
|
|
def test_index_pending_picks_stale(client):
|
|
pid = _mkpage("Fresh", "brand new content words")
|
|
out = sem.index_pending(limit=50)
|
|
assert out["indexed"] >= 1
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT indexed_at FROM semantic_index_state WHERE resource_type='page'"
|
|
" AND resource_id=?", (pid,)).fetchone()
|
|
assert row is not None
|
|
|
|
|
|
# ── vector + hybrid ────────────────────────────────────────────────────────
|
|
|
|
def test_vector_partial_overlap_recall(client):
|
|
pid = _mkpage("Ops", "alpha beta gamma delta")
|
|
sem.index_resource("page", pid)
|
|
# FTS AND would need all terms; vector matches on shared "alpha".
|
|
hits = sem.vector_search("alpha zeta omicron", limit=10)
|
|
assert any(h["resource_id"] == pid for h in hits)
|
|
|
|
|
|
def test_hybrid_finds_by_keyword(client):
|
|
session, user = _login(client)
|
|
pid = _mkpage("Kubernetes Guide", "deploy pods and services")
|
|
sem.index_resource("page", pid)
|
|
r = client.get("/api/v2/search/hybrid?q=kubernetes", cookies=_cookies(session))
|
|
assert r.status_code == 200, r.text
|
|
ids = [x["id"] for x in r.json()["results"] if x["type"] == "page"]
|
|
assert pid in ids
|
|
assert "X-Total-Count" in r.headers
|
|
|
|
|
|
def test_hybrid_requires_auth(client):
|
|
anon(client)
|
|
r = client.get("/api/v2/search/hybrid?q=test")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_hybrid_empty_query_400(client):
|
|
session, _ = _login(client)
|
|
r = client.get("/api/v2/search/hybrid?q=", cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_hybrid_pagination(client):
|
|
session, _ = _login(client)
|
|
for i in range(3):
|
|
sem.index_resource("page", _mkpage(f"Pagetopic {i}", f"pagetopic body {i}"))
|
|
r = client.get("/api/v2/search/hybrid?q=pagetopic&limit=2&offset=0",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert len(body["results"]) <= 2
|
|
assert body["limit"] == 2 and body["offset"] == 0
|
|
|
|
|
|
def test_hybrid_workspace_isolation(client):
|
|
# Workspaces are open-by-default (viewer fallback); restriction is opt-in
|
|
# via permission_type='restricted' + explicit grants.
|
|
s1, u1 = _login(client)
|
|
_s2, _u2 = _login(client)
|
|
wid = _mkworkspace(u1["id"])
|
|
pid = _mkpage("Team Secrets", "sekretwords vault", workspace_id=wid)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET permission_type='restricted' WHERE id=?", (pid,))
|
|
conn.commit()
|
|
sem.index_resource("page", pid)
|
|
r = client.get("/api/v2/search/hybrid?q=sekretwords", cookies=_cookies(s1))
|
|
assert pid in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
|
|
r = client.get("/api/v2/search/hybrid?q=sekretwords", cookies=_cookies(_s2))
|
|
assert pid not in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
|
|
|
|
|
|
def test_hybrid_excluded_page_absent(client):
|
|
session, _ = _login(client)
|
|
pid = _mkpage("Hidden", "cloakwords invisible")
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET search_excluded=1 WHERE id=?", (pid,))
|
|
conn.commit()
|
|
sem.purge_orphans()
|
|
r = client.get("/api/v2/search/hybrid?q=cloakwords", cookies=_cookies(session))
|
|
assert pid not in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
|
|
|
|
|
|
def test_hybrid_finds_collections(client):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection("Customer CRM", "tracks zalonowords leads")
|
|
sem.index_resource("collection", cid)
|
|
r = client.get("/api/v2/search/hybrid?q=zalonowords", cookies=_cookies(session))
|
|
assert r.status_code == 200
|
|
assert cid in [x["id"] for x in r.json()["results"] if x["type"] == "collection"]
|
|
|
|
|
|
# ── ask ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_ask_offline_with_citations(client):
|
|
sem.reset_state()
|
|
session, _ = _login(client)
|
|
pid = _mkpage("Deploy Guide",
|
|
"To deploy the app, run the deploy script. Then verify the pods are ready.")
|
|
sem.index_resource("page", pid)
|
|
r = client.post("/api/v2/search/ask", json={"question": "how to deploy the app"},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["offline"] is True
|
|
assert any(c["id"] == pid for c in body["citations"])
|
|
assert f"[[fdpage:{pid}]]" in body["answer_markdown"]
|
|
|
|
|
|
def test_ask_requires_auth(client):
|
|
anon(client)
|
|
r = client.post("/api/v2/search/ask", json={"question": "hi"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_ask_empty_400(client):
|
|
sem.reset_state()
|
|
session, _ = _login(client)
|
|
r = client.post("/api/v2/search/ask", json={"question": " "},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_ask_cached(client):
|
|
sem.reset_state()
|
|
session, _ = _login(client)
|
|
pid = _mkpage("Caching", "the cache stores answers for reuse and speed")
|
|
sem.index_resource("page", pid)
|
|
payload = {"question": "what does the cache store"}
|
|
r1 = client.post("/api/v2/search/ask", json=payload, cookies=_cookies(session))
|
|
assert r1.json()["cached"] is False
|
|
r2 = client.post("/api/v2/search/ask", json=payload, cookies=_cookies(session))
|
|
assert r2.json()["cached"] is True
|
|
assert r2.json()["answer_markdown"] == r1.json()["answer_markdown"]
|
|
|
|
|
|
def test_ask_acl_other_user(client):
|
|
sem.reset_state()
|
|
s1, u1 = _login(client)
|
|
s2, _u2 = _login(client)
|
|
wid = _mkworkspace(u1["id"])
|
|
pid = _mkpage("Private Ops", "quagmirewords runbook steps", workspace_id=wid)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET permission_type='restricted' WHERE id=?", (pid,))
|
|
conn.commit()
|
|
sem.index_resource("page", pid)
|
|
r = client.post("/api/v2/search/ask", json={"question": "quagmirewords runbook"},
|
|
cookies=_cookies(s1))
|
|
assert pid in [c["id"] for c in r.json()["citations"]]
|
|
r = client.post("/api/v2/search/ask", json={"question": "quagmirewords runbook"},
|
|
cookies=_cookies(s2))
|
|
assert pid not in [c["id"] for c in r.json()["citations"]]
|
|
|
|
|
|
def test_ask_rate_limit(client):
|
|
sem.reset_state()
|
|
session, _ = _login(client)
|
|
last = None
|
|
for _ in range(31):
|
|
last = client.post("/api/v2/search/ask", json={"question": "ping"},
|
|
cookies=_cookies(session))
|
|
assert last.status_code == 429
|
|
|
|
|
|
def test_index_status(client):
|
|
session, _ = _login(client)
|
|
pid = _mkpage("Status", "statuswords check")
|
|
sem.index_resource("page", pid)
|
|
r = client.get("/api/v2/search/index-status", cookies=_cookies(session))
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["vectors"] >= 1
|
|
assert body["model"] == "hash-256"
|