feat: complete phase 3 -- OpenAPI auto-discovery, SSRF protection, tool generation
- SSRF protection: private IP blocking, DNS rebinding defense, redirect validation - OpenAPI fetcher with SSRF guard, JSON/YAML auto-detection, 10MB limit - Structural spec validator (3.0.x/3.1.x) - Endpoint parser with $ref resolution, auto-generated operation IDs - Heuristic + LLM endpoint classifier with Protocol interface - Review API at /api/openapi (import, job status, classification CRUD, approve) - @tool code generator + Agent YAML generator - Import orchestrator (fetch -> validate -> parse -> classify pipeline) - 125 new tests, 322 total passing, 93.23% coverage
This commit is contained in:
258
backend/tests/unit/openapi/test_generator.py
Normal file
258
backend/tests/unit/openapi/test_generator.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Tests for OpenAPI tool generator module.
|
||||
|
||||
RED phase: written before implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.openapi.models import ClassificationResult, EndpointInfo, ParameterInfo
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_BASE_URL = "https://api.example.com"
|
||||
|
||||
|
||||
def _make_endpoint(
|
||||
path: str = "/items",
|
||||
method: str = "GET",
|
||||
operation_id: str = "list_items",
|
||||
summary: str = "List items",
|
||||
description: str = "Returns all items",
|
||||
parameters: tuple[ParameterInfo, ...] = (),
|
||||
request_body_schema: dict | None = None,
|
||||
) -> EndpointInfo:
|
||||
return EndpointInfo(
|
||||
path=path,
|
||||
method=method,
|
||||
operation_id=operation_id,
|
||||
summary=summary,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
request_body_schema=request_body_schema,
|
||||
)
|
||||
|
||||
|
||||
def _make_classification(
|
||||
endpoint: EndpointInfo,
|
||||
access_type: str = "read",
|
||||
needs_interrupt: bool = False,
|
||||
agent_group: str = "read_agent",
|
||||
) -> ClassificationResult:
|
||||
return ClassificationResult(
|
||||
endpoint=endpoint,
|
||||
access_type=access_type,
|
||||
customer_params=(),
|
||||
agent_group=agent_group,
|
||||
confidence=0.9,
|
||||
needs_interrupt=needs_interrupt,
|
||||
)
|
||||
|
||||
|
||||
_PATH_PARAM = ParameterInfo(
|
||||
name="item_id", location="path", required=True, schema_type="string"
|
||||
)
|
||||
_QUERY_PARAM = ParameterInfo(
|
||||
name="filter", location="query", required=False, schema_type="string"
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateToolCode:
|
||||
"""Tests for generate_tool_code function."""
|
||||
|
||||
def test_generate_tool_for_get_endpoint(self) -> None:
|
||||
"""Generated tool for GET endpoint is a GeneratedTool with non-empty code."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(method="GET")
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert tool.function_name == "list_items"
|
||||
assert tool.code != ""
|
||||
assert "@tool" in tool.code
|
||||
|
||||
def test_generate_tool_contains_function_name(self) -> None:
|
||||
"""Generated code contains the function name."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(operation_id="get_order", method="GET")
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert "get_order" in tool.code
|
||||
|
||||
def test_generate_tool_contains_base_url(self) -> None:
|
||||
"""Generated code contains the base URL."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert _BASE_URL in tool.code
|
||||
|
||||
def test_generate_tool_contains_http_method(self) -> None:
|
||||
"""Generated code uses the correct HTTP method."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(method="POST")
|
||||
clf = _make_classification(ep, access_type="write")
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert "post" in tool.code.lower()
|
||||
|
||||
def test_generate_tool_for_post_with_body(self) -> None:
|
||||
"""Generated tool for POST includes body parameter."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(
|
||||
method="POST",
|
||||
request_body_schema={"type": "object", "properties": {"name": {"type": "string"}}},
|
||||
)
|
||||
clf = _make_classification(ep, access_type="write")
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert tool.code != ""
|
||||
assert "POST" in tool.code or "post" in tool.code
|
||||
|
||||
def test_generate_tool_with_path_params(self) -> None:
|
||||
"""Generated tool includes path parameter in function signature."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(
|
||||
path="/items/{item_id}",
|
||||
operation_id="get_item",
|
||||
parameters=(_PATH_PARAM,),
|
||||
)
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert "item_id" in tool.code
|
||||
|
||||
def test_write_tool_includes_interrupt_marker(self) -> None:
|
||||
"""Write tools that need interrupt include a marker comment."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(method="DELETE", operation_id="delete_item")
|
||||
clf = _make_classification(ep, access_type="write", needs_interrupt=True)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert "interrupt" in tool.code.lower() or "approval" in tool.code.lower()
|
||||
|
||||
def test_generated_code_is_executable(self) -> None:
|
||||
"""Generated code can be exec'd without syntax errors."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint(
|
||||
path="/items/{item_id}",
|
||||
operation_id="fetch_item",
|
||||
parameters=(_PATH_PARAM,),
|
||||
)
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
# Must be valid Python syntax
|
||||
compile(tool.code, "<generated>", "exec")
|
||||
|
||||
def test_generated_tool_code_exec_imports(self) -> None:
|
||||
"""Generated code exec'd with required imports does not raise."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
namespace: dict = {}
|
||||
try:
|
||||
import httpx
|
||||
from langchain_core.tools import tool as lc_tool
|
||||
|
||||
namespace = {"httpx": httpx, "tool": lc_tool}
|
||||
exec(tool.code, namespace) # noqa: S102
|
||||
except ImportError:
|
||||
pytest.skip("langchain_core not available for exec test")
|
||||
|
||||
def test_returns_generated_tool_instance(self) -> None:
|
||||
"""generate_tool_code returns a GeneratedTool instance."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
from app.openapi.models import GeneratedTool
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
assert isinstance(tool, GeneratedTool)
|
||||
|
||||
def test_generated_tool_is_frozen(self) -> None:
|
||||
"""GeneratedTool instance is immutable."""
|
||||
from app.openapi.generator import generate_tool_code
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
tool = generate_tool_code(clf, _BASE_URL)
|
||||
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
tool.code = "new code" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestGenerateAgentYaml:
|
||||
"""Tests for generate_agent_yaml function."""
|
||||
|
||||
def test_generate_yaml_is_valid_string(self) -> None:
|
||||
"""generate_agent_yaml returns a non-empty string."""
|
||||
from app.openapi.generator import generate_agent_yaml
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
result = generate_agent_yaml((clf,), _BASE_URL)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_generated_yaml_is_parseable(self) -> None:
|
||||
"""Output can be parsed as YAML."""
|
||||
import yaml
|
||||
|
||||
from app.openapi.generator import generate_agent_yaml
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
result = generate_agent_yaml((clf,), _BASE_URL)
|
||||
|
||||
parsed = yaml.safe_load(result)
|
||||
assert isinstance(parsed, dict)
|
||||
|
||||
def test_generated_yaml_contains_agents_key(self) -> None:
|
||||
"""Generated YAML has an 'agents' key matching AgentConfig format."""
|
||||
import yaml
|
||||
|
||||
from app.openapi.generator import generate_agent_yaml
|
||||
|
||||
ep = _make_endpoint()
|
||||
clf = _make_classification(ep)
|
||||
result = generate_agent_yaml((clf,), _BASE_URL)
|
||||
|
||||
parsed = yaml.safe_load(result)
|
||||
assert "agents" in parsed
|
||||
|
||||
def test_generated_yaml_contains_tool_name(self) -> None:
|
||||
"""Generated YAML references the tool function name."""
|
||||
from app.openapi.generator import generate_agent_yaml
|
||||
|
||||
ep = _make_endpoint(operation_id="list_orders")
|
||||
clf = _make_classification(ep)
|
||||
result = generate_agent_yaml((clf,), _BASE_URL)
|
||||
|
||||
assert "list_orders" in result
|
||||
|
||||
def test_empty_classifications_returns_empty_agents(self) -> None:
|
||||
"""No classifications yields YAML with empty agents list."""
|
||||
import yaml
|
||||
|
||||
from app.openapi.generator import generate_agent_yaml
|
||||
|
||||
result = generate_agent_yaml((), _BASE_URL)
|
||||
parsed = yaml.safe_load(result)
|
||||
assert parsed.get("agents") == [] or parsed.get("agents") is None
|
||||
Reference in New Issue
Block a user