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:
290
backend/tests/unit/openapi/test_parser.py
Normal file
290
backend/tests/unit/openapi/test_parser.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for OpenAPI endpoint parser module.
|
||||
|
||||
RED phase: written before implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_MINIMAL_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
|
||||
_GET_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Orders API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/orders/{order_id}": {
|
||||
"get": {
|
||||
"operationId": "get_order",
|
||||
"summary": "Get an order",
|
||||
"description": "Retrieves a single order by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "order_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "The order identifier",
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Order found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_POST_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Orders API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/orders": {
|
||||
"post": {
|
||||
"operationId": "create_order",
|
||||
"summary": "Create an order",
|
||||
"description": "Creates a new order",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {"type": "string"},
|
||||
"quantity": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_MULTI_PARAM_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Items API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items/{item_id}": {
|
||||
"get": {
|
||||
"operationId": "get_item",
|
||||
"summary": "Get item",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
},
|
||||
{
|
||||
"name": "include_details",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "boolean"},
|
||||
},
|
||||
],
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_REF_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Ref API", "version": "1.0.0"},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/items": {
|
||||
"get": {
|
||||
"operationId": "list_items",
|
||||
"summary": "List items",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Item"}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_MULTI_ENDPOINT_SPEC = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Multi API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "list_users",
|
||||
"summary": "List users",
|
||||
"description": "",
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create user",
|
||||
"description": "",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
"/users/{id}": {
|
||||
"delete": {
|
||||
"operationId": "delete_user",
|
||||
"summary": "Delete user",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {"204": {"description": "Deleted"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestParseEndpoints:
|
||||
"""Tests for parse_endpoints function."""
|
||||
|
||||
def test_empty_paths_returns_empty_tuple(self) -> None:
|
||||
"""Spec with no paths yields no endpoints."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_MINIMAL_SPEC)
|
||||
assert result == ()
|
||||
|
||||
def test_parse_get_endpoint(self) -> None:
|
||||
"""Parse a GET endpoint with path parameter."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_GET_SPEC)
|
||||
assert len(result) == 1
|
||||
ep = result[0]
|
||||
assert ep.path == "/orders/{order_id}"
|
||||
assert ep.method == "GET"
|
||||
assert ep.operation_id == "get_order"
|
||||
assert ep.summary == "Get an order"
|
||||
|
||||
def test_parse_get_endpoint_parameters(self) -> None:
|
||||
"""Path parameters are extracted correctly."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_GET_SPEC)
|
||||
ep = result[0]
|
||||
assert len(ep.parameters) == 1
|
||||
param = ep.parameters[0]
|
||||
assert param.name == "order_id"
|
||||
assert param.location == "path"
|
||||
assert param.required is True
|
||||
assert param.schema_type == "string"
|
||||
|
||||
def test_parse_post_with_request_body(self) -> None:
|
||||
"""POST endpoint with request body is extracted."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_POST_SPEC)
|
||||
assert len(result) == 1
|
||||
ep = result[0]
|
||||
assert ep.method == "POST"
|
||||
assert ep.request_body_schema is not None
|
||||
assert ep.request_body_schema["type"] == "object"
|
||||
|
||||
def test_parse_path_and_query_params(self) -> None:
|
||||
"""Both path and query parameters are extracted."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_MULTI_PARAM_SPEC)
|
||||
ep = result[0]
|
||||
locations = {p.location for p in ep.parameters}
|
||||
assert "path" in locations
|
||||
assert "query" in locations
|
||||
|
||||
def test_autogenerate_operation_id_when_missing(self) -> None:
|
||||
"""Auto-generate operation_id when not provided in spec."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test", "version": "1.0"},
|
||||
"paths": {
|
||||
"/things/{id}": {
|
||||
"get": {
|
||||
"summary": "Get thing",
|
||||
"description": "",
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result = parse_endpoints(spec)
|
||||
ep = result[0]
|
||||
assert ep.operation_id != ""
|
||||
assert len(ep.operation_id) > 0
|
||||
|
||||
def test_multiple_endpoints_extracted(self) -> None:
|
||||
"""Multiple path+method combinations are all extracted."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_MULTI_ENDPOINT_SPEC)
|
||||
assert len(result) == 3
|
||||
methods = {ep.method for ep in result}
|
||||
assert "GET" in methods
|
||||
assert "POST" in methods
|
||||
assert "DELETE" in methods
|
||||
|
||||
def test_ref_in_response_schema_resolved(self) -> None:
|
||||
"""$ref in response schema is resolved to the target schema."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_REF_SPEC)
|
||||
ep = result[0]
|
||||
assert ep.response_schema is not None
|
||||
# Resolved ref should contain the properties
|
||||
assert "properties" in ep.response_schema or "$ref" not in ep.response_schema
|
||||
|
||||
def test_result_is_tuple(self) -> None:
|
||||
"""parse_endpoints returns a tuple (immutable)."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_GET_SPEC)
|
||||
assert isinstance(result, tuple)
|
||||
|
||||
def test_endpoint_info_is_frozen(self) -> None:
|
||||
"""EndpointInfo instances are frozen/immutable."""
|
||||
from app.openapi.parser import parse_endpoints
|
||||
|
||||
result = parse_endpoints(_GET_SPEC)
|
||||
ep = result[0]
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
ep.method = "POST" # type: ignore[misc]
|
||||
Reference in New Issue
Block a user