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,467 @@
|
|
|
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
|
+
"""Postman Collection v2.1 parser.
|
|
7
|
+
|
|
8
|
+
Reads a Postman Collection JSON file and auto-generates
|
|
9
|
+
:class:`~mannf.core.testing.models.TestCase` objects covering both positive
|
|
10
|
+
and negative test scenarios. Discovered endpoints are also registered into a
|
|
11
|
+
provided :class:`~mannf.core.distributed.endpoint.EndpointRegistry`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
22
|
+
from urllib.parse import urlparse, urlunparse
|
|
23
|
+
|
|
24
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
25
|
+
from mannf.core.testing.models import TestCase
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
_VAR_RE = re.compile(r"\{\{([^}]+)\}\}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_variables(text: str, variables: Dict[str, str]) -> str:
|
|
37
|
+
"""Replace ``{{varName}}`` patterns in *text* with values from *variables*."""
|
|
38
|
+
if not text:
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
def _replace(match: re.Match) -> str: # type: ignore[type-arg]
|
|
42
|
+
return variables.get(match.group(1), match.group(0))
|
|
43
|
+
|
|
44
|
+
return _VAR_RE.sub(_replace, text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _path_to_endpoint_name(path: str) -> str:
|
|
48
|
+
"""Turn ``/users/:id/orders`` into ``users-id-orders``."""
|
|
49
|
+
name = re.sub(r"[{}:()]", "", path)
|
|
50
|
+
name = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-")
|
|
51
|
+
return name or "root"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_base_url_from_collection(
|
|
55
|
+
collection: Dict[str, Any],
|
|
56
|
+
variables: Dict[str, str],
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Try to derive a base URL from collection variables or the first request."""
|
|
59
|
+
# 1. Check collection-level variable named "baseUrl" (common convention)
|
|
60
|
+
for var in collection.get("variable", []):
|
|
61
|
+
if var.get("key") == "baseUrl" and var.get("value"):
|
|
62
|
+
return str(var["value"]).rstrip("/")
|
|
63
|
+
|
|
64
|
+
# 2. Look for {{baseUrl}} replacement in variables dict
|
|
65
|
+
if "baseUrl" in variables:
|
|
66
|
+
return variables["baseUrl"].rstrip("/")
|
|
67
|
+
|
|
68
|
+
# 3. Fall back to extracting scheme+host from the first request's URL
|
|
69
|
+
first_url = _first_request_url(collection.get("item", []))
|
|
70
|
+
if first_url:
|
|
71
|
+
first_url = _resolve_variables(first_url, variables)
|
|
72
|
+
parsed = urlparse(first_url)
|
|
73
|
+
if parsed.scheme and parsed.netloc:
|
|
74
|
+
return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
|
|
75
|
+
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _first_request_url(items: List[Dict[str, Any]]) -> str:
|
|
80
|
+
"""Recursively find the URL string from the first leaf request."""
|
|
81
|
+
for item in items:
|
|
82
|
+
if "request" in item:
|
|
83
|
+
url = item["request"].get("url", "")
|
|
84
|
+
if isinstance(url, dict):
|
|
85
|
+
return url.get("raw", "")
|
|
86
|
+
return str(url)
|
|
87
|
+
if "item" in item:
|
|
88
|
+
result = _first_request_url(item["item"])
|
|
89
|
+
if result:
|
|
90
|
+
return result
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _collect_collection_variables(collection: Dict[str, Any]) -> Dict[str, str]:
|
|
95
|
+
"""Return a dict of collection-level variable definitions."""
|
|
96
|
+
variables: Dict[str, str] = {}
|
|
97
|
+
for var in collection.get("variable", []):
|
|
98
|
+
key = var.get("key") or ""
|
|
99
|
+
value = var.get("value") or ""
|
|
100
|
+
if key:
|
|
101
|
+
variables[key] = str(value)
|
|
102
|
+
return variables
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_auth_headers(
|
|
106
|
+
auth_block: Optional[Dict[str, Any]],
|
|
107
|
+
) -> Dict[str, str]:
|
|
108
|
+
"""Convert a Postman ``auth`` block to HTTP header mappings.
|
|
109
|
+
|
|
110
|
+
Supports: bearer, apikey, basic, noauth (and unknown types → empty).
|
|
111
|
+
"""
|
|
112
|
+
if not auth_block:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
auth_type = auth_block.get("type", "").lower()
|
|
116
|
+
headers: Dict[str, str] = {}
|
|
117
|
+
|
|
118
|
+
if auth_type == "bearer":
|
|
119
|
+
token = _get_auth_param(auth_block, "bearer", "token") or ""
|
|
120
|
+
if token:
|
|
121
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
122
|
+
|
|
123
|
+
elif auth_type == "apikey":
|
|
124
|
+
key = _get_auth_param(auth_block, "apikey", "key") or "X-API-Key"
|
|
125
|
+
value = _get_auth_param(auth_block, "apikey", "value") or ""
|
|
126
|
+
in_location = (_get_auth_param(auth_block, "apikey", "in") or "header").lower()
|
|
127
|
+
if in_location == "header" and value:
|
|
128
|
+
headers[key] = value
|
|
129
|
+
|
|
130
|
+
elif auth_type == "basic":
|
|
131
|
+
username = _get_auth_param(auth_block, "basic", "username") or ""
|
|
132
|
+
password = _get_auth_param(auth_block, "basic", "password") or ""
|
|
133
|
+
if username or password:
|
|
134
|
+
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
135
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
136
|
+
|
|
137
|
+
return headers
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_auth_param(
|
|
141
|
+
auth_block: Dict[str, Any],
|
|
142
|
+
auth_type: str,
|
|
143
|
+
key: str,
|
|
144
|
+
) -> Optional[str]:
|
|
145
|
+
"""Extract a named parameter value from a Postman auth block."""
|
|
146
|
+
params = auth_block.get(auth_type, [])
|
|
147
|
+
if isinstance(params, list):
|
|
148
|
+
for param in params:
|
|
149
|
+
if param.get("key") == key:
|
|
150
|
+
return param.get("value")
|
|
151
|
+
elif isinstance(params, dict):
|
|
152
|
+
return params.get(key)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_url(
|
|
157
|
+
url_obj: Any,
|
|
158
|
+
variables: Dict[str, str],
|
|
159
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
160
|
+
"""Return ``(path, query_params)`` from a Postman URL object or string.
|
|
161
|
+
|
|
162
|
+
Postman variables in path segments and query values are resolved.
|
|
163
|
+
"""
|
|
164
|
+
query_params: Dict[str, str] = {}
|
|
165
|
+
|
|
166
|
+
if isinstance(url_obj, str):
|
|
167
|
+
raw = _resolve_variables(url_obj, variables)
|
|
168
|
+
parsed = urlparse(raw)
|
|
169
|
+
path = parsed.path or "/"
|
|
170
|
+
if parsed.query:
|
|
171
|
+
for part in parsed.query.split("&"):
|
|
172
|
+
if "=" in part:
|
|
173
|
+
k, _, v = part.partition("=")
|
|
174
|
+
query_params[k] = v
|
|
175
|
+
return path, query_params
|
|
176
|
+
|
|
177
|
+
if not isinstance(url_obj, dict):
|
|
178
|
+
return "/", query_params
|
|
179
|
+
|
|
180
|
+
# Build path from segments array (preferred) or "raw"
|
|
181
|
+
path_segments = url_obj.get("path", [])
|
|
182
|
+
if path_segments and isinstance(path_segments, list):
|
|
183
|
+
parts: List[str] = []
|
|
184
|
+
for seg in path_segments:
|
|
185
|
+
if isinstance(seg, dict):
|
|
186
|
+
seg_val = seg.get("value", "") or ""
|
|
187
|
+
else:
|
|
188
|
+
seg_val = str(seg)
|
|
189
|
+
parts.append(_resolve_variables(seg_val, variables))
|
|
190
|
+
path = "/" + "/".join(parts)
|
|
191
|
+
else:
|
|
192
|
+
raw = _resolve_variables(url_obj.get("raw", ""), variables)
|
|
193
|
+
parsed = urlparse(raw)
|
|
194
|
+
path = parsed.path or "/"
|
|
195
|
+
|
|
196
|
+
# Query parameters
|
|
197
|
+
for q in url_obj.get("query", []):
|
|
198
|
+
if q.get("disabled"):
|
|
199
|
+
continue
|
|
200
|
+
k = q.get("key", "")
|
|
201
|
+
v = _resolve_variables(q.get("value", "") or "", variables)
|
|
202
|
+
if k:
|
|
203
|
+
query_params[k] = v
|
|
204
|
+
|
|
205
|
+
return path, query_params
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_body(
|
|
209
|
+
body_obj: Optional[Dict[str, Any]],
|
|
210
|
+
variables: Dict[str, str],
|
|
211
|
+
) -> Tuple[Optional[Any], List[str]]:
|
|
212
|
+
"""Return ``(json_body, required_fields)`` from a Postman request body.
|
|
213
|
+
|
|
214
|
+
Only ``raw`` mode with a JSON content-type is handled; all other modes
|
|
215
|
+
return ``(None, [])``.
|
|
216
|
+
"""
|
|
217
|
+
if not body_obj:
|
|
218
|
+
return None, []
|
|
219
|
+
|
|
220
|
+
mode = body_obj.get("mode", "")
|
|
221
|
+
if mode != "raw":
|
|
222
|
+
return None, []
|
|
223
|
+
|
|
224
|
+
raw = body_obj.get("raw", "") or ""
|
|
225
|
+
raw = _resolve_variables(raw, variables)
|
|
226
|
+
if not raw.strip():
|
|
227
|
+
return None, []
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
parsed = json.loads(raw)
|
|
231
|
+
except (ValueError, json.JSONDecodeError):
|
|
232
|
+
return None, []
|
|
233
|
+
|
|
234
|
+
if not isinstance(parsed, dict):
|
|
235
|
+
return parsed, []
|
|
236
|
+
|
|
237
|
+
# Heuristic: treat all top-level keys as required for negative generation
|
|
238
|
+
required = list(parsed.keys())
|
|
239
|
+
return parsed, required
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Public API
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class PostmanParser:
|
|
248
|
+
"""Parse a Postman Collection v2.1 file and generate :class:`TestCase` objects.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
collection_path:
|
|
253
|
+
Path to the Postman Collection JSON file.
|
|
254
|
+
base_url:
|
|
255
|
+
Override the base URL extracted from the collection. When ``None``
|
|
256
|
+
the parser tries ``{{baseUrl}}`` collection variables first, then
|
|
257
|
+
falls back to the first request's origin.
|
|
258
|
+
registry:
|
|
259
|
+
:class:`EndpointRegistry` to populate with discovered endpoints.
|
|
260
|
+
A fresh registry is created when ``None`` is passed.
|
|
261
|
+
max_tests:
|
|
262
|
+
Maximum number of :class:`TestCase` objects to generate. ``None``
|
|
263
|
+
means no limit.
|
|
264
|
+
variables:
|
|
265
|
+
Extra variable mappings to resolve ``{{varName}}`` patterns. These
|
|
266
|
+
take precedence over collection-level variable definitions.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
collection_path: str,
|
|
272
|
+
base_url: Optional[str] = None,
|
|
273
|
+
registry: Optional[EndpointRegistry] = None,
|
|
274
|
+
max_tests: Optional[int] = None,
|
|
275
|
+
variables: Optional[Dict[str, str]] = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
self.collection_path = collection_path
|
|
278
|
+
self.collection = self._load_collection(collection_path)
|
|
279
|
+
self.registry = registry if registry is not None else EndpointRegistry()
|
|
280
|
+
self.max_tests = max_tests
|
|
281
|
+
|
|
282
|
+
# Merge variable resolution order: caller-supplied > collection-level
|
|
283
|
+
coll_vars = _collect_collection_variables(self.collection)
|
|
284
|
+
self.variables: Dict[str, str] = {**coll_vars, **(variables or {})}
|
|
285
|
+
|
|
286
|
+
self.base_url = (
|
|
287
|
+
base_url
|
|
288
|
+
or _extract_base_url_from_collection(self.collection, self.variables)
|
|
289
|
+
).rstrip("/")
|
|
290
|
+
|
|
291
|
+
# Collection-level auth (lower priority than request-level)
|
|
292
|
+
self._collection_auth: Optional[Dict[str, Any]] = self.collection.get("auth")
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Public interface
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def parse(self) -> List[TestCase]:
|
|
299
|
+
"""Parse the collection and return all generated :class:`TestCase` objects."""
|
|
300
|
+
test_cases: List[TestCase] = []
|
|
301
|
+
items = self.collection.get("item", [])
|
|
302
|
+
request_items = self._flatten_items(items)
|
|
303
|
+
|
|
304
|
+
for item in request_items:
|
|
305
|
+
cases = self._generate_cases_for_item(item)
|
|
306
|
+
test_cases.extend(cases)
|
|
307
|
+
|
|
308
|
+
if self.max_tests is not None and len(test_cases) >= self.max_tests:
|
|
309
|
+
return test_cases[: self.max_tests]
|
|
310
|
+
|
|
311
|
+
return test_cases
|
|
312
|
+
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
# Internal helpers
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _load_collection(path: str) -> Dict[str, Any]:
|
|
319
|
+
"""Load and return a Postman Collection JSON from *path*."""
|
|
320
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
321
|
+
data = json.loads(text)
|
|
322
|
+
# Collections are sometimes wrapped under a "collection" top-level key
|
|
323
|
+
if "collection" in data and isinstance(data["collection"], dict):
|
|
324
|
+
return data["collection"]
|
|
325
|
+
return data
|
|
326
|
+
|
|
327
|
+
def _flatten_items(
|
|
328
|
+
self,
|
|
329
|
+
items: List[Dict[str, Any]],
|
|
330
|
+
) -> List[Dict[str, Any]]:
|
|
331
|
+
"""Recursively walk item/folder trees and return only leaf request items."""
|
|
332
|
+
result: List[Dict[str, Any]] = []
|
|
333
|
+
for item in items:
|
|
334
|
+
if "item" in item:
|
|
335
|
+
# This is a folder — recurse
|
|
336
|
+
result.extend(self._flatten_items(item["item"]))
|
|
337
|
+
elif "request" in item:
|
|
338
|
+
result.append(item)
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
def _generate_cases_for_item(
|
|
342
|
+
self,
|
|
343
|
+
item: Dict[str, Any],
|
|
344
|
+
) -> List[TestCase]:
|
|
345
|
+
"""Return positive + negative test cases for one Postman request item."""
|
|
346
|
+
cases: List[TestCase] = []
|
|
347
|
+
name = item.get("name", "Unnamed request")
|
|
348
|
+
request = item.get("request", {})
|
|
349
|
+
|
|
350
|
+
method = (request.get("method") or "GET").upper()
|
|
351
|
+
|
|
352
|
+
# --- URL --------------------------------------------------------
|
|
353
|
+
url_obj = request.get("url", "")
|
|
354
|
+
path, query_params = _parse_url(url_obj, self.variables)
|
|
355
|
+
|
|
356
|
+
# --- Auth headers -----------------------------------------------
|
|
357
|
+
request_auth = request.get("auth")
|
|
358
|
+
effective_auth = request_auth or self._collection_auth
|
|
359
|
+
auth_headers = _build_auth_headers(effective_auth)
|
|
360
|
+
|
|
361
|
+
# --- Request headers --------------------------------------------
|
|
362
|
+
headers: Dict[str, str] = dict(auth_headers)
|
|
363
|
+
for hdr in request.get("header", []):
|
|
364
|
+
if hdr.get("disabled"):
|
|
365
|
+
continue
|
|
366
|
+
hdr_key = hdr.get("key", "")
|
|
367
|
+
hdr_val = _resolve_variables(hdr.get("value", "") or "", self.variables)
|
|
368
|
+
if hdr_key:
|
|
369
|
+
headers[hdr_key] = hdr_val
|
|
370
|
+
|
|
371
|
+
# --- Request body -----------------------------------------------
|
|
372
|
+
body_obj = request.get("body")
|
|
373
|
+
json_body, required_fields = _parse_body(body_obj, self.variables)
|
|
374
|
+
|
|
375
|
+
# --- Register endpoint -----------------------------------------
|
|
376
|
+
ep_name = _path_to_endpoint_name(path)
|
|
377
|
+
parsed_base = urlparse(self.base_url) if self.base_url else None
|
|
378
|
+
host = (parsed_base.hostname or "localhost") if parsed_base else "localhost"
|
|
379
|
+
port = (
|
|
380
|
+
parsed_base.port
|
|
381
|
+
or (443 if (parsed_base and parsed_base.scheme == "https") else 80)
|
|
382
|
+
) if parsed_base else 80
|
|
383
|
+
protocol = (parsed_base.scheme or "http") if parsed_base else "http"
|
|
384
|
+
|
|
385
|
+
endpoint = Endpoint(
|
|
386
|
+
name=ep_name,
|
|
387
|
+
host=host,
|
|
388
|
+
port=port,
|
|
389
|
+
protocol=protocol,
|
|
390
|
+
tags={"path": path},
|
|
391
|
+
)
|
|
392
|
+
self.registry.register(endpoint)
|
|
393
|
+
|
|
394
|
+
# ---- Positive test case ----------------------------------------
|
|
395
|
+
pos_inputs: Dict[str, Any] = {"method": method}
|
|
396
|
+
if query_params:
|
|
397
|
+
pos_inputs["query_params"] = query_params
|
|
398
|
+
if headers:
|
|
399
|
+
pos_inputs["headers"] = headers
|
|
400
|
+
if json_body is not None and method in {"POST", "PUT", "PATCH"}:
|
|
401
|
+
pos_inputs["json"] = json_body
|
|
402
|
+
|
|
403
|
+
cases.append(
|
|
404
|
+
TestCase(
|
|
405
|
+
target=path,
|
|
406
|
+
inputs=pos_inputs,
|
|
407
|
+
expected_behavior=f"[positive] {name}",
|
|
408
|
+
priority=0.5,
|
|
409
|
+
metadata={
|
|
410
|
+
"spec_path": path,
|
|
411
|
+
"method": method,
|
|
412
|
+
"test_type": "positive",
|
|
413
|
+
"generator": "postman",
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# ---- Negative test cases ----------------------------------------
|
|
419
|
+
# 1. Missing required body field (if JSON body with fields present)
|
|
420
|
+
if json_body is not None and required_fields and method in {"POST", "PUT", "PATCH"}:
|
|
421
|
+
if isinstance(json_body, dict):
|
|
422
|
+
field_to_drop = required_fields[0]
|
|
423
|
+
missing_body = {k: v for k, v in json_body.items() if k != field_to_drop}
|
|
424
|
+
neg_inputs = {
|
|
425
|
+
**pos_inputs,
|
|
426
|
+
"json": missing_body,
|
|
427
|
+
"expected_status_codes": [400, 422],
|
|
428
|
+
}
|
|
429
|
+
cases.append(
|
|
430
|
+
TestCase(
|
|
431
|
+
target=path,
|
|
432
|
+
inputs=neg_inputs,
|
|
433
|
+
expected_behavior=(
|
|
434
|
+
f"[negative] {name}: missing required field '{field_to_drop}'"
|
|
435
|
+
),
|
|
436
|
+
priority=0.6,
|
|
437
|
+
metadata={
|
|
438
|
+
"spec_path": path,
|
|
439
|
+
"method": method,
|
|
440
|
+
"test_type": "negative",
|
|
441
|
+
"generator": "postman",
|
|
442
|
+
"neg_type": "missing_required_field",
|
|
443
|
+
"missing_field": field_to_drop,
|
|
444
|
+
},
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# 2. Empty body for requests that have a body defined
|
|
449
|
+
if json_body is not None and method in {"POST", "PUT", "PATCH"}:
|
|
450
|
+
empty_inputs = {**pos_inputs, "json": {}, "expected_status_codes": [400, 422]}
|
|
451
|
+
cases.append(
|
|
452
|
+
TestCase(
|
|
453
|
+
target=path,
|
|
454
|
+
inputs=empty_inputs,
|
|
455
|
+
expected_behavior=f"[negative] {name}: empty body",
|
|
456
|
+
priority=0.6,
|
|
457
|
+
metadata={
|
|
458
|
+
"spec_path": path,
|
|
459
|
+
"method": method,
|
|
460
|
+
"test_type": "negative",
|
|
461
|
+
"generator": "postman",
|
|
462
|
+
"neg_type": "empty_body",
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return cases
|