fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- 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:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+430
View File
@@ -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&amp;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"
+376
View File
@@ -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
View File
@@ -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 ──
+168
View File
@@ -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
+264
View File
@@ -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"] == []
+7 -3
View File
@@ -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