Files
flowdeck/tests/test_v757_mcp_discord.py
T
bruno 16f73fe39e
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s
feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
- app/services/plugins.py + migration 35 : table plugins (slug, name,
  description, enabled) pré-remplie avec 3 modules câblés — web-tools,
  web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
  (aucun router touché) → toutes les routes /workspace/automations* refusées +
  garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
  via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
  « Extensions » / « Automations » en {% if %} (absentes du DOM), sections
  conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
  liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
  (slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
  /api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
  câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
  ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
2026-10-07 10:19:25 -04:00

210 lines
9.2 KiB
Python

"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
def _create(client, **kw):
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Presets Discord / Telegram ──────────────────────────────────────────────
def test_discord_preset_uses_bot_auth(client):
row = _create(client, name="Discord", url="https://discord.com/api/v10",
auth="bot", kind="discord")
assert row["kind"] == "discord" and row["auth"] == "bot"
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
row = _create(client, name="Telegram", kind="telegram", auth="none",
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
with get_conn() as conn:
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["url"]
assert "{secret}" in stored and "123456" not in stored # jamais en clair
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"ok": true}'
monkeypatch.setattr(connectors, "_get", fake_get)
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
assert "Authorization" not in seen[0][1] # auth=none
def test_secret_placeholder_requires_key_and_valid_kinds(client):
no_key = client.post("/api/agent/connectors",
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
bad_kind = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
bad_auth = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
def _mcp_server(client) -> int:
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
def _fake_rpc_factory(calls):
async def fake_rpc(url, payload, headers=None):
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2024-11-05",
"serverInfo": {"name": "demo"}}}
if method == "tools/list":
return {"result": {"tools": [
{"name": "search_docs", "description": "Cherche dans les notes",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
"required": ["q"]}},
{"name": "Echo", "description": "Répète"},
]}}
if method == "tools/call":
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
return {}
return fake_rpc
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
sid = _mcp_server(client)
calls = []
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "ok", res
assert "2 outil(s)" in res["detail"]
assert calls == ["initialize", "notifications/initialized", "tools/list"]
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
rows = client.get("/api/agent/connectors").json()["connectors"]
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
assert me["kind"] == "mcp" and me["tools_count"] == 2
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_demo_server_search_docs" in schema
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
assert "mcp_demo_server_echo" in schema
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
assert res.status == "success"
assert "résultat utile" in res.data["text"]
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
# hors du registre → refus explicite plutôt qu'un appel réseau raté
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
assert res.status == "error" and "inconnu" in res.message.lower()
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
sid = _mcp_server(client)
async def bad_rpc(url, payload, headers=None):
if payload.get("method") == "initialize":
raise ValueError("MCP -32000 : handshake refusé")
return {}
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "error" and "handshake" in res["detail"]
# les outils restent absents : pas de cache partiel
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert tools == "[]"
def test_parse_body_handles_sse_and_rpc_errors():
sse = mcp_client.parse_body(
"text/event-stream; charset=utf-8",
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
assert sse == {"result": {"ok": 1}}
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
with pytest.raises(ValueError, match="illisible"):
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
with pytest.raises(ValueError, match="-32601"):
mcp_client.parse_body("application/json",
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
def test_tool_name_slug():
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
# ── Teams + câblage ─────────────────────────────────────────────────────────
def test_ms365_scopes_include_teams_messages():
from app.services.oauth_connectors import PROVIDERS
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
def test_connectors_menu_wired_for_presets(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
"auth: f.auth || 'bearer'",
"https://discord.com/api/v10",
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
assert token in src, token
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-cn-kind" in html
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 0
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
def test_mcp_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors").status_code == 401
assert client.post("/api/agent/connectors",
json={"name": "x", "url": "https://example.com/x",
"kind": "mcp"}).status_code == 401