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,976 @@
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
+ """HAR (HTTP Archive) ingestor plugin.
7
+
8
+ Parses HAR 1.2 files and produces normalised
9
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
10
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects from
11
+ recorded HTTP traffic.
12
+
13
+ HAR files are JSON documents exported by browser dev tools, Fiddler, Charles
14
+ Proxy, and similar tools. This ingestor is especially useful for teams that
15
+ lack structured API specs — they can record real traffic and let NAT generate
16
+ security and functional test cases from the recordings.
17
+
18
+ Spec reference: http://www.softwareishard.com/blog/har-12-spec/
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from urllib.parse import urlparse, parse_qs
29
+
30
+ from mannf.product.ingestors.base import IngestorPlugin
31
+ from mannf.product.ingestors.models import (
32
+ IngestedEndpoint,
33
+ IngestedTestCase,
34
+ IngestorSource,
35
+ IngestResult,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Constants
42
+ # ---------------------------------------------------------------------------
43
+
44
+ # File extensions considered "static resources" (excluded by default)
45
+ _STATIC_EXTENSIONS: frozenset[str] = frozenset({
46
+ ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
47
+ ".woff", ".woff2", ".ttf", ".eot", ".map", ".webp", ".bmp", ".tiff",
48
+ ".pdf", ".zip", ".gz", ".tar", ".mp4", ".webm", ".mp3", ".wav",
49
+ })
50
+
51
+ # Headers that indicate authentication requirements
52
+ _AUTH_HEADERS: frozenset[str] = frozenset({
53
+ "authorization",
54
+ "cookie",
55
+ "x-api-key",
56
+ "x-auth-token",
57
+ "x-access-token",
58
+ "x-csrf-token",
59
+ "bearer",
60
+ "token",
61
+ })
62
+
63
+ # Sensitive headers to redact from stored values (keep the name but mask value)
64
+ _SENSITIVE_HEADERS: frozenset[str] = frozenset({
65
+ "authorization",
66
+ "cookie",
67
+ "x-api-key",
68
+ "x-auth-token",
69
+ "x-access-token",
70
+ "x-csrf-token",
71
+ "proxy-authorization",
72
+ "x-forwarded-for",
73
+ "set-cookie",
74
+ })
75
+
76
+ # Regex patterns for path normalisation
77
+ _RE_UUID = re.compile(
78
+ r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
79
+ re.IGNORECASE,
80
+ )
81
+ _RE_NUMERIC_ID = re.compile(r"^\d+$")
82
+
83
+ # Mutation methods
84
+ _MUTATION_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Helper functions
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def _normalise_path(path: str) -> str:
93
+ """Replace numeric IDs and UUIDs in *path* with ``{id}`` / ``{uuid}``.
94
+
95
+ For example::
96
+
97
+ /users/123 → /users/{id}
98
+ /items/abc-def-... → /items/{uuid}
99
+ /orgs/42/repos/7 → /orgs/{id}/repos/{id}
100
+
101
+ Parameters
102
+ ----------
103
+ path:
104
+ Raw URL path string.
105
+
106
+ Returns
107
+ -------
108
+ str
109
+ Normalised path template.
110
+ """
111
+ segments: list[str] = []
112
+ for segment in path.split("/"):
113
+ if not segment:
114
+ segments.append(segment)
115
+ continue
116
+ # Strip query string fragment if accidentally included
117
+ segment = segment.split("?")[0]
118
+ if _RE_UUID.fullmatch(segment):
119
+ segments.append("{uuid}")
120
+ elif _RE_NUMERIC_ID.fullmatch(segment):
121
+ segments.append("{id}")
122
+ else:
123
+ segments.append(segment)
124
+ return "/".join(segments)
125
+
126
+
127
+ def _is_static_resource(path: str) -> bool:
128
+ """Return ``True`` when *path* ends with a known static file extension.
129
+
130
+ Parameters
131
+ ----------
132
+ path:
133
+ URL path to check.
134
+
135
+ Returns
136
+ -------
137
+ bool
138
+ ``True`` if the path looks like a static asset.
139
+ """
140
+ suffix = Path(path.split("?")[0]).suffix.lower()
141
+ return suffix in _STATIC_EXTENSIONS
142
+
143
+
144
+ def _extract_auth_requirements(headers: list[dict[str, str]]) -> list[str]:
145
+ """Return a list of auth scheme names detected in *headers*.
146
+
147
+ Parameters
148
+ ----------
149
+ headers:
150
+ List of ``{"name": ..., "value": ...}`` header dicts from a HAR entry.
151
+
152
+ Returns
153
+ -------
154
+ list[str]
155
+ E.g. ``["bearer"]``, ``["apikey"]``, or ``[]``.
156
+ """
157
+ schemes: list[str] = []
158
+ for header in headers:
159
+ name_lower = header.get("name", "").lower().strip()
160
+ value = header.get("value", "")
161
+ if name_lower == "authorization":
162
+ # Try to extract scheme from value, e.g. "Bearer <token>"
163
+ parts = value.split(None, 1)
164
+ scheme = parts[0].lower() if parts else "bearer"
165
+ if scheme not in schemes:
166
+ schemes.append(scheme)
167
+ elif name_lower == "cookie":
168
+ if "cookie" not in schemes:
169
+ schemes.append("cookie")
170
+ elif name_lower in _AUTH_HEADERS:
171
+ if name_lower not in schemes:
172
+ schemes.append(name_lower)
173
+ return schemes
174
+
175
+
176
+ def _sanitise_headers(headers: list[dict[str, str]]) -> list[dict[str, str]]:
177
+ """Return *headers* with sensitive values replaced by ``"<redacted>"``.
178
+
179
+ Parameters
180
+ ----------
181
+ headers:
182
+ Raw header list from a HAR entry.
183
+
184
+ Returns
185
+ -------
186
+ list[dict[str, str]]
187
+ Headers safe to store, with sensitive values masked.
188
+ """
189
+ result: list[dict[str, str]] = []
190
+ for h in headers:
191
+ name = h.get("name", "")
192
+ value = h.get("value", "")
193
+ if name.lower().strip() in _SENSITIVE_HEADERS:
194
+ value = "<redacted>"
195
+ result.append({"name": name, "value": value})
196
+ return result
197
+
198
+
199
+ def _parse_post_data(post_data: dict[str, Any] | None) -> dict[str, Any] | None:
200
+ """Extract a request body representation from a HAR ``postData`` block.
201
+
202
+ Parameters
203
+ ----------
204
+ post_data:
205
+ The ``postData`` dict from a HAR request, or ``None``.
206
+
207
+ Returns
208
+ -------
209
+ dict | None
210
+ A structured body dict, or ``None`` if there is no body.
211
+ """
212
+ if not post_data:
213
+ return None
214
+ mime_type: str = post_data.get("mimeType", "") or ""
215
+ text: str = post_data.get("text", "") or ""
216
+ params: list[dict] = post_data.get("params", []) or []
217
+
218
+ body: dict[str, Any] = {"mimeType": mime_type}
219
+
220
+ if "application/json" in mime_type and text:
221
+ try:
222
+ body["json"] = json.loads(text)
223
+ except (json.JSONDecodeError, ValueError):
224
+ body["text"] = text
225
+ elif params:
226
+ body["params"] = [
227
+ {"name": p.get("name", ""), "value": p.get("value", "")}
228
+ for p in params
229
+ ]
230
+ elif text:
231
+ body["text"] = text
232
+
233
+ return body if (body.get("json") or body.get("text") or body.get("params")) else None
234
+
235
+
236
+ def _extract_query_params(query_string: list[dict[str, str]]) -> list[dict[str, Any]]:
237
+ """Convert a HAR ``queryString`` array to parameter dicts.
238
+
239
+ Parameters
240
+ ----------
241
+ query_string:
242
+ The ``queryString`` array from a HAR request.
243
+
244
+ Returns
245
+ -------
246
+ list[dict]
247
+ Parameter dicts with ``name``, ``in``, and ``required`` keys.
248
+ """
249
+ seen: set[str] = set()
250
+ params: list[dict[str, Any]] = []
251
+ for qs in query_string:
252
+ name = qs.get("name", "").strip()
253
+ if not name or name in seen:
254
+ continue
255
+ seen.add(name)
256
+ params.append({
257
+ "name": name,
258
+ "in": "query",
259
+ "required": False,
260
+ "value": qs.get("value", ""),
261
+ })
262
+ return params
263
+
264
+
265
+ def _extract_path_params(normalised_path: str) -> list[dict[str, Any]]:
266
+ """Extract ``{id}`` and ``{uuid}`` placeholders as path parameters.
267
+
268
+ Parameters
269
+ ----------
270
+ normalised_path:
271
+ Path template string, e.g. ``"/users/{id}/posts/{uuid}"``.
272
+
273
+ Returns
274
+ -------
275
+ list[dict]
276
+ One parameter dict per placeholder found.
277
+ """
278
+ params: list[dict[str, Any]] = []
279
+ seen: set[str] = set()
280
+ for match in re.finditer(r"\{(\w+)\}", normalised_path):
281
+ name = match.group(1)
282
+ if name not in seen:
283
+ seen.add(name)
284
+ params.append({
285
+ "name": name,
286
+ "in": "path",
287
+ "required": True,
288
+ })
289
+ return params
290
+
291
+
292
+ def _endpoint_key(method: str, normalised_path: str) -> str:
293
+ """Return a deduplication key for a method + normalised path pair.
294
+
295
+ Parameters
296
+ ----------
297
+ method:
298
+ HTTP method (uppercase).
299
+ normalised_path:
300
+ Normalised path template.
301
+
302
+ Returns
303
+ -------
304
+ str
305
+ A unique string key.
306
+ """
307
+ return f"{method.upper()}:{normalised_path}"
308
+
309
+
310
+ def _priority_for_endpoint(
311
+ method: str,
312
+ path: str,
313
+ auth_requirements: list[str],
314
+ ) -> str:
315
+ """Compute a test priority for an endpoint.
316
+
317
+ Rules (highest wins):
318
+ * Auth endpoints (path contains ``/auth``, ``/login``, ``/token``,
319
+ ``/logout``, ``/password``) → ``"critical"``
320
+ * Endpoints that require authentication → ``"high"``
321
+ * Mutation methods (POST/PUT/PATCH/DELETE) → ``"medium"``
322
+ * Everything else → ``"low"``
323
+
324
+ Parameters
325
+ ----------
326
+ method:
327
+ HTTP method.
328
+ path:
329
+ Normalised URL path.
330
+ auth_requirements:
331
+ Auth scheme list from :func:`_extract_auth_requirements`.
332
+
333
+ Returns
334
+ -------
335
+ str
336
+ One of ``"critical"``, ``"high"``, ``"medium"``, ``"low"``.
337
+ """
338
+ path_lower = path.lower()
339
+ auth_keywords = ("/auth", "/login", "/token", "/logout", "/password",
340
+ "/oauth", "/signin", "/signup", "/register")
341
+ if any(kw in path_lower for kw in auth_keywords):
342
+ return "critical"
343
+ if auth_requirements:
344
+ return "high"
345
+ if method.upper() in _MUTATION_METHODS:
346
+ return "medium"
347
+ return "low"
348
+
349
+
350
+ def _generate_test_cases(
351
+ endpoint: IngestedEndpoint,
352
+ recorded_status: int,
353
+ include_response_data: bool,
354
+ response_body: str | None,
355
+ ) -> list[IngestedTestCase]:
356
+ """Generate test cases for a single *endpoint*.
357
+
358
+ Always generates:
359
+ * A **positive / replay** test case.
360
+ * A **missing auth** negative test case when auth is required.
361
+ * A **parameter tampering** test case when path parameters exist.
362
+
363
+ Parameters
364
+ ----------
365
+ endpoint:
366
+ The endpoint to generate tests for.
367
+ recorded_status:
368
+ The HTTP status code observed in the HAR recording.
369
+ include_response_data:
370
+ If ``True`` and *response_body* is non-empty, the positive test case
371
+ includes a response body snapshot for regression comparison.
372
+ response_body:
373
+ Raw response body text, or ``None``.
374
+
375
+ Returns
376
+ -------
377
+ list[IngestedTestCase]
378
+ Generated test cases.
379
+ """
380
+ test_cases: list[IngestedTestCase] = []
381
+ method = endpoint.method
382
+ path = endpoint.path
383
+ priority = endpoint.metadata.get("priority", "medium")
384
+ source_ref = f"{method} {path}"
385
+
386
+ # --- Positive / replay test ---
387
+ positive_meta: dict[str, Any] = {
388
+ "test_type": "positive",
389
+ "recorded_status": recorded_status,
390
+ }
391
+ if include_response_data and response_body:
392
+ positive_meta["expected_response_body"] = response_body
393
+
394
+ positive_steps = [
395
+ {"action": "send_request", "method": method, "path": path},
396
+ {"action": "assert_status", "expected": recorded_status},
397
+ ]
398
+ if include_response_data and response_body:
399
+ positive_steps.append({"action": "assert_response_body", "snapshot": True})
400
+
401
+ test_cases.append(IngestedTestCase(
402
+ name=f"[{method}] {path} — positive replay",
403
+ description=(
404
+ f"Replay the recorded {method} request to {path} and expect "
405
+ f"HTTP {recorded_status}."
406
+ ),
407
+ steps=positive_steps,
408
+ expected_result=f"HTTP {recorded_status} response",
409
+ priority=priority,
410
+ tags=["har", "replay", method.lower()],
411
+ source_ref=source_ref,
412
+ metadata=positive_meta,
413
+ ))
414
+
415
+ # --- Missing auth negative test ---
416
+ if endpoint.auth_requirements:
417
+ test_cases.append(IngestedTestCase(
418
+ name=f"[{method}] {path} — missing auth",
419
+ description=(
420
+ f"Send {method} {path} without the required authentication "
421
+ f"headers and expect 401 or 403."
422
+ ),
423
+ steps=[
424
+ {"action": "send_request", "method": method, "path": path,
425
+ "omit_headers": list(endpoint.auth_requirements)},
426
+ {"action": "assert_status_in", "expected": [401, 403]},
427
+ ],
428
+ expected_result="HTTP 401 or 403 — Unauthorized / Forbidden",
429
+ priority=priority,
430
+ tags=["har", "security", "auth", method.lower()],
431
+ source_ref=source_ref,
432
+ metadata={"test_type": "security_missing_auth"},
433
+ ))
434
+
435
+ # --- Parameter tampering test ---
436
+ path_params = [p for p in endpoint.parameters if p.get("in") == "path"]
437
+ if path_params:
438
+ test_cases.append(IngestedTestCase(
439
+ name=f"[{method}] {path} — invalid path parameter",
440
+ description=(
441
+ f"Send {method} {path} with invalid/unexpected path parameter "
442
+ f"values and expect 400 or 404."
443
+ ),
444
+ steps=[
445
+ {"action": "send_request", "method": method, "path": path,
446
+ "tamper_path_params": True},
447
+ {"action": "assert_status_in", "expected": [400, 404, 422]},
448
+ ],
449
+ expected_result="HTTP 400, 404, or 422 — invalid parameter rejected",
450
+ priority=priority,
451
+ tags=["har", "security", "parameter_tampering", method.lower()],
452
+ source_ref=source_ref,
453
+ metadata={"test_type": "security_parameter_tampering"},
454
+ ))
455
+
456
+ return test_cases
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # HARIngestor
461
+ # ---------------------------------------------------------------------------
462
+
463
+
464
+ class HARIngestor(IngestorPlugin):
465
+ """Ingestor plugin for HAR (HTTP Archive) files.
466
+
467
+ Parses recorded HTTP traffic exported from browser dev tools, Fiddler,
468
+ Charles Proxy, or similar tools and produces normalised endpoint and
469
+ test-case objects.
470
+
471
+ Config keys
472
+ -----------
473
+ base_url_filter : str, optional
474
+ If set, only ingest entries whose URL starts with this prefix.
475
+ exclude_static : bool, default True
476
+ Skip entries for static assets (.js, .css, images, fonts, etc.).
477
+ include_response_data : bool, default False
478
+ Capture response bodies for regression testing.
479
+ min_status : int, optional
480
+ Only include entries with HTTP status ≥ this value.
481
+ max_status : int, optional
482
+ Only include entries with HTTP status ≤ this value.
483
+ replay_mode : bool, default False
484
+ When ``True``, produce a single ordered scenario sequence preserving
485
+ request ordering and timing (inter-request delay in milliseconds) rather
486
+ than deduplicating endpoints. The result's ``test_cases`` will contain
487
+ one :class:`~mannf.product.ingestors.models.IngestedTestCase` per
488
+ HAR entry (after filtering), grouped as an ordered browser flow scenario
489
+ compatible with ``FunctionalTestOrchestrator.run()``.
490
+ """
491
+
492
+ name = "har"
493
+ display_name = "HAR File"
494
+ description = "Ingest HTTP Archive (HAR) files from browser dev tools or proxies."
495
+ supported_extensions = [".har"]
496
+ supported_formats = ["har"]
497
+
498
+ # ------------------------------------------------------------------
499
+ # can_handle
500
+ # ------------------------------------------------------------------
501
+
502
+ def can_handle(self, source: IngestorSource) -> bool:
503
+ """Return ``True`` for HAR sources.
504
+
505
+ Detection rules (in order):
506
+ 1. ``source.format`` is ``"har"`` or ``source.metadata.get("format_hint")``
507
+ is ``"har"``.
508
+ 2. File extension is ``.har``.
509
+ 3. Content starts with ``{"log":`` or contains both ``"log"`` and
510
+ ``"entries"`` keys.
511
+
512
+ Parameters
513
+ ----------
514
+ source:
515
+ The source to evaluate.
516
+
517
+ Returns
518
+ -------
519
+ bool
520
+ ``True`` when the source appears to be a HAR document.
521
+ """
522
+ if source.format == "har":
523
+ return True
524
+ if source.metadata.get("format_hint") == "har":
525
+ return True
526
+
527
+ if source.path is not None:
528
+ ext = Path(source.path).suffix.lower()
529
+ if ext == ".har":
530
+ return True
531
+
532
+ if source.raw_content:
533
+ stripped = source.raw_content.strip()
534
+ # Fast check before trying JSON parse
535
+ if stripped.startswith('{"log"') or stripped.startswith('{ "log"'):
536
+ return True
537
+ if '"log"' in stripped and '"entries"' in stripped:
538
+ try:
539
+ data = json.loads(stripped)
540
+ return isinstance(data.get("log"), dict) and "entries" in data["log"]
541
+ except (json.JSONDecodeError, ValueError):
542
+ return False
543
+
544
+ return False
545
+
546
+ # ------------------------------------------------------------------
547
+ # validate_config
548
+ # ------------------------------------------------------------------
549
+
550
+ def validate_config(self, config: dict) -> list[str]:
551
+ """Validate HAR ingestor configuration.
552
+
553
+ Parameters
554
+ ----------
555
+ config:
556
+ Configuration dict to validate.
557
+
558
+ Returns
559
+ -------
560
+ list[str]
561
+ Human-readable error strings; empty list means valid.
562
+ """
563
+ errors: list[str] = []
564
+
565
+ if "min_status" in config:
566
+ try:
567
+ val = int(config["min_status"])
568
+ if not (100 <= val <= 599):
569
+ errors.append("min_status must be between 100 and 599.")
570
+ except (TypeError, ValueError):
571
+ errors.append("min_status must be an integer.")
572
+
573
+ if "max_status" in config:
574
+ try:
575
+ val = int(config["max_status"])
576
+ if not (100 <= val <= 599):
577
+ errors.append("max_status must be between 100 and 599.")
578
+ except (TypeError, ValueError):
579
+ errors.append("max_status must be an integer.")
580
+
581
+ if "min_status" in config and "max_status" in config:
582
+ try:
583
+ mn = int(config["min_status"])
584
+ mx = int(config["max_status"])
585
+ if mn > mx:
586
+ errors.append("min_status must not be greater than max_status.")
587
+ except (TypeError, ValueError):
588
+ pass # already reported above
589
+
590
+ if "base_url_filter" in config:
591
+ val = config["base_url_filter"]
592
+ if not isinstance(val, str) or not val.strip():
593
+ errors.append("base_url_filter must be a non-empty string.")
594
+
595
+ if "replay_mode" in config:
596
+ if not isinstance(config["replay_mode"], bool):
597
+ errors.append("replay_mode must be a boolean.")
598
+
599
+ return errors
600
+
601
+ # ------------------------------------------------------------------
602
+ # ingest
603
+ # ------------------------------------------------------------------
604
+
605
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
606
+ """Parse a HAR document and return a normalised :class:`IngestResult`.
607
+
608
+ Parameters
609
+ ----------
610
+ source:
611
+ The source to ingest. Must provide either ``raw_content`` or a
612
+ valid ``path`` to a HAR JSON file.
613
+ config:
614
+ Plugin-specific configuration. See class docstring for keys.
615
+
616
+ Returns
617
+ -------
618
+ IngestResult
619
+ Populated result on success; ``success=False`` with ``errors`` on
620
+ failure.
621
+ """
622
+ # --- Read content ---
623
+ try:
624
+ content = self._read_source(source)
625
+ except (FileNotFoundError, ValueError) as exc:
626
+ return self._make_error_result(source, str(exc))
627
+
628
+ if not content or not content.strip():
629
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
630
+
631
+ # --- Parse JSON ---
632
+ try:
633
+ data: dict[str, Any] = json.loads(content)
634
+ except (json.JSONDecodeError, ValueError) as exc:
635
+ return self._make_error_result(source, f"Failed to parse HAR JSON: {exc}")
636
+
637
+ # --- Extract log ---
638
+ log = data.get("log")
639
+ if not isinstance(log, dict):
640
+ return self._make_error_result(
641
+ source, "Invalid HAR: top-level 'log' key missing or not an object."
642
+ )
643
+
644
+ entries: list[dict[str, Any]] = log.get("entries", []) or []
645
+ if not isinstance(entries, list):
646
+ return self._make_error_result(
647
+ source, "Invalid HAR: 'log.entries' is not an array."
648
+ )
649
+
650
+ # --- Config options ---
651
+ base_url_filter: str = (config.get("base_url_filter") or "").strip()
652
+ exclude_static: bool = bool(config.get("exclude_static", True))
653
+ include_response_data: bool = bool(config.get("include_response_data", False))
654
+ replay_mode: bool = bool(config.get("replay_mode", False))
655
+ min_status: int | None = None
656
+ max_status: int | None = None
657
+ if "min_status" in config:
658
+ try:
659
+ min_status = int(config["min_status"])
660
+ except (TypeError, ValueError):
661
+ pass
662
+ if "max_status" in config:
663
+ try:
664
+ max_status = int(config["max_status"])
665
+ except (TypeError, ValueError):
666
+ pass
667
+
668
+ warnings: list[str] = []
669
+ # Track metadata per unique endpoint key
670
+ # endpoint_key → IngestedEndpoint
671
+ endpoint_map: dict[str, IngestedEndpoint] = {}
672
+ # endpoint_key → list of (status, response_body)
673
+ response_map: dict[str, list[tuple[int, str | None]]] = {}
674
+ # replay_mode: ordered list of (entry, raw_url, method, parsed, response_status, response_body)
675
+ replay_entries: list[dict[str, Any]] = []
676
+
677
+ skipped_static = 0
678
+ skipped_filter = 0
679
+ skipped_status = 0
680
+ total_entries = len(entries)
681
+
682
+ for entry in entries:
683
+ if not isinstance(entry, dict):
684
+ warnings.append("Skipped a non-object entry in log.entries.")
685
+ continue
686
+
687
+ request: dict[str, Any] = entry.get("request") or {}
688
+ response: dict[str, Any] = entry.get("response") or {}
689
+
690
+ if not isinstance(request, dict):
691
+ warnings.append("Skipped entry with non-object 'request'.")
692
+ continue
693
+
694
+ raw_url: str = request.get("url", "") or ""
695
+ method: str = (request.get("method", "GET") or "GET").upper()
696
+
697
+ if not raw_url:
698
+ warnings.append("Skipped entry with missing URL.")
699
+ continue
700
+
701
+ # --- Parse URL ---
702
+ try:
703
+ parsed = urlparse(raw_url)
704
+ except Exception: # noqa: BLE001
705
+ warnings.append(f"Skipped entry with unparseable URL: {raw_url!r}")
706
+ continue
707
+
708
+ base_url_part = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else ""
709
+ raw_path = parsed.path or "/"
710
+
711
+ # --- Apply base_url_filter ---
712
+ if base_url_filter:
713
+ if not raw_url.startswith(base_url_filter):
714
+ skipped_filter += 1
715
+ continue
716
+
717
+ # --- Apply static resource filter ---
718
+ if exclude_static and _is_static_resource(raw_path):
719
+ skipped_static += 1
720
+ continue
721
+
722
+ # --- Apply status code filter ---
723
+ response_status: int = 0
724
+ if isinstance(response, dict):
725
+ try:
726
+ response_status = int(response.get("status", 0))
727
+ except (TypeError, ValueError):
728
+ response_status = 0
729
+
730
+ if min_status is not None and response_status < min_status:
731
+ skipped_status += 1
732
+ continue
733
+ if max_status is not None and response_status > max_status:
734
+ skipped_status += 1
735
+ continue
736
+
737
+ # --- Normalise path ---
738
+ normalised_path = _normalise_path(raw_path)
739
+ key = _endpoint_key(method, normalised_path)
740
+
741
+ # --- Extract headers ---
742
+ raw_headers: list[dict] = request.get("headers", []) or []
743
+ auth_requirements = _extract_auth_requirements(raw_headers)
744
+ safe_headers = _sanitise_headers(raw_headers)
745
+
746
+ # --- Extract parameters ---
747
+ query_string: list[dict] = request.get("queryString", []) or []
748
+ query_params = _extract_query_params(query_string)
749
+ path_params = _extract_path_params(normalised_path)
750
+ all_params = path_params + query_params
751
+
752
+ # --- Extract request body ---
753
+ post_data: dict | None = request.get("postData")
754
+ request_body = _parse_post_data(post_data)
755
+
756
+ # --- Extract response content type ---
757
+ response_headers: list[dict] = []
758
+ response_content_type = ""
759
+ response_body_text: str | None = None
760
+ if isinstance(response, dict):
761
+ response_headers = response.get("headers", []) or []
762
+ for rh in response_headers:
763
+ if rh.get("name", "").lower() == "content-type":
764
+ response_content_type = rh.get("value", "")
765
+ break
766
+ if include_response_data:
767
+ content_block: dict = response.get("content") or {}
768
+ response_body_text = content_block.get("text") or None
769
+
770
+ # --- Determine priority ---
771
+ priority = _priority_for_endpoint(method, normalised_path, auth_requirements)
772
+
773
+ # --- Collect replay entry (replay_mode) ---
774
+ if replay_mode:
775
+ entry_time_ms: float = 0.0
776
+ try:
777
+ entry_time_ms = float(entry.get("time", 0) or 0)
778
+ except (TypeError, ValueError):
779
+ pass
780
+ started_at: str = entry.get("startedDateTime", "") or ""
781
+ replay_entries.append({
782
+ "url": raw_url,
783
+ "method": method,
784
+ "path": normalised_path,
785
+ "auth_requirements": auth_requirements,
786
+ "request_body": request_body,
787
+ "response_status": response_status,
788
+ "response_body": response_body_text,
789
+ "time_ms": entry_time_ms,
790
+ "started_at": started_at,
791
+ "priority": priority,
792
+ })
793
+
794
+ if key not in endpoint_map:
795
+ # Build endpoint
796
+ metadata: dict[str, Any] = {
797
+ "base_url": base_url_part,
798
+ "priority": priority,
799
+ "original_urls": [raw_url],
800
+ "content_types_observed": [],
801
+ }
802
+ if response_content_type:
803
+ metadata["content_types_observed"].append(response_content_type)
804
+
805
+ endpoint_map[key] = IngestedEndpoint(
806
+ method=method,
807
+ path=normalised_path,
808
+ summary=f"{method} {normalised_path}",
809
+ description=f"Recorded {method} request to {normalised_path}",
810
+ parameters=all_params,
811
+ request_body=request_body,
812
+ responses={str(response_status): {"description": "Recorded response"}}
813
+ if response_status else {},
814
+ auth_requirements=auth_requirements,
815
+ tags=["har", method.lower()],
816
+ metadata=metadata,
817
+ )
818
+ response_map[key] = [(response_status, response_body_text)]
819
+ else:
820
+ # Merge into existing endpoint
821
+ existing = endpoint_map[key]
822
+ if raw_url not in existing.metadata.get("original_urls", []):
823
+ existing.metadata.setdefault("original_urls", []).append(raw_url)
824
+
825
+ # Merge auth requirements
826
+ for scheme in auth_requirements:
827
+ if scheme not in existing.auth_requirements:
828
+ existing.auth_requirements.append(scheme)
829
+
830
+ # Merge content types
831
+ if response_content_type:
832
+ ct_list = existing.metadata.setdefault("content_types_observed", [])
833
+ if response_content_type not in ct_list:
834
+ ct_list.append(response_content_type)
835
+
836
+ # Merge query params
837
+ existing_param_names = {p["name"] for p in existing.parameters}
838
+ for p in query_params:
839
+ if p["name"] not in existing_param_names:
840
+ existing.parameters.append(p)
841
+ existing_param_names.add(p["name"])
842
+
843
+ # Add response status
844
+ if response_status and str(response_status) not in existing.responses:
845
+ existing.responses[str(response_status)] = {
846
+ "description": "Recorded response"
847
+ }
848
+
849
+ response_map[key].append((response_status, response_body_text))
850
+
851
+ if not endpoint_map and total_entries > 0:
852
+ filtered_count = skipped_static + skipped_filter + skipped_status
853
+ if filtered_count == total_entries:
854
+ warnings.append(
855
+ "All entries were filtered out. Check base_url_filter, "
856
+ "exclude_static, and min_status/max_status settings."
857
+ )
858
+ else:
859
+ warnings.append("No valid endpoints could be extracted from the HAR entries.")
860
+
861
+ # --- Report filter stats ---
862
+ if skipped_static:
863
+ warnings.append(
864
+ f"Skipped {skipped_static} static resource entries "
865
+ f"(exclude_static=True)."
866
+ )
867
+ if skipped_filter:
868
+ warnings.append(
869
+ f"Skipped {skipped_filter} entries that did not match "
870
+ f"base_url_filter={base_url_filter!r}."
871
+ )
872
+ if skipped_status:
873
+ warnings.append(
874
+ f"Skipped {skipped_status} entries outside the status code range."
875
+ )
876
+
877
+ # --- Generate test cases ---
878
+ endpoints = list(endpoint_map.values())
879
+ test_cases: list[IngestedTestCase] = []
880
+
881
+ if replay_mode and replay_entries:
882
+ # Build a single ordered scenario as an IngestedTestCase with one step per entry
883
+ steps: list[dict[str, Any]] = []
884
+ prev_started_at: str = ""
885
+ for idx, re_entry in enumerate(replay_entries):
886
+ # Compute inter-request delay from HAR timing metadata
887
+ delay_ms: float = 0.0
888
+ if idx > 0:
889
+ delay_ms = replay_entries[idx - 1].get("time_ms", 0.0) or 0.0
890
+
891
+ step: dict[str, Any] = {
892
+ "action": "navigate" if re_entry["method"] == "GET" else "send_request",
893
+ "method": re_entry["method"],
894
+ "url": re_entry["url"],
895
+ "selector": re_entry["url"],
896
+ "sequence_index": idx,
897
+ }
898
+ if delay_ms > 0:
899
+ step["delay_ms"] = delay_ms
900
+ if re_entry.get("request_body"):
901
+ step["body"] = re_entry["request_body"]
902
+ if re_entry.get("auth_requirements"):
903
+ step["auth_requirements"] = re_entry["auth_requirements"]
904
+ steps.append(step)
905
+
906
+ # Add assertion step for expected status
907
+ if re_entry["response_status"]:
908
+ steps.append({
909
+ "action": "assert_status",
910
+ "expected": re_entry["response_status"],
911
+ "sequence_index": idx,
912
+ })
913
+
914
+ # Determine priority from most-frequent priority value
915
+ all_priorities = [e.get("priority", "medium") for e in replay_entries]
916
+ priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
917
+ top_priority = min(all_priorities, key=lambda p: priority_order.get(p, 2))
918
+
919
+ test_cases.append(IngestedTestCase(
920
+ name="HAR replay sequence",
921
+ description=(
922
+ f"Ordered replay of {len(replay_entries)} recorded requests "
923
+ f"preserving original request sequence and timing."
924
+ ),
925
+ steps=steps,
926
+ expected_result="All recorded requests complete with their expected HTTP status codes.",
927
+ priority=top_priority,
928
+ tags=["har", "replay", "ordered_sequence"],
929
+ source_ref="har_replay",
930
+ metadata={
931
+ "test_type": "replay_sequence",
932
+ "replay_mode": True,
933
+ "entry_count": len(replay_entries),
934
+ },
935
+ ))
936
+ else:
937
+ for ep in endpoints:
938
+ key = _endpoint_key(ep.method, ep.path)
939
+ recorded_responses = response_map.get(key, [])
940
+ # Use the most common status code for the positive test
941
+ if recorded_responses:
942
+ statuses = [s for s, _ in recorded_responses if s]
943
+ recorded_status = max(set(statuses), key=statuses.count) if statuses else 200
944
+ # Use the first available response body (if captured)
945
+ resp_body = next(
946
+ (b for _, b in recorded_responses if b is not None), None
947
+ )
948
+ else:
949
+ recorded_status = 200
950
+ resp_body = None
951
+
952
+ test_cases.extend(
953
+ _generate_test_cases(ep, recorded_status, include_response_data, resp_body)
954
+ )
955
+
956
+ # --- Stats ---
957
+ stats: dict[str, Any] = {
958
+ "total_entries": total_entries,
959
+ "endpoints_count": len(endpoints),
960
+ "test_cases_count": len(test_cases),
961
+ "skipped_static": skipped_static,
962
+ "skipped_filter": skipped_filter,
963
+ "skipped_status": skipped_status,
964
+ "include_response_data": include_response_data,
965
+ "replay_mode": replay_mode,
966
+ }
967
+
968
+ return IngestResult(
969
+ success=True,
970
+ source=source,
971
+ endpoints=endpoints,
972
+ test_cases=test_cases,
973
+ errors=[],
974
+ warnings=warnings,
975
+ stats=stats,
976
+ )