fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
"""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 = """
|
||||
<table><tr>
|
||||
<td><a class="result-link" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=1">Résultat A</a></td>
|
||||
<td class="result-snippet">Extrait du <b>résultat</b> A</td>
|
||||
</tr><tr>
|
||||
<td><a class="result-link" href="https://example.org/b">Résultat B</a></td>
|
||||
<td class="result-snippet">Extrait B</td>
|
||||
</tr></table>
|
||||
"""
|
||||
|
||||
|
||||
def test_web_search_exa(monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "test-key")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["x-api-key"] == "test-key"
|
||||
return httpx.Response(200, json=EXA_BODY)
|
||||
|
||||
with mock_http(handler):
|
||||
results, provider = run(ws.search_web("fastapi", transport=httpx.MockTransport(handler)))
|
||||
|
||||
assert provider == "exa"
|
||||
assert [r["url"] for r in results] == ["https://fastapi.tiangolo.com/"]
|
||||
assert results[0]["source"] == "exa"
|
||||
assert results[0]["title"] == "FastAPI docs"
|
||||
assert results[0]["published"] == "2026-01-02"
|
||||
|
||||
|
||||
def test_web_search_filters_search_engine_urls(monkeypatch):
|
||||
"""Une URL du moteur lui-même ne doit jamais être renvoyée à l'IA."""
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731
|
||||
with mock_http(handler):
|
||||
results, _ = run(ws.search_web("x", transport=httpx.MockTransport(handler)))
|
||||
assert all("duckduckgo.com" not in r["url"] for r in results)
|
||||
|
||||
|
||||
def test_web_search_falls_back_to_duckduckgo(monkeypatch):
|
||||
"""Sans clé Exa, le tool bascule sur le repli sans compte."""
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "duckduckgo" in str(request.url)
|
||||
return httpx.Response(200, text=DDG_HTML,
|
||||
headers={"content-type": "text/html"})
|
||||
|
||||
with mock_http(handler):
|
||||
results, provider = run(ws.search_web("test", transport=httpx.MockTransport(handler)))
|
||||
|
||||
assert provider == "duckduckgo"
|
||||
assert [r["url"] for r in results] == ["https://example.com/a", "https://example.org/b"]
|
||||
assert results[0]["title"] == "Résultat A"
|
||||
assert results[0]["snippet"] == "Extrait du résultat A"
|
||||
|
||||
|
||||
def test_web_search_empty_query_returns_nothing(monkeypatch):
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
results, provider = run(ws.search_web(" "))
|
||||
assert results == []
|
||||
assert provider == ""
|
||||
|
||||
|
||||
def test_available_providers_reflects_config(monkeypatch):
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
assert ws.available_providers()["exa"] is False
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
assert ws.available_providers()["exa"] is True
|
||||
|
||||
|
||||
def test_web_search_tool_returns_actionable_error(registry, monkeypatch):
|
||||
"""Aucun provider ne répond → le tool dit comment corriger."""
|
||||
monkeypatch.setattr(settings, "exa_api_key", "")
|
||||
handler = lambda r: httpx.Response(503, text="") # noqa: E731
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("web_search", {"query": "test"}))
|
||||
assert res.status == "error"
|
||||
assert "EXA_API_KEY" in res.message
|
||||
|
||||
|
||||
def test_web_search_tool_success(registry, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_search_provider", "exa")
|
||||
monkeypatch.setattr(settings, "exa_api_key", "k")
|
||||
handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("web_search", {"query": "fastapi", "num_results": 3}))
|
||||
assert res.status == "success"
|
||||
assert res.data["count"] == 1
|
||||
assert res.data["provider"] == "exa"
|
||||
|
||||
|
||||
# ── fetch_url ──────────────────────────────────────────────────────────────
|
||||
|
||||
HTML_PAGE = """
|
||||
<html><head><title>Titre de la page</title>
|
||||
<meta property="og:description" content="Description courte"></head>
|
||||
<body><article>
|
||||
<h1>Titre de la page</h1>
|
||||
<p>Premier paragraphe avec du <b>gras</b>.</p>
|
||||
<pre><code>print("hello")</code></pre>
|
||||
<ul><li>un</li><li>deux</li></ul>
|
||||
</article></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_fetch_url_returns_markdown(registry):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://example.com/page"
|
||||
return httpx.Response(200, text=HTML_PAGE,
|
||||
headers={"content-type": "text/html; charset=utf-8"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/page"}))
|
||||
|
||||
assert res.status == "success", res.message
|
||||
assert "Premier paragraphe" in res.data["markdown"]
|
||||
assert "print" in res.data["markdown"]
|
||||
assert res.data["title"] == "Titre de la page"
|
||||
assert res.data["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://localhost:8080/api/health",
|
||||
"http://127.0.0.1/admin",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]/",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/x",
|
||||
])
|
||||
def test_fetch_url_blocks_ssrf_targets(registry, url):
|
||||
res = run(registry.execute("fetch_url", {"url": url}))
|
||||
assert res.status == "error", f"{url} aurait dû être refusé"
|
||||
|
||||
|
||||
def test_fetch_url_revalidates_redirect_target(registry):
|
||||
"""Une URL publique redirigeant vers le metadata cloud doit être refusée."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/"}))
|
||||
|
||||
assert res.status == "error"
|
||||
assert "non autorisé" in res.message
|
||||
|
||||
|
||||
def test_fetch_url_follows_public_redirect(registry):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/old":
|
||||
return httpx.Response(301, headers={"location": "https://example.com/new"})
|
||||
return httpx.Response(200, text=HTML_PAGE,
|
||||
headers={"content-type": "text/html"})
|
||||
|
||||
with mock_http(handler):
|
||||
res = run(registry.execute("fetch_url", {"url": "https://example.com/old"}))
|
||||
|
||||
assert res.status == "success", res.message
|
||||
assert res.data["url"] == "https://example.com/new"
|
||||
|
||||
|
||||
def test_fetch_url_truncates(registry):
|
||||
long_html = "<html><body><p>" + ("a" * 50000) + "</p></body></html>"
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user