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,631 @@
|
|
|
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 ingestor plugin.
|
|
7
|
+
|
|
8
|
+
Parses Postman v2.1+ Collection JSON files and produces normalised
|
|
9
|
+
:class:`~mannf.product.ingestors.models.IngestedEndpoint` and
|
|
10
|
+
:class:`~mannf.product.ingestors.models.IngestedTestCase` objects.
|
|
11
|
+
|
|
12
|
+
Utility functions (``_resolve_variables``, ``_path_to_endpoint_name``,
|
|
13
|
+
``_parse_url``, ``_parse_body``, ``_build_auth_headers``,
|
|
14
|
+
``_collect_collection_variables``) are imported from the existing
|
|
15
|
+
:mod:`mannf.product.integrations.postman_parser` module to avoid duplication.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from mannf.product.ingestors.base import IngestorPlugin
|
|
26
|
+
from mannf.product.ingestors.models import (
|
|
27
|
+
IngestedEndpoint,
|
|
28
|
+
IngestedTestCase,
|
|
29
|
+
IngestorSource,
|
|
30
|
+
IngestResult,
|
|
31
|
+
)
|
|
32
|
+
from mannf.product.integrations.postman_parser import (
|
|
33
|
+
_build_auth_headers,
|
|
34
|
+
_collect_collection_variables,
|
|
35
|
+
_get_auth_param,
|
|
36
|
+
_parse_body,
|
|
37
|
+
_parse_url,
|
|
38
|
+
_path_to_endpoint_name,
|
|
39
|
+
_resolve_variables,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# Postman schema URL fragment that identifies v2.x collections
|
|
45
|
+
_POSTMAN_SCHEMA_FRAGMENT = "collection.json"
|
|
46
|
+
|
|
47
|
+
# Body modes that are not raw JSON — warn rather than crash
|
|
48
|
+
_UNSUPPORTED_BODY_MODES = {"graphql", "file"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_postman_collection(data: dict[str, Any]) -> bool:
|
|
52
|
+
"""Return ``True`` if *data* looks like a Postman v2.x collection.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
data:
|
|
57
|
+
Parsed JSON object.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
bool
|
|
62
|
+
``True`` when the document has the expected Postman collection shape.
|
|
63
|
+
"""
|
|
64
|
+
info = data.get("info")
|
|
65
|
+
if not isinstance(info, dict):
|
|
66
|
+
return False
|
|
67
|
+
schema: str = info.get("schema", "") or ""
|
|
68
|
+
postman_id: str = info.get("_postman_id", "") or ""
|
|
69
|
+
has_items = "item" in data
|
|
70
|
+
return has_items and (
|
|
71
|
+
_POSTMAN_SCHEMA_FRAGMENT in schema or bool(postman_id)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_auth_schemes(auth_block: dict[str, Any] | None) -> list[str]:
|
|
76
|
+
"""Return a list of auth scheme names present in *auth_block*.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
auth_block:
|
|
81
|
+
A Postman ``auth`` object, or ``None``.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
list[str]
|
|
86
|
+
E.g. ``["bearer"]``, ``["apikey"]``, ``["basic"]``, or ``[]``.
|
|
87
|
+
"""
|
|
88
|
+
if not auth_block:
|
|
89
|
+
return []
|
|
90
|
+
auth_type = auth_block.get("type", "").lower()
|
|
91
|
+
if auth_type in {"noauth", ""}:
|
|
92
|
+
return []
|
|
93
|
+
return [auth_type]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_headers(
|
|
97
|
+
request: dict[str, Any],
|
|
98
|
+
variables: dict[str, str],
|
|
99
|
+
) -> list[dict[str, str]]:
|
|
100
|
+
"""Return a list of non-disabled header parameter dicts for *request*.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
request:
|
|
105
|
+
The Postman request object.
|
|
106
|
+
variables:
|
|
107
|
+
Collection-level variable mappings for ``{{var}}`` resolution.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
list[dict[str, str]]
|
|
112
|
+
Each item has ``name`` and ``value`` keys.
|
|
113
|
+
"""
|
|
114
|
+
headers: list[dict[str, str]] = []
|
|
115
|
+
for hdr in request.get("header", []):
|
|
116
|
+
if hdr.get("disabled"):
|
|
117
|
+
continue
|
|
118
|
+
key = hdr.get("key", "")
|
|
119
|
+
value = _resolve_variables(hdr.get("value", "") or "", variables)
|
|
120
|
+
if key:
|
|
121
|
+
headers.append({"name": key, "value": value})
|
|
122
|
+
return headers
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _flatten_items(
|
|
126
|
+
items: list[dict[str, Any]],
|
|
127
|
+
folder_tags: list[str] | None = None,
|
|
128
|
+
) -> list[tuple[dict[str, Any], list[str]]]:
|
|
129
|
+
"""Recursively flatten a Postman item tree into ``(item, tags)`` pairs.
|
|
130
|
+
|
|
131
|
+
Folder names are accumulated as tags for the leaf requests they contain.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
items:
|
|
136
|
+
Top-level list of items or folders from the collection.
|
|
137
|
+
folder_tags:
|
|
138
|
+
Tags inherited from parent folders.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
list[tuple[dict[str, Any], list[str]]]
|
|
143
|
+
Each tuple is ``(request_item, accumulated_folder_tags)``.
|
|
144
|
+
"""
|
|
145
|
+
folder_tags = folder_tags or []
|
|
146
|
+
result: list[tuple[dict[str, Any], list[str]]] = []
|
|
147
|
+
for item in items:
|
|
148
|
+
if "item" in item:
|
|
149
|
+
# Folder — recurse with this folder's name appended to tags
|
|
150
|
+
folder_name = item.get("name", "")
|
|
151
|
+
child_tags = folder_tags + ([folder_name] if folder_name else [])
|
|
152
|
+
result.extend(_flatten_items(item["item"], child_tags))
|
|
153
|
+
elif "request" in item:
|
|
154
|
+
result.append((item, list(folder_tags)))
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_request_body_schema(
|
|
159
|
+
body_obj: dict[str, Any] | None,
|
|
160
|
+
variables: dict[str, str],
|
|
161
|
+
warnings: list[str],
|
|
162
|
+
) -> dict[str, Any] | None:
|
|
163
|
+
"""Convert a Postman request body into a normalised schema-like dict.
|
|
164
|
+
|
|
165
|
+
Handles ``raw`` (JSON), ``formdata``, and ``urlencoded`` modes.
|
|
166
|
+
Emits a warning for unsupported modes such as ``graphql`` or ``file``.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
body_obj:
|
|
171
|
+
The Postman ``body`` object from the request, or ``None``.
|
|
172
|
+
variables:
|
|
173
|
+
Collection variable mappings for ``{{var}}`` resolution.
|
|
174
|
+
warnings:
|
|
175
|
+
Mutable list to append warning strings to.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
dict[str, Any] | None
|
|
180
|
+
A body representation dict, or ``None`` for absent/unrecognised body.
|
|
181
|
+
"""
|
|
182
|
+
if not body_obj:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
mode = body_obj.get("mode", "")
|
|
186
|
+
|
|
187
|
+
if mode in _UNSUPPORTED_BODY_MODES:
|
|
188
|
+
warnings.append(
|
|
189
|
+
f"Request body mode '{mode}' is not fully supported; "
|
|
190
|
+
"body content will be omitted."
|
|
191
|
+
)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
if mode == "raw":
|
|
195
|
+
json_body, _ = _parse_body(body_obj, variables)
|
|
196
|
+
if json_body is not None:
|
|
197
|
+
return {
|
|
198
|
+
"content": {
|
|
199
|
+
"application/json": {
|
|
200
|
+
"schema": {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"example": json_body,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
# Raw but not valid JSON (e.g. plain text, XML)
|
|
208
|
+
raw_text = _resolve_variables(body_obj.get("raw", "") or "", variables)
|
|
209
|
+
if raw_text.strip():
|
|
210
|
+
return {
|
|
211
|
+
"content": {
|
|
212
|
+
"text/plain": {
|
|
213
|
+
"schema": {"type": "string", "example": raw_text}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
if mode == "formdata":
|
|
220
|
+
properties: dict[str, Any] = {}
|
|
221
|
+
for field in body_obj.get("formdata", []):
|
|
222
|
+
if field.get("disabled"):
|
|
223
|
+
continue
|
|
224
|
+
fname = field.get("key", "")
|
|
225
|
+
fval = _resolve_variables(field.get("value", "") or "", variables)
|
|
226
|
+
if fname:
|
|
227
|
+
properties[fname] = {"type": "string", "example": fval}
|
|
228
|
+
return (
|
|
229
|
+
{
|
|
230
|
+
"content": {
|
|
231
|
+
"multipart/form-data": {
|
|
232
|
+
"schema": {"type": "object", "properties": properties}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if properties
|
|
237
|
+
else None
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if mode == "urlencoded":
|
|
241
|
+
properties = {}
|
|
242
|
+
for field in body_obj.get("urlencoded", []):
|
|
243
|
+
if field.get("disabled"):
|
|
244
|
+
continue
|
|
245
|
+
fname = field.get("key", "")
|
|
246
|
+
fval = _resolve_variables(field.get("value", "") or "", variables)
|
|
247
|
+
if fname:
|
|
248
|
+
properties[fname] = {"type": "string", "example": fval}
|
|
249
|
+
return (
|
|
250
|
+
{
|
|
251
|
+
"content": {
|
|
252
|
+
"application/x-www-form-urlencoded": {
|
|
253
|
+
"schema": {"type": "object", "properties": properties}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if properties
|
|
258
|
+
else None
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _generate_test_cases(
|
|
265
|
+
item: dict[str, Any],
|
|
266
|
+
method: str,
|
|
267
|
+
path: str,
|
|
268
|
+
auth_schemes: list[str],
|
|
269
|
+
folder_tags: list[str],
|
|
270
|
+
variables: dict[str, str],
|
|
271
|
+
query_params: dict[str, str],
|
|
272
|
+
headers: list[dict[str, str]],
|
|
273
|
+
body_obj: dict[str, Any] | None,
|
|
274
|
+
) -> list[IngestedTestCase]:
|
|
275
|
+
"""Generate positive and optional negative test cases for one request item.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
item:
|
|
280
|
+
The Postman request item dict.
|
|
281
|
+
method:
|
|
282
|
+
HTTP method (uppercased).
|
|
283
|
+
path:
|
|
284
|
+
Resolved URL path.
|
|
285
|
+
auth_schemes:
|
|
286
|
+
Auth scheme names active for this request.
|
|
287
|
+
folder_tags:
|
|
288
|
+
Folder-inherited tags.
|
|
289
|
+
variables:
|
|
290
|
+
Collection variable mappings.
|
|
291
|
+
query_params:
|
|
292
|
+
Resolved query parameters.
|
|
293
|
+
headers:
|
|
294
|
+
Resolved header list.
|
|
295
|
+
body_obj:
|
|
296
|
+
The Postman request ``body`` object, or ``None``.
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
list[IngestedTestCase]
|
|
301
|
+
Positive test case, plus a no-auth negative case if auth is present.
|
|
302
|
+
"""
|
|
303
|
+
name = item.get("name", "Unnamed request")
|
|
304
|
+
source_ref = f"{method} {path}"
|
|
305
|
+
cases: list[IngestedTestCase] = []
|
|
306
|
+
|
|
307
|
+
# --- Positive test case ---
|
|
308
|
+
pos_inputs: dict[str, Any] = {"method": method, "path": path}
|
|
309
|
+
if query_params:
|
|
310
|
+
pos_inputs["query_params"] = query_params
|
|
311
|
+
if headers:
|
|
312
|
+
pos_inputs["headers"] = {h["name"]: h["value"] for h in headers}
|
|
313
|
+
|
|
314
|
+
json_body, required_fields = _parse_body(body_obj, variables) if body_obj else (None, [])
|
|
315
|
+
if json_body is not None and method in {"POST", "PUT", "PATCH"}:
|
|
316
|
+
pos_inputs["json"] = json_body
|
|
317
|
+
|
|
318
|
+
cases.append(
|
|
319
|
+
IngestedTestCase(
|
|
320
|
+
name=f"[positive] {name}",
|
|
321
|
+
description=f"Send a valid {method} request to {path} and expect a 2xx response.",
|
|
322
|
+
steps=[
|
|
323
|
+
{
|
|
324
|
+
"action": f"{method} {path}",
|
|
325
|
+
"description": "Send valid request",
|
|
326
|
+
}
|
|
327
|
+
],
|
|
328
|
+
expected_result="2xx response",
|
|
329
|
+
priority="medium",
|
|
330
|
+
tags=["positive", "functional"] + folder_tags,
|
|
331
|
+
source_ref=source_ref,
|
|
332
|
+
metadata={
|
|
333
|
+
"test_type": "positive",
|
|
334
|
+
"generator": "postman",
|
|
335
|
+
"item_name": name,
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# --- No-auth negative test case (only when auth is required) ---
|
|
341
|
+
if auth_schemes:
|
|
342
|
+
cases.append(
|
|
343
|
+
IngestedTestCase(
|
|
344
|
+
name=f"[security] {name} — missing auth",
|
|
345
|
+
description=(
|
|
346
|
+
f"Send a {method} request to {path} without authentication "
|
|
347
|
+
f"and expect a 401 or 403 response."
|
|
348
|
+
),
|
|
349
|
+
steps=[
|
|
350
|
+
{
|
|
351
|
+
"action": f"{method} {path}",
|
|
352
|
+
"description": "Send request without auth credentials",
|
|
353
|
+
}
|
|
354
|
+
],
|
|
355
|
+
expected_result="401 or 403 response",
|
|
356
|
+
priority="high",
|
|
357
|
+
tags=["security", "auth", "negative"] + folder_tags,
|
|
358
|
+
source_ref=source_ref,
|
|
359
|
+
metadata={
|
|
360
|
+
"test_type": "negative",
|
|
361
|
+
"neg_type": "missing_auth",
|
|
362
|
+
"generator": "postman",
|
|
363
|
+
"item_name": name,
|
|
364
|
+
"auth_schemes": auth_schemes,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return cases
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class PostmanIngestor(IngestorPlugin):
|
|
373
|
+
"""Ingest Postman v2.1+ Collection JSON files.
|
|
374
|
+
|
|
375
|
+
Produces normalised :class:`~mannf.product.ingestors.models.IngestedEndpoint`
|
|
376
|
+
and :class:`~mannf.product.ingestors.models.IngestedTestCase` objects from
|
|
377
|
+
Postman Collection v2.1 JSON files. Utility functions are reused from
|
|
378
|
+
:mod:`mannf.product.integrations.postman_parser`.
|
|
379
|
+
|
|
380
|
+
Configuration keys
|
|
381
|
+
------------------
|
|
382
|
+
base_url : str, optional
|
|
383
|
+
Override the base URL extracted from the collection.
|
|
384
|
+
resolve_variables : bool, optional
|
|
385
|
+
When ``True`` (default), ``{{variable}}`` placeholders in URLs, headers,
|
|
386
|
+
and bodies are replaced with collection-level variable values.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
name = "postman"
|
|
390
|
+
display_name = "Postman Collection"
|
|
391
|
+
description = "Ingest Postman v2.1 Collection JSON files."
|
|
392
|
+
supported_extensions = [".json", ".postman_collection.json"]
|
|
393
|
+
supported_formats = ["postman"]
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
# validate_config
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
400
|
+
"""Validate the plugin configuration.
|
|
401
|
+
|
|
402
|
+
Parameters
|
|
403
|
+
----------
|
|
404
|
+
config:
|
|
405
|
+
Supported keys: ``base_url`` (str, optional),
|
|
406
|
+
``resolve_variables`` (bool, optional).
|
|
407
|
+
|
|
408
|
+
Returns
|
|
409
|
+
-------
|
|
410
|
+
list[str]
|
|
411
|
+
A list of human-readable error strings. Empty when config is valid.
|
|
412
|
+
"""
|
|
413
|
+
errors: list[str] = []
|
|
414
|
+
if "base_url" in config and not isinstance(config["base_url"], str):
|
|
415
|
+
errors.append("'base_url' must be a string.")
|
|
416
|
+
if "resolve_variables" in config and not isinstance(
|
|
417
|
+
config["resolve_variables"], bool
|
|
418
|
+
):
|
|
419
|
+
errors.append("'resolve_variables' must be a boolean.")
|
|
420
|
+
return errors
|
|
421
|
+
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# can_handle
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def can_handle(self, source: IngestorSource) -> bool:
|
|
427
|
+
"""Return ``True`` for Postman format or matching JSON extension + content.
|
|
428
|
+
|
|
429
|
+
Parameters
|
|
430
|
+
----------
|
|
431
|
+
source:
|
|
432
|
+
The source to evaluate.
|
|
433
|
+
|
|
434
|
+
Returns
|
|
435
|
+
-------
|
|
436
|
+
bool
|
|
437
|
+
``True`` if this plugin can ingest the source.
|
|
438
|
+
"""
|
|
439
|
+
if source.format and source.format in self.supported_formats:
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
if not source.format and source.path is not None:
|
|
443
|
+
ext = Path(source.path).suffix.lower()
|
|
444
|
+
if ext not in {".json"}:
|
|
445
|
+
return False
|
|
446
|
+
# Content-sniff when raw_content is available
|
|
447
|
+
if source.raw_content:
|
|
448
|
+
content_lower = source.raw_content.lower()
|
|
449
|
+
return (
|
|
450
|
+
'"info"' in content_lower
|
|
451
|
+
and '"item"' in content_lower
|
|
452
|
+
and "postman" in content_lower
|
|
453
|
+
)
|
|
454
|
+
return True # Extension match is sufficient without content
|
|
455
|
+
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
# ------------------------------------------------------------------
|
|
459
|
+
# ingest
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
|
|
463
|
+
"""Parse a Postman Collection JSON file and return a :class:`IngestResult`.
|
|
464
|
+
|
|
465
|
+
Parameters
|
|
466
|
+
----------
|
|
467
|
+
source:
|
|
468
|
+
The source to ingest. Must provide either ``raw_content`` or a
|
|
469
|
+
valid ``path``.
|
|
470
|
+
config:
|
|
471
|
+
Plugin-specific configuration. See :meth:`validate_config` for
|
|
472
|
+
supported keys.
|
|
473
|
+
|
|
474
|
+
Returns
|
|
475
|
+
-------
|
|
476
|
+
IngestResult
|
|
477
|
+
Populated result on success; ``success=False`` with ``errors`` on
|
|
478
|
+
failure.
|
|
479
|
+
"""
|
|
480
|
+
# --- Read content ---
|
|
481
|
+
try:
|
|
482
|
+
content = self._read_source(source)
|
|
483
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
484
|
+
return self._make_error_result(source, str(exc))
|
|
485
|
+
|
|
486
|
+
# --- Parse JSON ---
|
|
487
|
+
try:
|
|
488
|
+
data: dict[str, Any] = json.loads(content)
|
|
489
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
490
|
+
return self._make_error_result(
|
|
491
|
+
source, f"Failed to parse JSON: {exc}"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if not isinstance(data, dict):
|
|
495
|
+
return self._make_error_result(
|
|
496
|
+
source, "Expected a JSON object at the top level."
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Unwrap collections sometimes wrapped under a "collection" key
|
|
500
|
+
if "collection" in data and isinstance(data["collection"], dict):
|
|
501
|
+
data = data["collection"]
|
|
502
|
+
|
|
503
|
+
# --- Validate Postman collection ---
|
|
504
|
+
if not _is_postman_collection(data):
|
|
505
|
+
return self._make_error_result(
|
|
506
|
+
source,
|
|
507
|
+
"Content does not appear to be a Postman v2.1 Collection "
|
|
508
|
+
"(missing 'info._postman_id' or 'info.schema' + 'item').",
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
warnings: list[str] = []
|
|
512
|
+
|
|
513
|
+
# --- Collect variables ---
|
|
514
|
+
should_resolve = bool(config.get("resolve_variables", True))
|
|
515
|
+
variables: dict[str, str] = (
|
|
516
|
+
_collect_collection_variables(data) if should_resolve else {}
|
|
517
|
+
)
|
|
518
|
+
# Config base_url takes precedence
|
|
519
|
+
config_base_url: str | None = config.get("base_url") or None
|
|
520
|
+
|
|
521
|
+
# Collection-level auth (lower priority than request-level)
|
|
522
|
+
collection_auth: dict[str, Any] | None = data.get("auth")
|
|
523
|
+
|
|
524
|
+
# --- Walk items ---
|
|
525
|
+
raw_items = data.get("item", [])
|
|
526
|
+
if not isinstance(raw_items, list):
|
|
527
|
+
raw_items = []
|
|
528
|
+
|
|
529
|
+
flat_items = _flatten_items(raw_items)
|
|
530
|
+
|
|
531
|
+
if not flat_items:
|
|
532
|
+
warnings.append("Collection contains no request items.")
|
|
533
|
+
|
|
534
|
+
endpoints: list[IngestedEndpoint] = []
|
|
535
|
+
test_cases: list[IngestedTestCase] = []
|
|
536
|
+
|
|
537
|
+
# Track unique method+path to avoid duplicates
|
|
538
|
+
seen_endpoints: set[tuple[str, str]] = set()
|
|
539
|
+
|
|
540
|
+
for item, folder_tags in flat_items:
|
|
541
|
+
request = item.get("request", {})
|
|
542
|
+
if not isinstance(request, dict):
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
# --- Method ---
|
|
546
|
+
method = (request.get("method") or "GET").upper()
|
|
547
|
+
|
|
548
|
+
# --- URL ---
|
|
549
|
+
url_obj = request.get("url", "")
|
|
550
|
+
path, query_params = _parse_url(url_obj, variables)
|
|
551
|
+
|
|
552
|
+
# --- Auth ---
|
|
553
|
+
request_auth: dict[str, Any] | None = request.get("auth")
|
|
554
|
+
effective_auth = request_auth if request_auth is not None else collection_auth
|
|
555
|
+
auth_schemes = _extract_auth_schemes(effective_auth)
|
|
556
|
+
|
|
557
|
+
# --- Headers ---
|
|
558
|
+
headers = _extract_headers(request, variables)
|
|
559
|
+
|
|
560
|
+
# --- Body ---
|
|
561
|
+
body_obj: dict[str, Any] | None = request.get("body")
|
|
562
|
+
request_body_schema = _build_request_body_schema(
|
|
563
|
+
body_obj, variables, warnings
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# --- Parameters ---
|
|
567
|
+
parameters: list[dict[str, Any]] = []
|
|
568
|
+
for qp_key, qp_val in query_params.items():
|
|
569
|
+
parameters.append(
|
|
570
|
+
{
|
|
571
|
+
"name": qp_key,
|
|
572
|
+
"in": "query",
|
|
573
|
+
"required": False,
|
|
574
|
+
"schema": {"type": "string"},
|
|
575
|
+
"example": qp_val,
|
|
576
|
+
}
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# --- Tags ---
|
|
580
|
+
item_tags = list(folder_tags)
|
|
581
|
+
|
|
582
|
+
# --- Build endpoint (deduplicated) ---
|
|
583
|
+
ep_key = (method, path)
|
|
584
|
+
if ep_key not in seen_endpoints:
|
|
585
|
+
seen_endpoints.add(ep_key)
|
|
586
|
+
item_name = item.get("name", "")
|
|
587
|
+
endpoint = IngestedEndpoint(
|
|
588
|
+
method=method,
|
|
589
|
+
path=path,
|
|
590
|
+
summary=item_name,
|
|
591
|
+
parameters=parameters,
|
|
592
|
+
request_body=request_body_schema,
|
|
593
|
+
auth_requirements=auth_schemes,
|
|
594
|
+
tags=item_tags,
|
|
595
|
+
metadata={
|
|
596
|
+
"generator": "postman",
|
|
597
|
+
"base_url": config_base_url or "",
|
|
598
|
+
"item_name": item_name,
|
|
599
|
+
},
|
|
600
|
+
)
|
|
601
|
+
endpoints.append(endpoint)
|
|
602
|
+
|
|
603
|
+
# --- Build test cases (always, even for duplicate method+path) ---
|
|
604
|
+
test_cases.extend(
|
|
605
|
+
_generate_test_cases(
|
|
606
|
+
item=item,
|
|
607
|
+
method=method,
|
|
608
|
+
path=path,
|
|
609
|
+
auth_schemes=auth_schemes,
|
|
610
|
+
folder_tags=folder_tags,
|
|
611
|
+
variables=variables,
|
|
612
|
+
query_params=query_params,
|
|
613
|
+
headers=headers,
|
|
614
|
+
body_obj=body_obj,
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
stats: dict[str, Any] = {
|
|
619
|
+
"endpoints_count": len(endpoints),
|
|
620
|
+
"test_cases_count": len(test_cases),
|
|
621
|
+
"collection_name": data.get("info", {}).get("name", ""),
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return IngestResult(
|
|
625
|
+
success=True,
|
|
626
|
+
source=source,
|
|
627
|
+
endpoints=endpoints,
|
|
628
|
+
test_cases=test_cases,
|
|
629
|
+
warnings=warnings,
|
|
630
|
+
stats=stats,
|
|
631
|
+
)
|