"""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", "gateway", 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