nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""OpenAPI 3.x / Swagger 2.x spec parser.
|
|
7
|
+
|
|
8
|
+
Reads a spec file (JSON or YAML) and auto-generates :class:`~mannf.core.testing.models.TestCase`
|
|
9
|
+
objects covering both positive and negative test scenarios. Discovered
|
|
10
|
+
endpoints are also registered into a provided :class:`~mannf.core.distributed.endpoint.EndpointRegistry`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
25
|
+
from mannf.core.testing.models import TestCase
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from mannf.product.llm.base import LLMProvider
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_SCALAR_DEFAULTS: Dict[str, Any] = {
|
|
37
|
+
"string": "example",
|
|
38
|
+
"integer": 1,
|
|
39
|
+
"number": 1.0,
|
|
40
|
+
"boolean": True,
|
|
41
|
+
"array": [],
|
|
42
|
+
"object": {},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_WRONG_TYPE_VALUES: Dict[str, Any] = {
|
|
46
|
+
"string": 42,
|
|
47
|
+
"integer": "not-a-number",
|
|
48
|
+
"number": "not-a-number",
|
|
49
|
+
"boolean": "not-a-boolean",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_spec(path: str) -> Dict[str, Any]:
|
|
54
|
+
"""Load a JSON or YAML spec file from *path*."""
|
|
55
|
+
p = Path(path)
|
|
56
|
+
text = p.read_text(encoding="utf-8")
|
|
57
|
+
if p.suffix.lower() in {".yaml", ".yml"}:
|
|
58
|
+
return yaml.safe_load(text)
|
|
59
|
+
return json.loads(text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_ref(spec: Dict[str, Any], ref: str) -> Dict[str, Any]:
|
|
63
|
+
"""Resolve a ``$ref`` pointer within *spec* (internal refs only)."""
|
|
64
|
+
if not ref.startswith("#/"):
|
|
65
|
+
return {}
|
|
66
|
+
parts = ref.lstrip("#/").split("/")
|
|
67
|
+
node: Any = spec
|
|
68
|
+
for part in parts:
|
|
69
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
70
|
+
if not isinstance(node, dict) or part not in node:
|
|
71
|
+
return {}
|
|
72
|
+
node = node[part]
|
|
73
|
+
return node if isinstance(node, dict) else {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _schema_example(spec: Dict[str, Any], schema: Dict[str, Any], depth: int = 0) -> Any:
|
|
77
|
+
"""Generate a minimal valid example value for *schema*."""
|
|
78
|
+
if depth > 5:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
if "$ref" in schema:
|
|
82
|
+
schema = _resolve_ref(spec, schema["$ref"])
|
|
83
|
+
|
|
84
|
+
schema_type = schema.get("type", "object")
|
|
85
|
+
|
|
86
|
+
if "example" in schema:
|
|
87
|
+
return schema["example"]
|
|
88
|
+
if "default" in schema:
|
|
89
|
+
return schema["default"]
|
|
90
|
+
if "enum" in schema and schema["enum"]:
|
|
91
|
+
return schema["enum"][0]
|
|
92
|
+
|
|
93
|
+
if schema_type == "object" or "properties" in schema:
|
|
94
|
+
props = schema.get("properties", {})
|
|
95
|
+
return {k: _schema_example(spec, v, depth + 1) for k, v in props.items()}
|
|
96
|
+
|
|
97
|
+
if schema_type == "array":
|
|
98
|
+
items = schema.get("items", {})
|
|
99
|
+
return [_schema_example(spec, items, depth + 1)]
|
|
100
|
+
|
|
101
|
+
return _SCALAR_DEFAULTS.get(schema_type, None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _detect_version(spec: Dict[str, Any]) -> str:
|
|
105
|
+
"""Return ``"openapi3"`` or ``"swagger2"``."""
|
|
106
|
+
if "openapi" in spec:
|
|
107
|
+
return "openapi3"
|
|
108
|
+
if "swagger" in spec:
|
|
109
|
+
return "swagger2"
|
|
110
|
+
return "openapi3"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _base_url_from_spec(spec: Dict[str, Any]) -> Optional[str]:
|
|
114
|
+
"""Extract base URL from the spec if present."""
|
|
115
|
+
version = _detect_version(spec)
|
|
116
|
+
if version == "openapi3":
|
|
117
|
+
servers = spec.get("servers", [])
|
|
118
|
+
if servers:
|
|
119
|
+
return servers[0].get("url", "").rstrip("/")
|
|
120
|
+
else: # swagger2
|
|
121
|
+
host = spec.get("host", "")
|
|
122
|
+
base_path = spec.get("basePath", "/").rstrip("/")
|
|
123
|
+
scheme = (spec.get("schemes") or ["http"])[0]
|
|
124
|
+
if host:
|
|
125
|
+
return f"{scheme}://{host}{base_path}"
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _path_to_endpoint_name(path: str) -> str:
|
|
130
|
+
"""Turn ``/users/{id}/orders`` into ``users-id-orders``."""
|
|
131
|
+
name = re.sub(r"[{}]", "", path)
|
|
132
|
+
name = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-")
|
|
133
|
+
return name or "root"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Public API
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
class OpenApiParser:
|
|
141
|
+
"""Parse an OpenAPI / Swagger spec and generate :class:`TestCase` objects.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
spec_path:
|
|
146
|
+
Path to the spec file (JSON or YAML).
|
|
147
|
+
base_url:
|
|
148
|
+
Override the base URL extracted from the spec. When ``None`` the
|
|
149
|
+
parser tries to read ``servers[0].url`` (OpenAPI 3) or
|
|
150
|
+
``host`` + ``basePath`` (Swagger 2).
|
|
151
|
+
registry:
|
|
152
|
+
:class:`EndpointRegistry` to populate with discovered endpoints.
|
|
153
|
+
A fresh registry is created when ``None`` is passed.
|
|
154
|
+
max_tests:
|
|
155
|
+
Maximum number of :class:`TestCase` objects to generate. ``None``
|
|
156
|
+
means no limit.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
spec_path: str,
|
|
162
|
+
base_url: Optional[str] = None,
|
|
163
|
+
registry: Optional[EndpointRegistry] = None,
|
|
164
|
+
max_tests: Optional[int] = None,
|
|
165
|
+
llm_provider: Optional["LLMProvider"] = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
self.spec_path = spec_path
|
|
168
|
+
self.spec = _load_spec(spec_path)
|
|
169
|
+
self.base_url = (base_url or _base_url_from_spec(self.spec) or "").rstrip("/")
|
|
170
|
+
self.registry = registry if registry is not None else EndpointRegistry()
|
|
171
|
+
self.max_tests = max_tests
|
|
172
|
+
self._version = _detect_version(self.spec)
|
|
173
|
+
self.llm_provider = llm_provider
|
|
174
|
+
|
|
175
|
+
def parse(self) -> List[TestCase]:
|
|
176
|
+
"""Parse the spec and return all generated :class:`TestCase` objects."""
|
|
177
|
+
test_cases: List[TestCase] = []
|
|
178
|
+
paths = self.spec.get("paths", {})
|
|
179
|
+
|
|
180
|
+
for path, path_item in paths.items():
|
|
181
|
+
if not isinstance(path_item, dict):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Register endpoint
|
|
185
|
+
ep_name = _path_to_endpoint_name(path)
|
|
186
|
+
parsed_url = urlparse(self.base_url) if self.base_url else None
|
|
187
|
+
host = parsed_url.hostname or "localhost" if parsed_url else "localhost"
|
|
188
|
+
port = (
|
|
189
|
+
parsed_url.port or (443 if (parsed_url and parsed_url.scheme == "https") else 80)
|
|
190
|
+
if parsed_url else 80
|
|
191
|
+
)
|
|
192
|
+
protocol = (parsed_url.scheme or "http") if parsed_url else "http"
|
|
193
|
+
|
|
194
|
+
endpoint = Endpoint(
|
|
195
|
+
name=ep_name,
|
|
196
|
+
host=host,
|
|
197
|
+
port=port,
|
|
198
|
+
protocol=protocol,
|
|
199
|
+
tags={"path": path},
|
|
200
|
+
)
|
|
201
|
+
self.registry.register(endpoint)
|
|
202
|
+
|
|
203
|
+
for method in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
204
|
+
operation = path_item.get(method)
|
|
205
|
+
if not isinstance(operation, dict):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
cases = self._generate_cases_for_operation(path, method.upper(), operation)
|
|
209
|
+
test_cases.extend(cases)
|
|
210
|
+
|
|
211
|
+
if self.max_tests is not None and len(test_cases) >= self.max_tests:
|
|
212
|
+
return test_cases[: self.max_tests]
|
|
213
|
+
|
|
214
|
+
return test_cases
|
|
215
|
+
|
|
216
|
+
async def parse_with_llm(self) -> List[TestCase]:
|
|
217
|
+
"""Parse the spec and augment with LLM-generated test cases when available.
|
|
218
|
+
|
|
219
|
+
Behaves identically to :meth:`parse` when no LLM provider is configured
|
|
220
|
+
or when the provider is not available (fail-safe).
|
|
221
|
+
"""
|
|
222
|
+
test_cases: List[TestCase] = []
|
|
223
|
+
paths = self.spec.get("paths", {})
|
|
224
|
+
|
|
225
|
+
for path, path_item in paths.items():
|
|
226
|
+
if not isinstance(path_item, dict):
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Register endpoint
|
|
230
|
+
ep_name = _path_to_endpoint_name(path)
|
|
231
|
+
parsed_url = urlparse(self.base_url) if self.base_url else None
|
|
232
|
+
host = parsed_url.hostname or "localhost" if parsed_url else "localhost"
|
|
233
|
+
port = (
|
|
234
|
+
parsed_url.port or (443 if (parsed_url and parsed_url.scheme == "https") else 80)
|
|
235
|
+
if parsed_url else 80
|
|
236
|
+
)
|
|
237
|
+
protocol = (parsed_url.scheme or "http") if parsed_url else "http"
|
|
238
|
+
|
|
239
|
+
endpoint = Endpoint(
|
|
240
|
+
name=ep_name,
|
|
241
|
+
host=host,
|
|
242
|
+
port=port,
|
|
243
|
+
protocol=protocol,
|
|
244
|
+
tags={"path": path},
|
|
245
|
+
)
|
|
246
|
+
self.registry.register(endpoint)
|
|
247
|
+
|
|
248
|
+
for method in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
249
|
+
operation = path_item.get(method)
|
|
250
|
+
if not isinstance(operation, dict):
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
cases = self._generate_cases_for_operation(path, method.upper(), operation)
|
|
254
|
+
|
|
255
|
+
# LLM augmentation
|
|
256
|
+
if self.llm_provider is not None and self.llm_provider.is_available():
|
|
257
|
+
body_schema, _ = self._extract_body_schema(operation)
|
|
258
|
+
parameters = operation.get("parameters", [])
|
|
259
|
+
endpoint_info = {
|
|
260
|
+
"method": method.upper(),
|
|
261
|
+
"path": path,
|
|
262
|
+
"description": (
|
|
263
|
+
operation.get("summary")
|
|
264
|
+
or operation.get("description")
|
|
265
|
+
or ""
|
|
266
|
+
),
|
|
267
|
+
"parameters": parameters,
|
|
268
|
+
"schema": body_schema or {},
|
|
269
|
+
}
|
|
270
|
+
spec_context = {
|
|
271
|
+
"info": self.spec.get("info", {}),
|
|
272
|
+
"servers": self.spec.get("servers", []),
|
|
273
|
+
}
|
|
274
|
+
llm_cases = await self._generate_llm_cases(
|
|
275
|
+
path, method.upper(), operation, endpoint_info, spec_context
|
|
276
|
+
)
|
|
277
|
+
cases.extend(llm_cases)
|
|
278
|
+
|
|
279
|
+
test_cases.extend(cases)
|
|
280
|
+
|
|
281
|
+
if self.max_tests is not None and len(test_cases) >= self.max_tests:
|
|
282
|
+
return test_cases[: self.max_tests]
|
|
283
|
+
|
|
284
|
+
return test_cases
|
|
285
|
+
|
|
286
|
+
async def _generate_llm_cases(
|
|
287
|
+
self,
|
|
288
|
+
path: str,
|
|
289
|
+
method: str,
|
|
290
|
+
operation: Dict[str, Any],
|
|
291
|
+
endpoint_info: Dict[str, Any],
|
|
292
|
+
spec_context: Dict[str, Any],
|
|
293
|
+
) -> List[TestCase]:
|
|
294
|
+
"""Call the LLM provider and convert results to :class:`TestCase` objects."""
|
|
295
|
+
assert self.llm_provider is not None
|
|
296
|
+
try:
|
|
297
|
+
raw_cases = await self.llm_provider.generate_test_cases(
|
|
298
|
+
endpoint_info, spec_context, n=5
|
|
299
|
+
)
|
|
300
|
+
except Exception as exc: # noqa: BLE001
|
|
301
|
+
logger.warning("OpenApiParser: LLM generation failed for %s %s: %s", method, path, exc)
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
cases: List[TestCase] = []
|
|
305
|
+
summary = operation.get("summary") or operation.get("operationId") or f"{method} {path}"
|
|
306
|
+
for raw in raw_cases:
|
|
307
|
+
if not isinstance(raw, dict):
|
|
308
|
+
continue
|
|
309
|
+
inputs = raw.get("inputs", {})
|
|
310
|
+
if not isinstance(inputs, dict):
|
|
311
|
+
inputs = {}
|
|
312
|
+
inputs.setdefault("method", method)
|
|
313
|
+
cases.append(
|
|
314
|
+
TestCase(
|
|
315
|
+
target=path,
|
|
316
|
+
inputs=inputs,
|
|
317
|
+
expected_behavior=raw.get("expected_behavior", f"[llm] {summary}"),
|
|
318
|
+
priority=0.7,
|
|
319
|
+
metadata={
|
|
320
|
+
"spec_path": path,
|
|
321
|
+
"method": method,
|
|
322
|
+
"test_type": raw.get("test_type", "llm"),
|
|
323
|
+
"generator": "llm",
|
|
324
|
+
"generation_strategy": "llm_edge_case",
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return cases
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Internal helpers
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def _generate_cases_for_operation(
|
|
335
|
+
self,
|
|
336
|
+
path: str,
|
|
337
|
+
method: str,
|
|
338
|
+
operation: Dict[str, Any],
|
|
339
|
+
) -> List[TestCase]:
|
|
340
|
+
"""Return positive + negative test cases for one operation."""
|
|
341
|
+
cases: List[TestCase] = []
|
|
342
|
+
summary = operation.get("summary") or operation.get("operationId") or f"{method} {path}"
|
|
343
|
+
|
|
344
|
+
# Collect parameter info
|
|
345
|
+
parameters: List[Dict[str, Any]] = operation.get("parameters", [])
|
|
346
|
+
# Inherit path-level parameters if any
|
|
347
|
+
path_params, query_params, header_params = self._parse_parameters(parameters)
|
|
348
|
+
|
|
349
|
+
# Request body (OpenAPI 3 / Swagger 2)
|
|
350
|
+
body_schema, required_body_fields = self._extract_body_schema(operation)
|
|
351
|
+
|
|
352
|
+
# ---- Positive test case ----------------------------------------
|
|
353
|
+
pos_inputs: Dict[str, Any] = {"method": method}
|
|
354
|
+
if query_params:
|
|
355
|
+
pos_inputs["query_params"] = query_params
|
|
356
|
+
if header_params:
|
|
357
|
+
pos_inputs["headers"] = header_params
|
|
358
|
+
|
|
359
|
+
if body_schema and method in {"POST", "PUT", "PATCH"}:
|
|
360
|
+
pos_inputs["json"] = _schema_example(self.spec, body_schema)
|
|
361
|
+
|
|
362
|
+
# Replace path parameters in the target path
|
|
363
|
+
resolved_path = path
|
|
364
|
+
for param_name, param_value in path_params.items():
|
|
365
|
+
resolved_path = resolved_path.replace(f"{{{param_name}}}", str(param_value))
|
|
366
|
+
|
|
367
|
+
cases.append(
|
|
368
|
+
TestCase(
|
|
369
|
+
target=resolved_path,
|
|
370
|
+
inputs=pos_inputs,
|
|
371
|
+
expected_behavior=f"[positive] {summary}",
|
|
372
|
+
priority=0.5,
|
|
373
|
+
metadata={"spec_path": path, "method": method, "test_type": "positive"},
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# ---- Negative test cases ----------------------------------------
|
|
378
|
+
# 1. Missing required body fields
|
|
379
|
+
if body_schema and required_body_fields and method in {"POST", "PUT", "PATCH"}:
|
|
380
|
+
full_body = _schema_example(self.spec, body_schema) or {}
|
|
381
|
+
if isinstance(full_body, dict) and required_body_fields:
|
|
382
|
+
# Drop the first required field
|
|
383
|
+
field_to_drop = next(iter(required_body_fields))
|
|
384
|
+
missing_body = {k: v for k, v in full_body.items() if k != field_to_drop}
|
|
385
|
+
neg_inputs = {**pos_inputs, "json": missing_body, "expected_status_codes": [400, 422]}
|
|
386
|
+
cases.append(
|
|
387
|
+
TestCase(
|
|
388
|
+
target=resolved_path,
|
|
389
|
+
inputs=neg_inputs,
|
|
390
|
+
expected_behavior=f"[negative] {summary}: missing required field '{field_to_drop}'",
|
|
391
|
+
priority=0.6,
|
|
392
|
+
metadata={
|
|
393
|
+
"spec_path": path,
|
|
394
|
+
"method": method,
|
|
395
|
+
"test_type": "negative",
|
|
396
|
+
"neg_type": "missing_required_field",
|
|
397
|
+
"missing_field": field_to_drop,
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# 2. Wrong type for first required body field
|
|
403
|
+
if body_schema and required_body_fields and method in {"POST", "PUT", "PATCH"}:
|
|
404
|
+
full_body = _schema_example(self.spec, body_schema) or {}
|
|
405
|
+
if isinstance(full_body, dict):
|
|
406
|
+
field_name = next(iter(required_body_fields))
|
|
407
|
+
props = body_schema.get("properties", {})
|
|
408
|
+
field_schema = props.get(field_name, {})
|
|
409
|
+
if "$ref" in field_schema:
|
|
410
|
+
field_schema = _resolve_ref(self.spec, field_schema["$ref"])
|
|
411
|
+
field_type = field_schema.get("type", "string")
|
|
412
|
+
wrong_val = _WRONG_TYPE_VALUES.get(field_type)
|
|
413
|
+
if wrong_val is not None:
|
|
414
|
+
wrong_body = {**full_body, field_name: wrong_val}
|
|
415
|
+
neg_inputs2 = {
|
|
416
|
+
**pos_inputs,
|
|
417
|
+
"json": wrong_body,
|
|
418
|
+
"expected_status_codes": [400, 422],
|
|
419
|
+
}
|
|
420
|
+
cases.append(
|
|
421
|
+
TestCase(
|
|
422
|
+
target=resolved_path,
|
|
423
|
+
inputs=neg_inputs2,
|
|
424
|
+
expected_behavior=(
|
|
425
|
+
f"[negative] {summary}: wrong type for '{field_name}'"
|
|
426
|
+
),
|
|
427
|
+
priority=0.6,
|
|
428
|
+
metadata={
|
|
429
|
+
"spec_path": path,
|
|
430
|
+
"method": method,
|
|
431
|
+
"test_type": "negative",
|
|
432
|
+
"neg_type": "wrong_type",
|
|
433
|
+
"field": field_name,
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# 3. Non-existent resource (append bogus ID for paths ending with a param)
|
|
439
|
+
if "{" not in path and method == "GET":
|
|
440
|
+
neg_path = resolved_path.rstrip("/") + "/99999999"
|
|
441
|
+
neg_inputs3 = {**pos_inputs, "expected_status_codes": [404]}
|
|
442
|
+
cases.append(
|
|
443
|
+
TestCase(
|
|
444
|
+
target=neg_path,
|
|
445
|
+
inputs=neg_inputs3,
|
|
446
|
+
expected_behavior=f"[negative] {summary}: non-existent resource",
|
|
447
|
+
priority=0.4,
|
|
448
|
+
metadata={
|
|
449
|
+
"spec_path": path,
|
|
450
|
+
"method": method,
|
|
451
|
+
"test_type": "negative",
|
|
452
|
+
"neg_type": "not_found",
|
|
453
|
+
},
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return cases
|
|
458
|
+
|
|
459
|
+
def _parse_parameters(
|
|
460
|
+
self,
|
|
461
|
+
parameters: List[Dict[str, Any]],
|
|
462
|
+
) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
|
|
463
|
+
"""Return (path_params, query_params, header_params) example dicts."""
|
|
464
|
+
path_params: Dict[str, Any] = {}
|
|
465
|
+
query_params: Dict[str, Any] = {}
|
|
466
|
+
header_params: Dict[str, Any] = {}
|
|
467
|
+
|
|
468
|
+
for param in parameters:
|
|
469
|
+
if "$ref" in param:
|
|
470
|
+
param = _resolve_ref(self.spec, param["$ref"])
|
|
471
|
+
name = param.get("name", "")
|
|
472
|
+
location = param.get("in", "")
|
|
473
|
+
schema = param.get("schema", {}) or {}
|
|
474
|
+
example = param.get("example") or _schema_example(self.spec, schema)
|
|
475
|
+
|
|
476
|
+
if location == "path":
|
|
477
|
+
path_params[name] = example if example is not None else "1"
|
|
478
|
+
elif location == "query":
|
|
479
|
+
query_params[name] = example if example is not None else ""
|
|
480
|
+
elif location == "header":
|
|
481
|
+
header_params[name] = str(example) if example is not None else ""
|
|
482
|
+
|
|
483
|
+
return path_params, query_params, header_params
|
|
484
|
+
|
|
485
|
+
def _extract_body_schema(
|
|
486
|
+
self,
|
|
487
|
+
operation: Dict[str, Any],
|
|
488
|
+
) -> Tuple[Optional[Dict[str, Any]], List[str]]:
|
|
489
|
+
"""Return (body_schema, required_fields) for the operation's request body."""
|
|
490
|
+
# OpenAPI 3
|
|
491
|
+
request_body = operation.get("requestBody", {})
|
|
492
|
+
if request_body:
|
|
493
|
+
content = request_body.get("content", {})
|
|
494
|
+
for media_type in ("application/json", "application/x-www-form-urlencoded"):
|
|
495
|
+
if media_type in content:
|
|
496
|
+
schema = content[media_type].get("schema", {})
|
|
497
|
+
if "$ref" in schema:
|
|
498
|
+
schema = _resolve_ref(self.spec, schema["$ref"])
|
|
499
|
+
required = schema.get("required", [])
|
|
500
|
+
return schema, list(required)
|
|
501
|
+
|
|
502
|
+
# Swagger 2 body parameter
|
|
503
|
+
for param in operation.get("parameters", []):
|
|
504
|
+
if "$ref" in param:
|
|
505
|
+
param = _resolve_ref(self.spec, param["$ref"])
|
|
506
|
+
if param.get("in") == "body":
|
|
507
|
+
schema = param.get("schema", {})
|
|
508
|
+
if "$ref" in schema:
|
|
509
|
+
schema = _resolve_ref(self.spec, schema["$ref"])
|
|
510
|
+
required = schema.get("required", [])
|
|
511
|
+
return schema, list(required)
|
|
512
|
+
|
|
513
|
+
return None, []
|