Titre de la page
Premier paragraphe avec du gras.
print("hello")
- un
- deux
"""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 = """
| Résultat A | Extrait du résultat A |
| Résultat B | Extrait B |
Premier paragraphe avec du gras.
print("hello")
" + ("a" * 50000) + "
" 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"