fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
"""FlowDeck — v7.46.0 : web tools de l'agent (web_search / fetch_url / search_code).
|
||||
|
||||
Ces tests ne touchent JAMAIS le réseau : la couche httpx est injectée via le
|
||||
paramètre ``transport`` des services, et les tools sont exercés à travers le
|
||||
registre (``ToolRegistry.execute``) comme le ferait ``AgentEngine``.
|
||||
|
||||
Points critiques couverts :
|
||||
* le registre expose bien les 3 tools et leurs schémas ;
|
||||
* chaque preset de la galerie ne référence que des tools existants ;
|
||||
* ``web_search`` : provider Exa, repli DuckDuckGo, et filtrage des URLs
|
||||
anti-bot du moteur ;
|
||||
* ``fetch_url`` : markdown extrait, troncature, et **refus SSRF** (localhost,
|
||||
IP privée, schémas `file:`/`ftp:`, lien vers metadata cloud à travers une
|
||||
redirection) ;
|
||||
* ``search_code`` : repos / code / issues, et message clair sur quota.
|
||||
|
||||
Convention du projet : les tests async passent par ``asyncio.run`` (idem
|
||||
``tests/test_agent.py``), pas de marqueur pytest-asyncio.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.services import http_client
|
||||
from app.services import tool_registry as tr
|
||||
from app.services import web_search as ws
|
||||
from app.services.skill_gallery import GALLERY, export_skill, parse_payload
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
WEB_TOOLS = ("web_search", "fetch_url", "search_code")
|
||||
|
||||
#: IP publique factice : les tests ne doivent dépendre ni du réseau HTTP ni du DNS.
|
||||
PUBLIC_IP = "93.184.216.34"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_dns(monkeypatch):
|
||||
"""Neutralise le DNS pour les noms d'hôtes publics.
|
||||
|
||||
Le garde-fou SSRF (`_is_public_host`) résout l'hôte pour décider si l'IP
|
||||
est publique : sans ce stub, chaque test `fetch_url` sur `example.com`
|
||||
ferait une vraie résolution DNS — lente, et susceptible d'échouer sous
|
||||
`pytest -n auto`, ce qui rendrait ces tests dépendants de l'environnement.
|
||||
Les IP littérales (`127.0.0.1`, `169.254.169.254`, `::1`) gardent la
|
||||
résolution réelle : le refus des adresses privées reste donc testé pour de
|
||||
vrai, y compris sur les redirections.
|
||||
"""
|
||||
real = socket.getaddrinfo
|
||||
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
|
||||
return real(host, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> ToolRegistry:
|
||||
return ToolRegistry()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mock_http(handler):
|
||||
"""Injecte un transport httpx dans le client partagé des services.
|
||||
|
||||
``web_search`` importe ``shared_client`` au niveau module, l'outillage
|
||||
``fetch_url`` fait un import local : on patche les deux références pour
|
||||
qu'aucun test n'atteigne jamais le réseau, même sans ``transport=``.
|
||||
"""
|
||||
original = http_client.shared_client
|
||||
|
||||
def build(**kwargs):
|
||||
kwargs.pop("transport", None)
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
return original(**kwargs)
|
||||
|
||||
targets = (http_client, ws, tr)
|
||||
for module in targets:
|
||||
monkey = getattr(module, "shared_client", None)
|
||||
if monkey is not None:
|
||||
module.shared_client = build
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for module in targets:
|
||||
if getattr(module, "shared_client", None) is build:
|
||||
module.shared_client = original
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ── Registre ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_registry_exposes_web_tools(registry):
|
||||
for name in WEB_TOOLS:
|
||||
assert name in registry.tools, name
|
||||
assert registry.tools[name].description
|
||||
|
||||
|
||||
def test_registry_schema_includes_web_tools(registry):
|
||||
names = [t["name"] for t in registry.schema()]
|
||||
for name in WEB_TOOLS:
|
||||
assert name in names
|
||||
# Un tool hors périmètre ne doit jamais fuiter dans le schéma.
|
||||
scoped = [t["name"] for t in registry.schema({"tools": ["search_workspace"]})]
|
||||
assert scoped == ["search_workspace"]
|
||||
|
||||
|
||||
def test_registry_rejects_unknown_tool(registry):
|
||||
res = run(registry.execute("web_search_does_not_exist", {}))
|
||||
assert res.status == "error"
|
||||
|
||||
|
||||
# ── Cohérence galerie / registre ───────────────────────────────────────────
|
||||
|
||||
def test_gallery_allowed_tools_all_exist():
|
||||
tools = set(ToolRegistry().tools)
|
||||
for slug, preset in GALLERY.items():
|
||||
unknown = [t for t in preset["allowed_tools"] if t not in tools]
|
||||
assert not unknown, f"{slug} référence des tools inexistants : {unknown}"
|
||||
|
||||
|
||||
def test_gallery_presets_are_complete():
|
||||
for slug, preset in GALLERY.items():
|
||||
assert preset["name"].strip(), slug
|
||||
assert preset["description"].strip(), slug
|
||||
assert preset["prompt_template"].strip(), slug
|
||||
assert preset["allowed_tools"], slug
|
||||
|
||||
|
||||
def test_web_presets_survive_export_import():
|
||||
"""Chaque nouveau preset doit survivre au cycle export → import."""
|
||||
for slug in ("recherche-marche", "veille-techno", "debug-web"):
|
||||
preset = GALLERY[slug]
|
||||
payload = export_skill({
|
||||
"name": preset["name"],
|
||||
"description": preset["description"],
|
||||
"prompt_template": preset["prompt_template"],
|
||||
"allowed_tools_json": json.dumps(preset["allowed_tools"]),
|
||||
})
|
||||
fields = parse_payload(payload)
|
||||
assert fields["name"] == preset["name"]
|
||||
assert sorted(fields["allowed_tools"]) == sorted(preset["allowed_tools"])
|
||||
|
||||
|
||||
# ── web_search ─────────────────────────────────────────────────────────────
|
||||
|
||||
EXA_BODY = {
|
||||
"results": [
|
||||
{"title": "FastAPI docs", "url": "https://fastapi.tiangolo.com/",
|
||||
"text": "FastAPI framework", "publishedDate": "2026-01-02T00:00:00Z"},
|
||||
{"title": "Spam", "url": "https://duckduckgo.com/y.js", "text": "à filtrer"},
|
||||
],
|
||||
}
|
||||
|
||||
DDG_HTML = """
|
||||
<table><tr>
|
||||
<td><a class="result-link" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=1">Résultat A</a></td>
|
||||
<td class="result-snippet">Extrait du <b>résultat</b> A</td>
|
||||
</tr><tr>
|
||||
<td><a class="result-link" href="https://example.org/b">Résultat B</a></td>
|
||||
<td class="result-snippet">Extrait B</td>
|
||||
</tr></table>
|
||||
"""
|
||||
|
||||
|
||||
def test_web_search_exa(monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "test-key")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["x-api-key"] == "test-key"
|
||||
return httpx.Response(200, json=EXA_BODY)
|
||||
|
||||
with mock_http(handler):
|
||||
results, provider = run(ws.search_web("fastapi", transport=httpx.MockTransport(handler)))
|
||||
|
||||
assert provider == "exa"
|
||||
assert [r["url"] for r in results] == ["https://fastapi.tiangolo.com/"]
|
||||
assert results[0]["source"] == "exa"
|
||||
assert results[0]["title"] == "FastAPI docs"
|
||||
assert results[0]["published"] == "2026-01-02"
|
||||
|
||||
|
||||
def test_web_search_filters_search_engine_urls(monkeypatch):
|
||||
"""Une URL du moteur lui-même ne doit jamais être renvoyée à l'IA."""
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731
|
||||
with mock_http(handler):
|
||||
results, _ = run(ws.search_web("x", transport=httpx.MockTransport(handler)))
|
||||
assert all("duckduckgo.com" not in r["url"] for r in results)
|
||||
|
||||
|
||||
def test_web_search_falls_back_to_duckduckgo(monkeypatch):
|
||||
"""Sans clé Exa, le tool bascule sur le repli sans compte."""
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "duckduckgo" in str(request.url)
|
||||
return httpx.Response(200, text=DDG_HTML,
|
||||
headers={"content-type": "text/html"})
|
||||
|
||||
with mock_http(handler):
|
||||
results, provider = run(ws.search_web("test", transport=httpx.MockTransport(handler)))
|
||||
|
||||
assert provider == "duckduckgo"
|
||||
assert [r["url"] for r in results] == ["https://example.com/a", "https://example.org/b"]
|
||||
assert results[0]["title"] == "Résultat A"
|
||||
assert results[0]["snippet"] == "Extrait du résultat A"
|
||||
|
||||
|
||||
def test_web_search_empty_query_returns_nothing(monkeypatch):
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
results, provider = run(ws.search_web(" "))
|
||||
assert results == []
|
||||
assert provider == ""
|
||||
|
||||
|
||||
def test_available_providers_reflects_config(monkeypatch):
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
assert ws.available_providers()["exa"] is False
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
assert ws.available_providers()["exa"] is True
|
||||
|
||||
|
||||
def test_web_search_tool_returns_actionable_error(registry, monkeypatch):
|
||||
"""Aucun provider ne répond → le tool dit comment corriger."""
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
handler = lambda r: httpx.Response(503, text="") # noqa: E731
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("web_search", {"query": "test"}))
|
||||
assert res.status == "error"
|
||||
assert "EXA_API_KEY" in res.message
|
||||
|
||||
|
||||
def test_web_search_tool_success(registry, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("web_search", {"query": "fastapi", "num_results": 3}))
|
||||
assert res.status == "success"
|
||||
assert res.data["count"] == 1
|
||||
assert res.data["provider"] == "exa"
|
||||
|
||||
|
||||
# ── fetch_url ──────────────────────────────────────────────────────────────
|
||||
|
||||
HTML_PAGE = """
|
||||
<html><head><title>Titre de la page</title>
|
||||
<meta property="og:description" content="Description courte"></head>
|
||||
<body><article>
|
||||
<h1>Titre de la page</h1>
|
||||
<p>Premier paragraphe avec du <b>gras</b>.</p>
|
||||
<pre><code>print("hello")</code></pre>
|
||||
<ul><li>un</li><li>deux</li></ul>
|
||||
</article></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_fetch_url_returns_markdown(registry):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://example.com/page"
|
||||
return httpx.Response(200, text=HTML_PAGE,
|
||||
headers={"content-type": "text/html; charset=utf-8"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/page"}))
|
||||
|
||||
assert res.status == "success", res.message
|
||||
assert "Premier paragraphe" in res.data["markdown"]
|
||||
assert "print" in res.data["markdown"]
|
||||
assert res.data["title"] == "Titre de la page"
|
||||
assert res.data["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://localhost:8080/api/health",
|
||||
"http://127.0.0.1/admin",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]/",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/x",
|
||||
])
|
||||
def test_fetch_url_blocks_ssrf_targets(registry, url):
|
||||
res = run(registry.execute("fetch_url", {"url": url}))
|
||||
assert res.status == "error", f"{url} aurait dû être refusé"
|
||||
|
||||
|
||||
def test_fetch_url_revalidates_redirect_target(registry):
|
||||
"""Une URL publique redirigeant vers le metadata cloud doit être refusée."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/"}))
|
||||
|
||||
assert res.status == "error"
|
||||
assert "non autorisé" in res.message
|
||||
|
||||
|
||||
def test_fetch_url_follows_public_redirect(registry):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/old":
|
||||
return httpx.Response(301, headers={"location": "https://example.com/new"})
|
||||
return httpx.Response(200, text=HTML_PAGE,
|
||||
headers={"content-type": "text/html"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/old"}))
|
||||
|
||||
assert res.status == "success", res.message
|
||||
assert res.data["url"] == "https://example.com/new"
|
||||
|
||||
|
||||
def test_fetch_url_truncates(registry):
|
||||
long_html = "<html><body><p>" + ("a" * 50000) + "</p></body></html>"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=long_html,
|
||||
headers={"content-type": "text/html"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute(
|
||||
"fetch_url", {"url": "https://example.com/long", "max_chars": 1000}))
|
||||
|
||||
assert res.status == "success"
|
||||
assert res.data["truncated"] is True
|
||||
assert len(res.data["markdown"]) < 1200
|
||||
|
||||
|
||||
def test_fetch_url_empty_url(registry):
|
||||
assert run(registry.execute("fetch_url", {"url": " "})).status == "error"
|
||||
|
||||
|
||||
# ── search_code ────────────────────────────────────────────────────────────
|
||||
|
||||
GH_REPOS = {
|
||||
"total_count": 2,
|
||||
"items": [
|
||||
{"full_name": "tiangolo/fastapi", "html_url": "https://github.com/tiangolo/fastapi",
|
||||
"description": "FastAPI framework", "stargazers_count": 80000},
|
||||
{"full_name": "someone/fastapi-clone", "html_url": "https://github.com/someone/fastapi-clone",
|
||||
"description": "A clone", "stargazers_count": 3},
|
||||
],
|
||||
}
|
||||
|
||||
GH_ISSUES = {
|
||||
"total_count": 1,
|
||||
"items": [
|
||||
{"number": 42, "title": "Crash on startup", "state": "closed",
|
||||
"html_url": "https://github.com/o/r/issues/42", "body": "It crashes"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_search_code_repos(monkeypatch):
|
||||
monkeypatch.setattr(settings, "github_token", "")
|
||||
handler = lambda r: httpx.Response(200, json=GH_REPOS) # noqa: E731
|
||||
results = run(ws.search_github("fastapi", "repositories", 5,
|
||||
transport=httpx.MockTransport(handler)))
|
||||
assert [r["title"] for r in results] == ["tiangolo/fastapi", "someone/fastapi-clone"]
|
||||
assert results[0]["stars"] == 80000
|
||||
assert results[0]["source"] == "github/repos"
|
||||
|
||||
|
||||
def test_search_code_issues_sends_token(monkeypatch):
|
||||
monkeypatch.setattr(settings, "github_token", "ghp_test")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == "Bearer ghp_test"
|
||||
assert request.url.path.endswith("/search/issues")
|
||||
return httpx.Response(200, json=GH_ISSUES)
|
||||
|
||||
results = run(ws.search_github("crash", "issues", 5,
|
||||
transport=httpx.MockTransport(handler)))
|
||||
assert results[0]["title"] == "Crash on startup"
|
||||
assert results[0]["state"] == "closed"
|
||||
assert results[0]["url"].endswith("/issues/42")
|
||||
|
||||
|
||||
def test_search_code_quota_message(monkeypatch):
|
||||
monkeypatch.setattr(settings, "github_token", "")
|
||||
handler = lambda r: httpx.Response(403, json={}) # noqa: E731
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
run(ws.search_github("x", "repos", 5, transport=httpx.MockTransport(handler)))
|
||||
assert "GITHUB_TOKEN" in str(err.value)
|
||||
|
||||
|
||||
def test_search_code_tool_wraps_errors(registry, monkeypatch):
|
||||
"""Le tool doit transformer une erreur GitHub en message lisible, sans réseau."""
|
||||
async def _boom(*a, **kw):
|
||||
raise RuntimeError("GitHub: requête de recherche invalide (422)")
|
||||
|
||||
# L'outillage importe ``search_github`` au moment de l'appel : c'est
|
||||
# l'attribut du module qu'il faut patcher, pas celui du registre.
|
||||
monkeypatch.setattr(ws, "search_github", _boom)
|
||||
res = run(registry.execute("search_code", {"query": "!!!"}))
|
||||
assert res.status == "error"
|
||||
assert "GitHub" in res.message
|
||||
|
||||
|
||||
def test_search_code_tool_success(registry, monkeypatch):
|
||||
monkeypatch.setattr(settings, "github_token", "")
|
||||
handler = lambda r: httpx.Response(200, json=GH_REPOS) # noqa: E731
|
||||
real_search = ws.search_github
|
||||
|
||||
async def _fake(query, kind="repositories", limit=5, transport=None):
|
||||
return await real_search(query, kind, limit, transport=httpx.MockTransport(handler))
|
||||
|
||||
monkeypatch.setattr(ws, "search_github", _fake)
|
||||
res = run(registry.execute("search_code", {"query": "fastapi", "kind": "repositories"}))
|
||||
assert res.status == "success"
|
||||
assert res.data["count"] == 2
|
||||
assert res.data["results"][0]["title"] == "tiangolo/fastapi"
|
||||
@@ -0,0 +1,376 @@
|
||||
"""FlowDeck — correctifs d'anomalies v7.46.0 (audit de fonctionnement).
|
||||
|
||||
Chaque test verrouille une anomalie réellement constatée sur l'instance :
|
||||
|
||||
* **P0-3** ``/auth/user`` renvoyait le ``password_hash`` (et le cookie de
|
||||
session, signé mais non chiffré, l'embarquait) ;
|
||||
* **P0-4** ``gitea_oauth_client_id`` vaut le placeholder ``test-id`` →
|
||||
``/auth/login`` redirigeait vers Gitea avec un client_id invalide ;
|
||||
* **P0-1** écritures anonymes sur ``/api/workspaces*`` et
|
||||
``/api/local-workspace/items`` (``uid = ... else 1``) ;
|
||||
* **P0-2** ``/workspace/*`` sans session : export CSV, historique, commentaires ;
|
||||
* **P1-5** bouton Home : ``local_workspaces[0]`` (ordre alphabétique) au lieu
|
||||
de l'espace actif ;
|
||||
* **P1-6** ``/workspace/favorites`` : 500 permanent (colonne ``collection_id``
|
||||
supprimée par la migration v2.2.0) ;
|
||||
* **P1-7** ``/api/v2/agents/conversations`` masqué par ``/agents/{agent_id}`` ;
|
||||
* **P1-8** l'éditeur de page appelait ``/api/synced-blocks`` (404) au lieu de
|
||||
``/board/api/synced-blocks`` ;
|
||||
* **P1-9** ``/board/api/synced-blocks`` : 500 anonyme + absence de contrôle de
|
||||
propriété sur PUT/DELETE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf, login_test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Same isolated temp DB contract as tests/conftest.py::client."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
|
||||
data_dir = tempfile.mkdtemp(prefix="fd_data_")
|
||||
|
||||
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["LLM_PROVIDER"] = "offline"
|
||||
os.environ["FLOWDECK_DATA_DIR"] = data_dir
|
||||
|
||||
import app.config
|
||||
|
||||
s = app.config.settings
|
||||
s.database_url = f"sqlite:///{db_path}"
|
||||
s.app_secret_key = "test-secret-for-tests"
|
||||
s.rate_limit_enabled = False
|
||||
s.backup_enabled = False
|
||||
s.backup_dir = backup_dir
|
||||
s.project_sync_enabled = False
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
init_db()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
c = login_test_client(TestClient(app))
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _mk_ws(client, name: str, owner: int = 1) -> int:
|
||||
r = client.post("/api/workspaces", json={"name": name})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
# ══════════════════════ P0-3 — password_hash ══════════════════════
|
||||
|
||||
def test_auth_user_never_exposes_password_hash(client):
|
||||
r = client.get("/auth/user")
|
||||
assert r.status_code == 200
|
||||
assert "password_hash" not in r.text
|
||||
|
||||
|
||||
def test_session_cookie_does_not_embed_password_hash(client):
|
||||
from app.auth.session import SessionManager, public_user
|
||||
|
||||
sanitized = public_user({"id": 1, "login": "a", "password_hash": "deadbeef"})
|
||||
assert "password_hash" not in sanitized
|
||||
|
||||
raw = SessionManager.create_session(
|
||||
{"id": 1, "login": "a", "password_hash": "deadbeef", "is_admin": 1}
|
||||
)
|
||||
assert "deadbeef" not in raw
|
||||
# Le payload décodé ne contient plus le champ sensible (décodé par la même
|
||||
# instance de serializer que le serveur, independamment de `settings`).
|
||||
decoded = SessionManager.decode_session(raw)
|
||||
assert decoded is not None
|
||||
assert "password_hash" not in decoded
|
||||
assert decoded["login"] == "a"
|
||||
|
||||
|
||||
# ══════════════════════ P0-4 — OAuth placeholder ══════════════════════
|
||||
|
||||
def test_placeholder_gitea_credentials_are_not_enabled(client):
|
||||
import app.auth.providers as providers
|
||||
|
||||
gitea = providers.GiteaProvider(
|
||||
base_url="https://git.example.net",
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
redirect_uri="",
|
||||
)
|
||||
assert gitea.is_enabled() is False
|
||||
|
||||
|
||||
def test_login_does_not_redirect_to_placeholder_client(client):
|
||||
r = client.get("/auth/login", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "client_id=test-id" not in r.text
|
||||
assert "not configured" in r.text.lower()
|
||||
|
||||
|
||||
def test_login_redirects_when_credentials_are_real(client):
|
||||
from app.config import settings
|
||||
|
||||
old_id, old_secret = settings.gitea_oauth_client_id, settings.gitea_oauth_client_secret
|
||||
settings.gitea_oauth_client_id = "real-id"
|
||||
settings.gitea_oauth_client_secret = "real-secret"
|
||||
try:
|
||||
r = client.get("/auth/login", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "client_id=real-id" in r.headers["location"]
|
||||
finally:
|
||||
settings.gitea_oauth_client_id = old_id
|
||||
settings.gitea_oauth_client_secret = old_secret
|
||||
|
||||
|
||||
# ══════════════════════ P0-1 — écritures anonymes ══════════════════════
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,body",
|
||||
[
|
||||
("post", "/api/workspaces", {"name": "ANON"}),
|
||||
("put", "/api/workspaces/1", {"name": "ANON"}),
|
||||
("delete", "/api/workspaces/1", None),
|
||||
("post", "/api/workspaces/1/select", {}),
|
||||
("post", "/api/local-workspace/items", {"name": "ANON"}),
|
||||
("put", "/api/local-workspace/items/1", {"name": "ANON"}),
|
||||
("delete", "/api/local-workspace/items/1", None),
|
||||
("post", "/api/local-workspace/items/1/restore", {}),
|
||||
("put", "/api/local-workspace/items/1/move", {"parent_id": None}),
|
||||
],
|
||||
)
|
||||
def test_anonymous_write_is_rejected(client, method, path, body):
|
||||
c = anon_csrf(client)
|
||||
fn = getattr(c, method)
|
||||
r = fn(path, json=body) if body is not None else fn(path)
|
||||
assert r.status_code == 401, f"{method.upper()} {path} -> {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_anonymous_workspace_write_creates_nothing(client):
|
||||
ws_id = _mk_ws(client, "Real WS")
|
||||
c = anon_csrf(client)
|
||||
assert c.post("/api/workspaces", json={"name": "Ghost"}).status_code == 401
|
||||
assert c.delete(f"/api/workspaces/{ws_id}").status_code == 401
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
names = [r[0] for r in conn.execute("SELECT name FROM workspaces")]
|
||||
assert "Ghost" not in names
|
||||
assert "Real WS" in names
|
||||
|
||||
|
||||
def test_workspace_rename_delete_require_ownership(client):
|
||||
ws_id = _mk_ws(client, "WS of admin")
|
||||
other = login_test_client(client, user_id=2, login="intruder", is_admin=0)
|
||||
assert other.put(f"/api/workspaces/{ws_id}", json={"name": "hijacked"}).status_code == 403
|
||||
assert other.delete(f"/api/workspaces/{ws_id}").status_code == 403
|
||||
assert other.post(f"/api/workspaces/{ws_id}/select").status_code == 403
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT name FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()[0] == "WS of admin"
|
||||
|
||||
|
||||
# ══════════════════════ P0-2 — /workspace authentifié ══════════════════════
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/workspace/favorites",
|
||||
"/workspace/pages/1/comments",
|
||||
"/workspace/pages/1/history",
|
||||
"/workspace/templates/database",
|
||||
"/workspace/collections/1/export/csv",
|
||||
"/workspace/collections/1/sprints",
|
||||
"/workspace/collections/1/dashboards",
|
||||
],
|
||||
)
|
||||
def test_workspace_router_requires_session(client, path):
|
||||
c = anon_csrf(client)
|
||||
r = c.get(path)
|
||||
assert r.status_code == 401, f"{path} -> {r.status_code} {r.text[:160]}"
|
||||
|
||||
|
||||
def test_public_sharing_route_stays_public(client):
|
||||
"""La seule route publique du router `/workspace` reste accessible."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO collections (name) VALUES ('Public')")
|
||||
conn.commit()
|
||||
cid = conn.execute("SELECT MAX(id) FROM collections").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
|
||||
(cid, "Page publique"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r = anon_csrf(client).get(f"/workspace/public/{cid}")
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert "Page publique" in r.text
|
||||
|
||||
# Une collection absente ne doit surtout pas exiger une session (401).
|
||||
r2 = anon_csrf(client).get("/workspace/public/424242")
|
||||
assert r2.status_code != 401
|
||||
|
||||
|
||||
# ══════════════════════ P1-6 — favorites ══════════════════════
|
||||
|
||||
def test_favorites_roundtrip_uses_current_schema(client):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format) "
|
||||
"VALUES ('WS','Fav page','','blocks')"
|
||||
)
|
||||
conn.commit()
|
||||
page_id = conn.execute("SELECT MAX(id) FROM pages").fetchone()[0]
|
||||
|
||||
r = client.get("/workspace/favorites")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["favorites"] == []
|
||||
|
||||
r = client.post("/workspace/favorites", json={"page_id": page_id})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/workspace/favorites")
|
||||
assert r.status_code == 200, r.text
|
||||
favs = r.json()["favorites"]
|
||||
assert len(favs) == 1
|
||||
assert favs[0]["page_id"] == page_id
|
||||
assert favs[0]["page_title"] == "Fav page"
|
||||
|
||||
assert client.delete(f"/workspace/favorites/{favs[0]['id']}").status_code == 200
|
||||
assert client.get("/workspace/favorites").json()["favorites"] == []
|
||||
|
||||
|
||||
def test_favorites_add_requires_page_id(client):
|
||||
assert client.post("/workspace/favorites", json={}).status_code == 400
|
||||
|
||||
|
||||
# ══════════════════════ P1-7 — ordre de routes agents ══════════════════════
|
||||
|
||||
def test_agents_conversations_route_is_not_shadowed(client):
|
||||
r = client.get("/api/v2/agents/conversations")
|
||||
# 401 « API token required » = la route a bien été atteinte (avant : 422
|
||||
# int_parsing sur /agents/{agent_id}).
|
||||
assert r.status_code == 401, r.text
|
||||
assert "int_parsing" not in r.text
|
||||
|
||||
|
||||
# ══════════════════════ P1-8 / P1-9 — synced blocks ══════════════════════
|
||||
|
||||
def test_page_editor_uses_board_prefix_for_synced_blocks():
|
||||
from pathlib import Path
|
||||
|
||||
src = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
assert "'/api/synced-blocks'" not in src
|
||||
assert src.count("'/board/api/synced-blocks'") == 1
|
||||
assert "'/board/api/synced-blocks/'" in src
|
||||
assert "Settings → Synced Blocks" not in src
|
||||
|
||||
|
||||
def test_synced_blocks_anonymous_is_401_not_500(client):
|
||||
c = anon_csrf(client)
|
||||
assert c.get("/board/api/synced-blocks").status_code == 401
|
||||
assert c.get("/board/api/synced-blocks/1").status_code == 401
|
||||
assert c.post("/board/api/synced-blocks", json={"title": "x"}).status_code == 401
|
||||
|
||||
|
||||
def test_synced_block_cannot_be_edited_by_another_user(client):
|
||||
|
||||
r = client.post("/board/api/synced-blocks", json={"title": "Bloc", "content": []})
|
||||
assert r.status_code == 200, r.text
|
||||
sid = r.json()["synced_block_id"]
|
||||
|
||||
intruder = login_test_client(client, user_id=2, login="intruder", is_admin=0)
|
||||
assert intruder.put(f"/board/api/synced-blocks/{sid}", json={"title": "pwn"}).status_code == 403
|
||||
assert intruder.delete(f"/board/api/synced-blocks/{sid}").status_code == 403
|
||||
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
|
||||
assert get_synced_block(sid)["title"] == "Bloc"
|
||||
|
||||
owner = login_test_client(client, user_id=1, login="tester", is_admin=1)
|
||||
assert owner.put(f"/board/api/synced-blocks/{sid}", json={"title": "ok"}).status_code == 200
|
||||
assert get_synced_block(sid)["title"] == "ok"
|
||||
|
||||
|
||||
def test_synced_blocks_list_with_session(client):
|
||||
r = client.get("/board/api/synced-blocks")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "synced_blocks" in r.json()
|
||||
|
||||
|
||||
# ══════════════════════ P1-5 — bouton Home ══════════════════════
|
||||
|
||||
def test_undefined_template_variable_is_logged_not_silent(caplog):
|
||||
"""P1-10 : une variable absente du contexte rend ``""`` (contrattenu par
|
||||
40+ templates optionnelles) mais ne doit plus être totally silencieuse."""
|
||||
import logging
|
||||
|
||||
from app.templating import ENV
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.templating"):
|
||||
out = ENV.from_string("{{ totally_unknown_variable }}").render()
|
||||
assert out == ""
|
||||
assert any(
|
||||
"totally_unknown_variable" in r.getMessage() for r in caplog.records
|
||||
), [r.getMessage() for r in caplog.records]
|
||||
|
||||
|
||||
def test_home_button_targets_the_active_workspace(client):
|
||||
"""``local_workspaces[0]`` = premier workspace TRIÉ PAR NOM ; le Home doit
|
||||
cibler l'espace actif (``local_ws_id``)."""
|
||||
from pathlib import Path
|
||||
|
||||
tpl = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
m = re.search(r'<a href="([^"]*)" class="nav-icon-btn home-btn[^"]*"', tpl)
|
||||
assert m, "lien Home introuvable dans base.html"
|
||||
href = m.group(1)
|
||||
assert "local_ws_id" in href, href
|
||||
assert href.index("local_ws_id") < href.index("local_workspaces")
|
||||
|
||||
|
||||
def test_home_button_renders_active_workspace_id(client):
|
||||
from app.db import get_conn
|
||||
|
||||
a = _mk_ws(client, "AAA first alphabetically")
|
||||
b = _mk_ws(client, "ZZZ active")
|
||||
client.post(f"/api/workspaces/{b}/select")
|
||||
r = client.post("/api/local-workspace/items", json={"name": "HomeProbe", "workspace_id": b})
|
||||
assert r.status_code == 200, r.text
|
||||
pid = r.json()["id"]
|
||||
|
||||
r = client.get(f"/pages/{pid}")
|
||||
assert r.status_code == 200, r.status_code
|
||||
m = re.search(r'<a href="([^"]*)" class="nav-icon-btn home-btn[^"]*"', r.text)
|
||||
assert m, "lien Home absent du HTML rendu"
|
||||
assert m.group(1) == f"/local-workspace?ws={b}"
|
||||
assert f"/local-workspace?ws={a}" != m.group(1)
|
||||
|
||||
with get_conn() as conn:
|
||||
names = {r[0] for r in conn.execute("SELECT name FROM workspaces")}
|
||||
assert {"AAA first alphabetically", "ZZZ active"} <= names
|
||||
+117
-34
@@ -840,28 +840,73 @@ def test_my_tasks_page(client):
|
||||
|
||||
|
||||
def test_my_tasks_cross_db(client):
|
||||
"""My Tasks API returns JSON."""
|
||||
r = client.post("/db/api", json={"name": "My Project"})
|
||||
"""My Tasks API returns JSON from every *connected* database.
|
||||
|
||||
Depuis v7.47.0, une base n'alimente My Tasks qu'après conversion
|
||||
explicite (``POST /db/{id}/task-db/api``, mapping des 3 colonnes) : la
|
||||
page n'est plus scopée par ``flowdeck_workspace``.
|
||||
"""
|
||||
import time
|
||||
|
||||
from app.db import get_conn
|
||||
col_name = f"My Project {int(time.time() * 1000)}"
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=(SELECT id FROM users WHERE login='tester') LIMIT 1"
|
||||
).fetchone()
|
||||
ws_id = ws_row["id"] if ws_row else conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, "
|
||||
"(SELECT id FROM users WHERE login='tester'))", ("My Tasks Cross DB WS",)
|
||||
).lastrowid
|
||||
conn.commit()
|
||||
|
||||
r = client.post("/db/api", json={"name": col_name, "workspace_id": ws_id})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
|
||||
|
||||
# Sans conversion : la base n'émet rien.
|
||||
resp = client.get("/my-tasks/api")
|
||||
assert resp.status_code == 200
|
||||
assert cid not in [t["collection_id"] for t in resp.json()["tasks"]]
|
||||
|
||||
conv = client.post(f"/db/{cid}/task-db/api", json={})
|
||||
assert conv.status_code == 200, conv.text
|
||||
assert conv.json()["configured"] is True
|
||||
|
||||
for title in ("Task 1", "Task 2"):
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": title})
|
||||
|
||||
resp = client.get("/my-tasks/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tasks" in data
|
||||
assert data["total"] >= 2
|
||||
titles = [t["title"] for t in data["tasks"]]
|
||||
assert "Task 1" in titles and "Task 2" in titles
|
||||
assert col_name in [s["name"] for s in data["sources"]]
|
||||
# Le périmètre est transverse : pas de workspace_id dans le payload.
|
||||
assert "workspace_id" not in data
|
||||
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_my_tasks_view_today(client):
|
||||
resp = client.get("/my-tasks?view=today")
|
||||
@pytest.mark.parametrize("view", ["table", "board", "calendar"])
|
||||
def test_my_tasks_views(client, view):
|
||||
"""Les trois vues (tableau / kanban / calendrier) sont acceptées.
|
||||
|
||||
Sans base connectée, le serveur rend son état vide (le conteneur client
|
||||
n'est chargé que lorsqu'il y a des tâches à afficher) : on vérifie donc le
|
||||
code HTTP ici, et le contenu via l'API.
|
||||
"""
|
||||
resp = client.get("/my-tasks", params={"view": view})
|
||||
assert resp.status_code == 200
|
||||
assert "My Tasks" in resp.text
|
||||
assert client.get("/my-tasks/api",
|
||||
params={"view": view}).status_code == 200
|
||||
|
||||
|
||||
def test_my_tasks_view_overdue(client):
|
||||
resp = client.get("/my-tasks?view=overdue")
|
||||
@pytest.mark.parametrize("due", ["all", "today", "overdue", "week"])
|
||||
def test_my_tasks_due_filters(client, due):
|
||||
resp = client.get("/my-tasks/api", params={"due": due})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["filters"]["due"] == due
|
||||
|
||||
|
||||
# ── v2.1.0: Workspace, Comments, Favorites, CSV ──
|
||||
@@ -1716,18 +1761,33 @@ def test_oauth_login_local_page(client):
|
||||
assert "Login" in resp.text or "login" in resp.text.lower()
|
||||
|
||||
|
||||
def test_oauth_login_gitea_redirect(client):
|
||||
"""GET /auth/login?provider=gitea — redirects to Gitea OAuth (configured in test env)."""
|
||||
def test_oauth_login_gitea_redirect(client, monkeypatch):
|
||||
"""GET /auth/login?provider=gitea — redirects to Gitea OAuth when configured.
|
||||
|
||||
v7.46.0 : les valeurs de substitution livrées dans app/config.py
|
||||
(``test-id`` / ``test-secret``) ne comptent plus comme « configuré » — sinon
|
||||
/auth/login redirigeait vers Gitea avec un client_id invalide. Le test
|
||||
configure donc de VRAIS identifiants.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id")
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret")
|
||||
resp = client.get("/auth/login?provider=gitea", follow_redirects=False)
|
||||
# Gitea OAuth IS configured in test env → redirect to Gitea
|
||||
# Gitea OAuth configuré → redirect to Gitea
|
||||
assert resp.status_code == 302
|
||||
assert "login/oauth" in resp.headers.get("location", "").lower()
|
||||
assert "client_id=gitea-real-id" in resp.headers["location"]
|
||||
|
||||
|
||||
def test_oauth_login_with_link_mode(client):
|
||||
def test_oauth_login_with_link_mode(client, monkeypatch):
|
||||
"""GET /auth/login?provider=gitea&mode=link — link mode redirects to Gitea OAuth."""
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id")
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret")
|
||||
resp = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
|
||||
# Gitea OAuth IS configured → redirect to Gitea with link mode set in session
|
||||
# Gitea OAuth configuré → redirect to Gitea with link mode set in session
|
||||
assert resp.status_code == 302
|
||||
assert "login/oauth" in resp.headers.get("location", "").lower()
|
||||
|
||||
@@ -1786,8 +1846,12 @@ def test_get_redirect_uri_env_override_wins(monkeypatch):
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
def test_oauth_login_redirect_uri_dynamic(client):
|
||||
def test_oauth_login_redirect_uri_dynamic(client, monkeypatch):
|
||||
"""The authorize URL carries the request-derived redirect_uri (encoded)."""
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id")
|
||||
monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret")
|
||||
resp = client.get(
|
||||
"/auth/login?provider=gitea",
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "flowdeck.dracodev.net"},
|
||||
@@ -2802,28 +2866,47 @@ def test_sprint_burndown(client):
|
||||
|
||||
|
||||
def test_my_tasks_page_render(client):
|
||||
"""My Tasks page renders cross-database aggregation."""
|
||||
# Create a task collection
|
||||
resp = client.post("/db/api", json={"name": "My Tasks DB"})
|
||||
coll_id = resp.json()["id"]
|
||||
"""My Tasks page renders the three views and its sources.
|
||||
|
||||
# Toggle to task mode
|
||||
client.put(f"/db/{coll_id}/toggle-task/api")
|
||||
Le tableau de bord est rendu côté client depuis ``/my-tasks/api`` : le HTML
|
||||
transporte la configuration (``data-config``), pas les tâches.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Add pages
|
||||
client.post(f"/db/{coll_id}/pages/api", json={
|
||||
"title": "Urgent fix",
|
||||
"properties": {"Status": "Todo"},
|
||||
})
|
||||
client.post(f"/db/{coll_id}/pages/api", json={
|
||||
"title": "Deploy",
|
||||
"properties": {"Status": "Done"},
|
||||
})
|
||||
from app.db import get_conn
|
||||
name = f"My Tasks DB {int(time.time() * 1000)}"
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=(SELECT id FROM users WHERE login='tester') LIMIT 1"
|
||||
).fetchone()
|
||||
ws_id = ws_row["id"] if ws_row else conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, "
|
||||
"(SELECT id FROM users WHERE login='tester'))", ("Test Workspace",)
|
||||
).lastrowid
|
||||
conn.commit()
|
||||
|
||||
resp = client.get("/my-tasks?view=all")
|
||||
coll_id = client.post("/db/api",
|
||||
json={"name": name, "workspace_id": ws_id}).json()["id"]
|
||||
|
||||
# `toggle-task` reste rétrocompatible : il réalise la conversion complète.
|
||||
resp = client.put(f"/db/{coll_id}/toggle-task/api")
|
||||
assert resp.status_code == 200
|
||||
assert "Urgent fix" in resp.text
|
||||
assert "My Tasks DB" in resp.text
|
||||
assert resp.json()["is_task"] is True
|
||||
|
||||
client.post(f"/db/{coll_id}/pages/api", json={"title": "Urgent fix"})
|
||||
client.post(f"/db/{coll_id}/pages/api", json={"title": "Deploy"})
|
||||
|
||||
resp = client.get("/my-tasks")
|
||||
assert resp.status_code == 200
|
||||
assert 'id="my-tasks-app"' in resp.text
|
||||
assert "/static/js/my_tasks.js" in resp.text
|
||||
|
||||
# Les titres sont bien exposés par l'API alimentée par la page.
|
||||
titles = [t["title"] for t in client.get("/my-tasks/api").json()["tasks"]]
|
||||
assert "Urgent fix" in titles
|
||||
assert name in [s["name"] for s in client.get("/my-tasks/api").json()["sources"]]
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
# ── v4.6.0: Content Blocks Enriched ──
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""FlowDeck — bouton « Home » de la sidebar : le paramètre ``?ws=`` est respecté.
|
||||
|
||||
Le sidebar construit ``/local-workspace?ws=<id>`` (premier workspace possédé,
|
||||
trié par nom). La route déclarait seulement ``folder`` : ``ws`` était ignoré et
|
||||
la page affichait le workspace **actif** (cookie), donc Home pouvait montrer un
|
||||
workspace différent de celui annoncé dans l'URL.
|
||||
|
||||
Ces tests verrouillent le contrat :
|
||||
* ``?ws=`` affiche bien le workspace demandé (et aligne le cookie) ;
|
||||
* un ``?ws=`` appartenant à quelqu'un d'autre est ignoré — aucune fuite ;
|
||||
* sans ``?ws=``, le comportement précédent (workspace actif) est inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
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["LLM_PROVIDER"] = "offline"
|
||||
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.llm_provider = "offline"
|
||||
init_db()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = login_test_client(TestClient(app))
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _make_workspace(client, name: str) -> dict:
|
||||
r = client.post("/api/workspaces", json={"name": name})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _workspace_id_in(client, workspace_id: int) -> int | None:
|
||||
"""Lit le workspace_id embarqué dans le HTML rendu (lw-config)."""
|
||||
r = client.get(f"/local-workspace?ws={workspace_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.text
|
||||
marker = '"workspace_id":'
|
||||
idx = body.find(marker)
|
||||
assert idx != -1, "lw-config absent de la page"
|
||||
rest = body[idx + len(marker):].lstrip()
|
||||
digits = ""
|
||||
for ch in rest:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def test_home_ws_param_is_honoured(client):
|
||||
"""Home doit afficher le workspace qu'il annonce, pas celui du cookie."""
|
||||
client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "Home1"})
|
||||
zeta = _make_workspace(client, "Zeta Project") # id plus élevé
|
||||
alpha = _make_workspace(client, "Alpha Project") # premier par nom
|
||||
|
||||
# Le cookie « workspace actif » pointe sur Zeta…
|
||||
client.post(f"/api/workspaces/{zeta['id']}/select")
|
||||
|
||||
# …mais Home annonce le premier par nom (Alpha).
|
||||
assert _workspace_id_in(client, alpha["id"]) == alpha["id"]
|
||||
assert _workspace_id_in(client, zeta["id"]) == zeta["id"]
|
||||
|
||||
|
||||
def test_home_ws_param_title_matches_content(client):
|
||||
"""Le titre affiché doit être celui du workspace demandé, pas celui du cookie.
|
||||
|
||||
Régression : la sidebar est calculée avant le changement de workspace, donc
|
||||
`active_ws_name` désignait l'ancien workspace — le contenu aurait été celui
|
||||
de Zeta sous le titre « Alpha Project ».
|
||||
"""
|
||||
client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "Home5"})
|
||||
zeta = _make_workspace(client, "Zeta Project")
|
||||
alpha = _make_workspace(client, "Alpha Project")
|
||||
client.post(f"/api/workspaces/{zeta['id']}/select")
|
||||
|
||||
r = client.get(f"/local-workspace?ws={alpha['id']}")
|
||||
assert r.status_code == 200
|
||||
# La sidebar liste légitimement tous les workspaces : on cible le <title>,
|
||||
# qui reflète le workspace dont le contenu est rendu.
|
||||
import re
|
||||
|
||||
title = re.search(r"<title>(.*?)</title>", r.text, re.S).group(1)
|
||||
assert "Alpha Project" in title
|
||||
assert "Zeta Project" not in title
|
||||
|
||||
|
||||
def test_home_ws_param_aligns_cookie(client):
|
||||
"""Après un Home vers ?ws=, le cookie doit suivre le workspace affiché."""
|
||||
client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "Home2"})
|
||||
a = _make_workspace(client, "AAA")
|
||||
b = _make_workspace(client, "BBB")
|
||||
client.post(f"/api/workspaces/{a['id']}/select")
|
||||
|
||||
r = client.get(f"/local-workspace?ws={b['id']}")
|
||||
assert r.status_code == 200
|
||||
assert client.cookies.get("flowdeck_workspace") == str(b["id"])
|
||||
|
||||
|
||||
def test_foreign_ws_param_is_ignored(client):
|
||||
"""Un ?ws= appartenant à un autre utilisateur ne doit rien divulguer."""
|
||||
client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "Home3"})
|
||||
mine = _make_workspace(client, "Mine")
|
||||
|
||||
# Workspace appartenant à un AUTRE utilisateur (FK users respectée)
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, email, full_name, password_hash, is_active) "
|
||||
"VALUES ('ghost', '[email protected]', 'Ghost', ?, 1)",
|
||||
(hash_password("secret123"),))
|
||||
conn.commit()
|
||||
ghost_id = conn.execute("SELECT id FROM users WHERE login='ghost'").fetchone()["id"]
|
||||
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
("Secret Corp", ghost_id))
|
||||
conn.commit()
|
||||
foreign = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE name='Secret Corp'").fetchone()["id"]
|
||||
|
||||
r = client.get(f"/local-workspace?ws={foreign}")
|
||||
assert r.status_code == 200
|
||||
# Le workspace demandé n'est pas rendu : on retombe sur celui du visiteur.
|
||||
assert _workspace_id_in(client, foreign) == mine["id"]
|
||||
assert client.cookies.get("flowdeck_workspace") != str(foreign)
|
||||
assert "Secret Corp" not in r.text
|
||||
|
||||
|
||||
def test_without_ws_param_active_workspace_is_used(client):
|
||||
"""Sans ?ws=, le comportement historique est inchangé."""
|
||||
client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "Home4"})
|
||||
_make_workspace(client, "Active")
|
||||
b = _make_workspace(client, "Other")
|
||||
client.post(f"/api/workspaces/{b['id']}/select")
|
||||
|
||||
assert _workspace_id_in(client, b["id"]) == b["id"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
"""FlowDeck — onglets compressibles du sidebar (Home / Chat / Meeting / Inbox).
|
||||
|
||||
La barre latérale gauche devient un sélecteur d'onglet : le bouton actif
|
||||
s'étend en pilule avec son libellé, les autres se réduisent à leur icône, et
|
||||
chaque sélection remplace intégralement le contenu de la barre latérale.
|
||||
|
||||
* ordre exact des onglets : Home → Chat → Meeting → Inbox ;
|
||||
* quatre panneaux Alpine dédiés (``sidebarTab === …``) ;
|
||||
* contenu branché sur des données réelles : historique des conversations
|
||||
agent (``/api/agent/conversations``), événements à venir
|
||||
(``GET /api/v2/meetings/upcoming``, nouveau) et notifications
|
||||
(``/api/notifications``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import login_test_client
|
||||
|
||||
TPL = Path("app/templates/base.html")
|
||||
CSS = Path("static/css/app.css")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Same isolated temp DB contract as tests/conftest.py::client."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
|
||||
data_dir = tempfile.mkdtemp(prefix="fd_data_")
|
||||
|
||||
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["LLM_PROVIDER"] = "offline"
|
||||
os.environ["FLOWDECK_DATA_DIR"] = data_dir
|
||||
|
||||
import app.config
|
||||
|
||||
s = app.config.settings
|
||||
s.database_url = f"sqlite:///{db_path}"
|
||||
s.app_secret_key = "test-secret-for-tests"
|
||||
s.rate_limit_enabled = False
|
||||
s.backup_enabled = False
|
||||
s.backup_dir = backup_dir
|
||||
s.project_sync_enabled = False
|
||||
|
||||
from app.db import init_db
|
||||
from app.main import app
|
||||
|
||||
init_db()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
c = login_test_client(TestClient(app))
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _render(client) -> str:
|
||||
r = client.post("/api/local-workspace/items", json={"name": "TabsProbe"})
|
||||
assert r.status_code == 200, r.text
|
||||
page = client.get(f"/pages/{r.json()['id']}")
|
||||
assert page.status_code == 200
|
||||
return page.text
|
||||
|
||||
|
||||
# ══════════════════ ordre et libellés des onglets ══════════════════
|
||||
|
||||
def test_tab_order_is_home_chat_meeting_inbox(client):
|
||||
tpl = TPL.read_text(encoding="utf-8")
|
||||
row = re.search(r'<div class="sidebar-nav-row".*?</div>', tpl, re.S)
|
||||
assert row, "sidebar-nav-row introuvable"
|
||||
tabs = re.findall(r'data-tab="([a-z]+)"', row.group(0))
|
||||
assert tabs == ["home", "chat", "meetings", "inbox"], tabs
|
||||
|
||||
|
||||
def test_rendered_sidebar_shows_the_four_tabs_in_order(client):
|
||||
html = _render(client)
|
||||
row = re.search(r'<div class="sidebar-nav-row".*?</div>\s*</div>', html, re.S)
|
||||
assert row, "barre d'onglets absente du HTML rendu"
|
||||
tabs = re.findall(r'data-tab="([a-z]+)"', row.group(0))
|
||||
assert tabs == ["home", "chat", "meetings", "inbox"], tabs
|
||||
for label in ("Home", "Chat", "Meeting", "Inbox"):
|
||||
assert f">{label}</span>" in row.group(0), label
|
||||
|
||||
|
||||
def test_tabs_are_accessible_tabs(client):
|
||||
html = _render(client)
|
||||
row = re.search(r'<div class="sidebar-nav-row".*?</div>\s*</div>', html, re.S).group(0)
|
||||
assert row.count('role="tab"') == 4
|
||||
assert ":aria-selected=\"sidebarTab === 'meetings'\"" in row
|
||||
|
||||
|
||||
# ══════════════════ panneaux contextuels ══════════════════
|
||||
|
||||
def test_each_tab_has_its_own_panel(client):
|
||||
tpl = TPL.read_text(encoding="utf-8")
|
||||
for tab in ("home", "chat", "meetings", "inbox"):
|
||||
assert f'x-show="sidebarTab === \'{tab}\'"' in tpl, tab
|
||||
# Le contenu Home historique reste dans le panneau home.
|
||||
assert tpl.index('sidebarTab === \'home\'') < tpl.index('sidebar-section" x-show="isSectionVisible(\'workspace\')')
|
||||
|
||||
|
||||
def test_panels_use_real_data_sources(client):
|
||||
tpl = TPL.read_text(encoding="utf-8")
|
||||
assert "/api/agent/conversations" in tpl
|
||||
assert "/api/v2/meetings/upcoming" in tpl
|
||||
assert "/api/notifications?limit=50" in tpl
|
||||
assert "/api/v2/meetings/transcribe" in tpl
|
||||
|
||||
|
||||
def test_tab_state_is_persisted_and_validated(client):
|
||||
tpl = TPL.read_text(encoding="utf-8")
|
||||
assert "localStorage.setItem('fd_sidebar_tab', tab)" in tpl
|
||||
assert "['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0" in tpl
|
||||
|
||||
|
||||
def test_active_tab_is_a_pill_and_inactive_tabs_hide_their_label():
|
||||
css = CSS.read_text(encoding="utf-8")
|
||||
assert ".nav-tab-btn .nav-tab-label {" in css
|
||||
assert "display: none;" in css.split(".nav-tab-btn .nav-tab-label {")[1].split("}")[0]
|
||||
assert ".nav-tab-btn.active .nav-tab-label { display: inline-block; }" in css
|
||||
assert "border-radius: 15px;" in css
|
||||
|
||||
|
||||
def test_label_visibility_is_driven_by_the_template_not_only_by_css(client):
|
||||
"""Régression : ``?v={{ asset_version }}`` vaut le contenu de ``VERSION``, donc
|
||||
un navigateur garde l'ancien ``app.css`` en cache après une livraison. Si
|
||||
l'affichage des libellés ne dépendait que du CSS, le clic sur Home
|
||||
affichait le texte sur les quatre boutons. La décision est donc prise dans
|
||||
le HTML (style inline Alpine), la CSS n'étant qu'un repli sans JS."""
|
||||
tpl = TPL.read_text(encoding="utf-8")
|
||||
for tab in ("home", "chat", "meetings", "inbox"):
|
||||
needle = (f'<span class="nav-tab-label" :style="sidebarTab === \'{tab}\' '
|
||||
f'? \'display:inline-block\' : \'display:none\'">')
|
||||
assert needle in tpl, tab
|
||||
# Idem pour les badges de compte (masqués sur l'onglet actif).
|
||||
for tab in ("chat", "meetings", "inbox"):
|
||||
assert (f':style="sidebarTab === \'{tab}\' ? \'display:none\' : \'\'"'
|
||||
in tpl), tab
|
||||
|
||||
|
||||
# ══════════════════ endpoint « Meeting » ══════════════════
|
||||
|
||||
def test_upcoming_requires_authentication():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
r = TestClient(app).get("/api/v2/meetings/upcoming")
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
def _seed_calendar_collection(client, days_ahead: int, title: str = "Sync weekly"):
|
||||
"""Calendar link + dated rows (what the Google/CalDAV sync produces)."""
|
||||
from app.db import get_conn
|
||||
|
||||
day = (datetime.now(UTC).date() + timedelta(days=days_ahead)).isoformat()
|
||||
with get_conn() as conn:
|
||||
cid = conn.execute(
|
||||
"INSERT INTO collections (name) VALUES ('Agenda')"
|
||||
).lastrowid
|
||||
prop = conn.execute(
|
||||
"INSERT INTO collection_properties (collection_id, name, prop_type) "
|
||||
"VALUES (?, 'Date', 'date')",
|
||||
(cid,),
|
||||
).lastrowid
|
||||
past = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, external_event_id) "
|
||||
"VALUES (?, 'Last week sync', 'ext-old')",
|
||||
(cid,),
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, external_event_id) "
|
||||
"VALUES (?, ?, 'ext-new')",
|
||||
(cid, title),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE collection_id=?",
|
||||
(json.dumps({str(prop): day}), cid),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps({str(prop): "2000-01-01"}), past),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO calendar_links (user_id, provider, tokens_enc, calendar_id, "
|
||||
"collection_id, date_property) VALUES (1, 'google', '{}', 'primary', ?, ?)",
|
||||
(cid, str(prop)),
|
||||
)
|
||||
conn.commit()
|
||||
return cid
|
||||
|
||||
|
||||
def test_upcoming_lists_only_current_and_future_events(client):
|
||||
_seed_calendar_collection(client, 3, "Roadmap review")
|
||||
r = client.get("/api/v2/meetings/upcoming?days=30")
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
titles = [e["title"] for e in data["events"]]
|
||||
assert titles == ["Roadmap review"], titles
|
||||
ev = data["events"][0]
|
||||
assert ev["provider"] == "google"
|
||||
assert ev["synced"] is True
|
||||
assert ev["today"] is False
|
||||
assert ev["url"] == f"/db/{ev['collection_id']}"
|
||||
assert data["today"] == datetime.now(UTC).date().isoformat()
|
||||
|
||||
|
||||
def test_upcoming_today_filter_flag(client):
|
||||
_seed_calendar_collection(client, 0, "Standup")
|
||||
events = client.get("/api/v2/meetings/upcoming").json()["events"]
|
||||
assert [e["title"] for e in events] == ["Standup"]
|
||||
assert events[0]["today"] is True
|
||||
|
||||
|
||||
def test_upcoming_ignores_events_beyond_the_horizon(client):
|
||||
_seed_calendar_collection(client, 200, "Far future")
|
||||
r = client.get("/api/v2/meetings/upcoming?days=7")
|
||||
assert r.json()["events"] == []
|
||||
assert client.get("/api/v2/meetings/upcoming?days=365").json()["events"]
|
||||
|
||||
|
||||
def test_upcoming_is_empty_without_a_calendar_link(client):
|
||||
r = client.get("/api/v2/meetings/upcoming")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"today": datetime.now(UTC).date().isoformat(),
|
||||
"days": 30, "events": [], "count": 0}
|
||||
|
||||
|
||||
def test_upcoming_validates_and_clamps_the_horizon(client):
|
||||
r = client.get("/api/v2/meetings/upcoming?days=abc")
|
||||
assert r.status_code == 422, r.text # FastAPI refuse un horizon non entier
|
||||
assert client.get("/api/v2/meetings/upcoming?days=0").json()["days"] == 1
|
||||
assert client.get("/api/v2/meetings/upcoming?days=9999").json()["days"] == 365
|
||||
|
||||
|
||||
def test_upcoming_is_scoped_to_the_owner_of_the_link(client):
|
||||
"""Un autre utilisateur ne voit pas les événements d'un lien qui n'est pas
|
||||
le sien (le WHERE user_id= filtre sur calendar_links)."""
|
||||
from app.db import get_conn
|
||||
|
||||
_seed_calendar_collection(client, 1, "Private sync")
|
||||
with get_conn() as conn:
|
||||
other = conn.execute(
|
||||
"INSERT INTO users (login, password_hash) VALUES ('someone-else', 'x')"
|
||||
).lastrowid
|
||||
conn.execute("UPDATE calendar_links SET user_id=?", (other,))
|
||||
conn.commit()
|
||||
assert client.get("/api/v2/meetings/upcoming").json()["events"] == []
|
||||
@@ -1123,11 +1123,15 @@ def test_dashboard_collection_table_data_and_row_create(client):
|
||||
|
||||
|
||||
def test_dashboard_workspace_select_and_breadcrumb(client):
|
||||
# select : cookie positionné + shape
|
||||
r = client.post("/api/workspaces/1/select")
|
||||
# v7.46.0 : le select exige la session ET un workspace existant
|
||||
# (avant : 200 + cookie sur un id inexistant, et 200 en anonyme).
|
||||
ws_id = client.post("/api/workspaces", json={"name": "Smoke WS"}).json()["id"]
|
||||
r = client.post(f"/api/workspaces/{ws_id}/select")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "ok", "workspace_id": 1}
|
||||
assert r.json() == {"status": "ok", "workspace_id": ws_id}
|
||||
assert "flowdeck_workspace" in r.headers.get("set-cookie", "")
|
||||
# un workspace inexistant est refusé (404), pas de cookie fantôme
|
||||
assert client.post("/api/workspaces/999999/select").status_code == 404
|
||||
# breadcrumb d'un dossier inexistant → liste (shape)
|
||||
b = client.get("/api/local-workspace/breadcrumb", params={"folder": 999999})
|
||||
assert b.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user