- 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
291 lines
9.2 KiB
Python
291 lines
9.2 KiB
Python
"""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]
|