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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. 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
+ )