137 lines
5.7 KiB
Python
137 lines
5.7 KiB
Python
"""Fusion ACC-2.1 — connecteur MCP Flowdeck → TikTak (première paire).
|
|
|
|
Le preset « tiktak » crée un connecteur MCP préconfiguré (URL du
|
|
endpoint /mcp de TikTak, auth bearer = clé `tck_` chiffrée). Le probe
|
|
parle le dialecte JSON-RPC réel de TikTak (initialize →
|
|
protocolVersion 2025-03-26, tools/list avec ses trois outils en
|
|
lecture, tools/call → content[0].text en JSON) ; les outils sont
|
|
cachés puis exposés à l'agent comme `mcp_tiktak_*`.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.db import get_conn
|
|
from app.services import connectors, mcp_client
|
|
from app.services.importers import url_fetch
|
|
from app.services.tool_registry import ToolRegistry
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _dns_monde_reel(monkeypatch):
|
|
"""L'environnement de test local résout les hôtes publics vers
|
|
198.18.0.0/15 (plage « reserved ») : la garde SSRF les refuse, alors
|
|
qu'en production/CI tiktak.dracodev.net résout vers Cloudflare.
|
|
On neutralise la résolution pour la création — le comportement du
|
|
produit n'est pas modifié, seuls les tests s'affranchissent du DNS
|
|
local (les tests v755/v757 échouent localement pour la même cause).
|
|
"""
|
|
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
|
|
|
|
# Outils tels que TikTak les déclare (app/routers/sync.py, _OUTILS).
|
|
OUTILS_TIKTAK = [
|
|
{"name": "list_today_tasks",
|
|
"description": "Liste les tâches actives de l'utilisateur échéant aujourd'hui.",
|
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
{"name": "search_tasks",
|
|
"description": "Recherche des tâches et des notes par mots.",
|
|
"inputSchema": {"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"]}},
|
|
{"name": "get_task",
|
|
"description": "Lit une tâche par son identifiant.",
|
|
"inputSchema": {"type": "object",
|
|
"properties": {"id": {"type": "integer"}},
|
|
"required": ["id"]}},
|
|
]
|
|
|
|
|
|
def _creer_tiktak(client, **kw):
|
|
body = {"name": "TikTak", "kind": "tiktak",
|
|
"url": "https://tiktak.dracodev.net/mcp",
|
|
"secret": "tck_cle_de_test", "auth": "bearer", **kw}
|
|
r = client.post("/api/agent/connectors", json=body)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _faux_rpc_tiktak(appels, entetes):
|
|
"""Rejoue le serveur MCP de TikTak (POST /mcp, JSON-RPC)."""
|
|
async def fake_rpc(url, payload, headers=None):
|
|
assert url == "https://tiktak.dracodev.net/mcp"
|
|
entetes.append(headers or {})
|
|
appels.append(payload.get("method"))
|
|
method = payload.get("method")
|
|
if method == "initialize":
|
|
return {"result": {"protocolVersion": "2025-03-26",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "tiktak", "version": "0.51.0"}}}
|
|
if method == "tools/list":
|
|
return {"result": {"tools": OUTILS_TIKTAK}}
|
|
if method == "tools/call":
|
|
params = payload.get("params") or {}
|
|
if params.get("name") == "list_today_tasks":
|
|
res = [{"id": 42, "title": "Appeler le dentiste", "list": "Perso"}]
|
|
else:
|
|
res = {"id": 42, "title": "Appeler le dentiste"}
|
|
return {"result": {"content": [{"type": "text",
|
|
"text": json.dumps(res, ensure_ascii=False)}]}}
|
|
return {}
|
|
return fake_rpc
|
|
|
|
|
|
def test_preset_tiktak_et_secret_chiffre(client):
|
|
row = _creer_tiktak(client)
|
|
assert row["kind"] == "tiktak" and row["auth"] == "bearer"
|
|
assert row["has_secret"] is True
|
|
assert "tck_cle_de_test" not in json.dumps(row) # jamais renvoyée
|
|
assert connectors.PRESETS["tiktak"]["url"] == "https://tiktak.dracodev.net/mcp"
|
|
hdrs = connectors._headers(row["id"])
|
|
assert hdrs["Authorization"] == "Bearer tck_cle_de_test" # ce qu'attend TikTak
|
|
|
|
|
|
def test_probe_tiktak_handshake_et_cache(client, monkeypatch):
|
|
row = _creer_tiktak(client)
|
|
appels, entetes = [], []
|
|
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak(appels, entetes))
|
|
|
|
res = asyncio.run(connectors.probe(str(row["id"])))
|
|
assert res["status"] == "ok", res
|
|
assert "3 outil(s)" in res["detail"]
|
|
assert appels == ["initialize", "notifications/initialized", "tools/list"]
|
|
assert entetes[0]["Authorization"] == "Bearer tck_cle_de_test"
|
|
|
|
with get_conn() as conn:
|
|
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
|
|
(row["id"],)).fetchone()["tools_json"]
|
|
for nom in ("mcp_tiktak_list_today_tasks", "mcp_tiktak_search_tasks",
|
|
"mcp_tiktak_get_task"):
|
|
assert nom in tools
|
|
|
|
|
|
def test_outils_tiktak_exposes_a_l_agent(client, monkeypatch):
|
|
row = _creer_tiktak(client)
|
|
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], []))
|
|
asyncio.run(connectors.probe(str(row["id"])))
|
|
|
|
reg = ToolRegistry()
|
|
schema = {t["name"]: t for t in reg.schema()}
|
|
assert "mcp_tiktak_list_today_tasks" in schema
|
|
assert schema["mcp_tiktak_search_tasks"]["parameters"]["required"] == ["query"]
|
|
assert schema["mcp_tiktak_get_task"]["parameters"]["required"] == ["id"]
|
|
|
|
res = asyncio.run(reg.execute("mcp_tiktak_list_today_tasks", {}))
|
|
assert res.status == "success"
|
|
assert "Appeler le dentiste" in res.data["text"]
|
|
|
|
|
|
def test_tiktak_desactive_masque_ses_outils(client, monkeypatch):
|
|
row = _creer_tiktak(client)
|
|
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], []))
|
|
asyncio.run(connectors.probe(str(row["id"])))
|
|
client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False})
|
|
noms = {t["name"] for t in ToolRegistry().schema()}
|
|
assert "mcp_tiktak_list_today_tasks" not in noms
|