- 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)
210 lines
9.2 KiB
Python
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
|