Files
smart-support/backend/tests/unit/openapi/test_parser.py
Yaojia Wang a54eb224e0 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
2026-03-31 00:10:44 +02:00

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]