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,679 @@
|
|
|
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
|
+
"""Production traffic ingestor plugin.
|
|
7
|
+
|
|
8
|
+
Parses anonymized production traffic captures from HAR files and
|
|
9
|
+
mitmproxy flow dumps (JSON or native format) and produces
|
|
10
|
+
:class:`~mannf.product.ingestors.models.IngestedEndpoint` and
|
|
11
|
+
:class:`~mannf.product.ingestors.models.IngestedTestCase` objects for
|
|
12
|
+
seeding NAT's autonomous test loop with real traffic patterns.
|
|
13
|
+
|
|
14
|
+
Supported formats
|
|
15
|
+
-----------------
|
|
16
|
+
* HAR files (``"log"``/``"entries"`` structure) — the same format as
|
|
17
|
+
:class:`~mannf.product.ingestors.har_ingestor.HARIngestor` but with
|
|
18
|
+
production-traffic-specific sampling and anonymization support.
|
|
19
|
+
* mitmproxy JSON dumps (``mitmproxy --save-stream-file`` / ``mitmdump -w``
|
|
20
|
+
output converted to JSON via ``mitmproxy``) — a list of flow dicts with
|
|
21
|
+
``"type": "http"``, ``"request"``, and ``"response"`` keys.
|
|
22
|
+
* ``.mitmproxy`` / ``.pcap`` path extensions are accepted as format hints
|
|
23
|
+
(PCAP is treated as mitmproxy-compatible after conversion).
|
|
24
|
+
|
|
25
|
+
Config keys
|
|
26
|
+
-----------
|
|
27
|
+
base_url_filter : str, optional
|
|
28
|
+
If set, only ingest traffic entries whose URL starts with this prefix.
|
|
29
|
+
Useful for isolating a single service in mixed prod captures.
|
|
30
|
+
exclude_static : bool, default True
|
|
31
|
+
Skip static asset requests (.js, .css, images, fonts, etc.).
|
|
32
|
+
include_response_data : bool, default False
|
|
33
|
+
Capture response bodies for regression testing.
|
|
34
|
+
sample_rate : float, default 1.0
|
|
35
|
+
Fraction of entries to include (0.0–1.0). Useful for very large
|
|
36
|
+
captures where full ingestion would be too slow.
|
|
37
|
+
min_status : int, optional
|
|
38
|
+
Only include entries with HTTP status ≥ this value.
|
|
39
|
+
max_status : int, optional
|
|
40
|
+
Only include entries with HTTP status ≤ this value.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import re
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
from urllib.parse import urlparse
|
|
51
|
+
|
|
52
|
+
from mannf.product.ingestors.base import IngestorPlugin
|
|
53
|
+
from mannf.product.ingestors.models import (
|
|
54
|
+
IngestedEndpoint,
|
|
55
|
+
IngestedTestCase,
|
|
56
|
+
IngestorSource,
|
|
57
|
+
IngestResult,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Constants
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
_STATIC_EXTENSIONS: frozenset[str] = frozenset({
|
|
67
|
+
".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
68
|
+
".woff", ".woff2", ".ttf", ".eot", ".map", ".webp", ".bmp", ".tiff",
|
|
69
|
+
".pdf", ".zip", ".gz", ".tar", ".mp4", ".webm", ".mp3", ".wav",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
_AUTH_HEADERS: frozenset[str] = frozenset({
|
|
73
|
+
"authorization",
|
|
74
|
+
"cookie",
|
|
75
|
+
"x-api-key",
|
|
76
|
+
"x-auth-token",
|
|
77
|
+
"x-access-token",
|
|
78
|
+
"x-csrf-token",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
_SENSITIVE_HEADERS: frozenset[str] = frozenset({
|
|
82
|
+
"authorization",
|
|
83
|
+
"cookie",
|
|
84
|
+
"x-api-key",
|
|
85
|
+
"x-auth-token",
|
|
86
|
+
"x-access-token",
|
|
87
|
+
"x-csrf-token",
|
|
88
|
+
"proxy-authorization",
|
|
89
|
+
"set-cookie",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
_RE_UUID = re.compile(
|
|
93
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
94
|
+
re.IGNORECASE,
|
|
95
|
+
)
|
|
96
|
+
_RE_NUMERIC_ID = re.compile(r"^\d+$")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Helper functions
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _normalise_path(path: str) -> str:
|
|
105
|
+
"""Replace numeric IDs and UUIDs in *path* with ``{id}`` / ``{uuid}``."""
|
|
106
|
+
path = _RE_UUID.sub("{uuid}", path)
|
|
107
|
+
parts = path.split("/")
|
|
108
|
+
normalised = []
|
|
109
|
+
for part in parts:
|
|
110
|
+
if _RE_NUMERIC_ID.match(part):
|
|
111
|
+
normalised.append("{id}")
|
|
112
|
+
else:
|
|
113
|
+
normalised.append(part)
|
|
114
|
+
return "/".join(normalised)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_static_resource(path: str) -> bool:
|
|
118
|
+
"""Return ``True`` if *path* looks like a static asset."""
|
|
119
|
+
suffix = Path(path).suffix.lower()
|
|
120
|
+
return suffix in _STATIC_EXTENSIONS
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _endpoint_key(method: str, path: str) -> str:
|
|
124
|
+
return f"{method.upper()}:{path}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_auth_requirements(headers: list[dict]) -> list[str]:
|
|
128
|
+
found: list[str] = []
|
|
129
|
+
for h in headers:
|
|
130
|
+
name = str(h.get("name", "")).lower()
|
|
131
|
+
if name in _AUTH_HEADERS and name not in found:
|
|
132
|
+
found.append(name)
|
|
133
|
+
return found
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _sanitise_headers(headers: list[dict]) -> list[dict]:
|
|
137
|
+
result: list[dict] = []
|
|
138
|
+
for h in headers:
|
|
139
|
+
name = str(h.get("name", ""))
|
|
140
|
+
if name.lower() in _SENSITIVE_HEADERS:
|
|
141
|
+
result.append({"name": name, "value": "[REDACTED]"})
|
|
142
|
+
else:
|
|
143
|
+
result.append(h)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Parsers for different traffic dump formats
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_har_entries(data: dict) -> list[dict[str, Any]]:
|
|
153
|
+
"""Extract a flat list of normalised entry dicts from a HAR document."""
|
|
154
|
+
log = data.get("log")
|
|
155
|
+
if not isinstance(log, dict):
|
|
156
|
+
return []
|
|
157
|
+
entries = log.get("entries", []) or []
|
|
158
|
+
if not isinstance(entries, list):
|
|
159
|
+
return []
|
|
160
|
+
return entries
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_mitmproxy_entries(data: list) -> list[dict[str, Any]]:
|
|
164
|
+
"""Convert a mitmproxy JSON flow list to HAR-compatible entry dicts.
|
|
165
|
+
|
|
166
|
+
mitmproxy JSON flows look like::
|
|
167
|
+
|
|
168
|
+
[
|
|
169
|
+
{
|
|
170
|
+
"type": "http",
|
|
171
|
+
"request": {
|
|
172
|
+
"method": "GET",
|
|
173
|
+
"url": "https://example.com/api",
|
|
174
|
+
"headers": [["Content-Type", "application/json"]],
|
|
175
|
+
"content": ""
|
|
176
|
+
},
|
|
177
|
+
"response": {
|
|
178
|
+
"status_code": 200,
|
|
179
|
+
"headers": [["Content-Type", "application/json"]],
|
|
180
|
+
"content": "{}"
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
...
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
Headers may be a list-of-pairs or a dict.
|
|
187
|
+
"""
|
|
188
|
+
entries: list[dict[str, Any]] = []
|
|
189
|
+
for flow in data:
|
|
190
|
+
if not isinstance(flow, dict):
|
|
191
|
+
continue
|
|
192
|
+
if flow.get("type") not in ("http", None) and "request" not in flow:
|
|
193
|
+
continue
|
|
194
|
+
req = flow.get("request") or {}
|
|
195
|
+
resp = flow.get("response") or {}
|
|
196
|
+
if not isinstance(req, dict):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
url = req.get("url") or req.get("pretty_url") or ""
|
|
200
|
+
method = req.get("method", "GET") or "GET"
|
|
201
|
+
|
|
202
|
+
# Normalise headers: accept list-of-pairs or list-of-dicts
|
|
203
|
+
raw_req_headers = req.get("headers") or []
|
|
204
|
+
norm_req_headers: list[dict] = []
|
|
205
|
+
if isinstance(raw_req_headers, dict):
|
|
206
|
+
norm_req_headers = [{"name": k, "value": v} for k, v in raw_req_headers.items()]
|
|
207
|
+
elif isinstance(raw_req_headers, list):
|
|
208
|
+
for item in raw_req_headers:
|
|
209
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
210
|
+
norm_req_headers.append({"name": item[0], "value": item[1]})
|
|
211
|
+
elif isinstance(item, dict):
|
|
212
|
+
norm_req_headers.append(item)
|
|
213
|
+
|
|
214
|
+
raw_resp_headers = resp.get("headers") or []
|
|
215
|
+
norm_resp_headers: list[dict] = []
|
|
216
|
+
if isinstance(raw_resp_headers, dict):
|
|
217
|
+
norm_resp_headers = [{"name": k, "value": v} for k, v in raw_resp_headers.items()]
|
|
218
|
+
elif isinstance(raw_resp_headers, list):
|
|
219
|
+
for item in raw_resp_headers:
|
|
220
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
221
|
+
norm_resp_headers.append({"name": item[0], "value": item[1]})
|
|
222
|
+
elif isinstance(item, dict):
|
|
223
|
+
norm_resp_headers.append(item)
|
|
224
|
+
|
|
225
|
+
status_code = resp.get("status_code") or resp.get("status") or 0
|
|
226
|
+
try:
|
|
227
|
+
status_code = int(status_code)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
status_code = 0
|
|
230
|
+
|
|
231
|
+
resp_content_text = resp.get("content") or resp.get("text") or ""
|
|
232
|
+
if isinstance(resp_content_text, bytes):
|
|
233
|
+
resp_content_text = resp_content_text.decode("utf-8", errors="replace")
|
|
234
|
+
|
|
235
|
+
req_content = req.get("content") or req.get("text") or req.get("body") or ""
|
|
236
|
+
if isinstance(req_content, bytes):
|
|
237
|
+
req_content = req_content.decode("utf-8", errors="replace")
|
|
238
|
+
|
|
239
|
+
post_data: dict | None = None
|
|
240
|
+
if req_content:
|
|
241
|
+
content_type = ""
|
|
242
|
+
for h in norm_req_headers:
|
|
243
|
+
if h.get("name", "").lower() == "content-type":
|
|
244
|
+
content_type = h.get("value", "")
|
|
245
|
+
break
|
|
246
|
+
post_data = {"mimeType": content_type, "text": req_content}
|
|
247
|
+
|
|
248
|
+
entry: dict[str, Any] = {
|
|
249
|
+
"startedDateTime": flow.get("timestamp_start") or "",
|
|
250
|
+
"time": flow.get("timestamp_end", 0),
|
|
251
|
+
"request": {
|
|
252
|
+
"method": method.upper(),
|
|
253
|
+
"url": url,
|
|
254
|
+
"headers": norm_req_headers,
|
|
255
|
+
"queryString": [],
|
|
256
|
+
"postData": post_data,
|
|
257
|
+
},
|
|
258
|
+
"response": {
|
|
259
|
+
"status": status_code,
|
|
260
|
+
"headers": norm_resp_headers,
|
|
261
|
+
"content": {"text": resp_content_text, "mimeType": ""},
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
entries.append(entry)
|
|
265
|
+
|
|
266
|
+
return entries
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# TrafficIngestor
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class TrafficIngestor(IngestorPlugin):
|
|
275
|
+
"""Ingestor plugin for anonymized production traffic captures.
|
|
276
|
+
|
|
277
|
+
Parses HAR files and mitmproxy JSON dumps containing real production
|
|
278
|
+
traffic and converts them into scenario seeds for NAT's autonomous
|
|
279
|
+
test loop. This enables zero-cold-start testing: import production
|
|
280
|
+
traffic and immediately seed the autonomous loop with realistic flows.
|
|
281
|
+
|
|
282
|
+
Format detection
|
|
283
|
+
----------------
|
|
284
|
+
* ``.mitmproxy`` extension → treated as mitmproxy JSON
|
|
285
|
+
* ``.pcap`` extension → treated as mitmproxy JSON (post-conversion)
|
|
286
|
+
* ``format="traffic"`` → auto-detect between HAR and mitmproxy based
|
|
287
|
+
on content structure
|
|
288
|
+
* HAR content (``{"log": ...}`` structure) → parsed as HAR
|
|
289
|
+
* JSON list of flow dicts → parsed as mitmproxy
|
|
290
|
+
|
|
291
|
+
Config keys
|
|
292
|
+
-----------
|
|
293
|
+
base_url_filter : str, optional
|
|
294
|
+
Only include entries whose URL starts with this prefix.
|
|
295
|
+
exclude_static : bool, default True
|
|
296
|
+
Skip static asset requests.
|
|
297
|
+
include_response_data : bool, default False
|
|
298
|
+
Capture response bodies.
|
|
299
|
+
sample_rate : float, default 1.0
|
|
300
|
+
Fraction of entries to include (0.0–1.0).
|
|
301
|
+
min_status : int, optional
|
|
302
|
+
Only include entries with HTTP status ≥ this value.
|
|
303
|
+
max_status : int, optional
|
|
304
|
+
Only include entries with HTTP status ≤ this value.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
name = "traffic"
|
|
308
|
+
display_name = "Production Traffic"
|
|
309
|
+
description = (
|
|
310
|
+
"Ingest anonymized production traffic from HAR files or mitmproxy captures."
|
|
311
|
+
)
|
|
312
|
+
supported_extensions = [".mitmproxy", ".pcap"]
|
|
313
|
+
supported_formats = ["traffic", "mitmproxy", "pcap"]
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# can_handle
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def can_handle(self, source: IngestorSource) -> bool:
|
|
320
|
+
"""Return ``True`` for traffic/mitmproxy sources."""
|
|
321
|
+
if source.format in self.supported_formats:
|
|
322
|
+
return True
|
|
323
|
+
if source.metadata.get("format_hint") in self.supported_formats:
|
|
324
|
+
return True
|
|
325
|
+
if source.path is not None:
|
|
326
|
+
ext = Path(source.path).suffix.lower()
|
|
327
|
+
if ext in self.supported_extensions:
|
|
328
|
+
return True
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
# ------------------------------------------------------------------
|
|
332
|
+
# validate_config
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
336
|
+
"""Validate traffic ingestor configuration."""
|
|
337
|
+
errors: list[str] = []
|
|
338
|
+
|
|
339
|
+
if "min_status" in config:
|
|
340
|
+
try:
|
|
341
|
+
val = int(config["min_status"])
|
|
342
|
+
if not (100 <= val <= 599):
|
|
343
|
+
errors.append("min_status must be between 100 and 599.")
|
|
344
|
+
except (TypeError, ValueError):
|
|
345
|
+
errors.append("min_status must be an integer.")
|
|
346
|
+
|
|
347
|
+
if "max_status" in config:
|
|
348
|
+
try:
|
|
349
|
+
val = int(config["max_status"])
|
|
350
|
+
if not (100 <= val <= 599):
|
|
351
|
+
errors.append("max_status must be between 100 and 599.")
|
|
352
|
+
except (TypeError, ValueError):
|
|
353
|
+
errors.append("max_status must be an integer.")
|
|
354
|
+
|
|
355
|
+
if "min_status" in config and "max_status" in config:
|
|
356
|
+
try:
|
|
357
|
+
mn = int(config["min_status"])
|
|
358
|
+
mx = int(config["max_status"])
|
|
359
|
+
if mn > mx:
|
|
360
|
+
errors.append("min_status must not be greater than max_status.")
|
|
361
|
+
except (TypeError, ValueError):
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
if "base_url_filter" in config:
|
|
365
|
+
val = config["base_url_filter"]
|
|
366
|
+
if not isinstance(val, str) or not val.strip():
|
|
367
|
+
errors.append("base_url_filter must be a non-empty string.")
|
|
368
|
+
|
|
369
|
+
if "sample_rate" in config:
|
|
370
|
+
try:
|
|
371
|
+
val = float(config["sample_rate"])
|
|
372
|
+
if not (0.0 < val <= 1.0):
|
|
373
|
+
errors.append("sample_rate must be between 0.0 (exclusive) and 1.0.")
|
|
374
|
+
except (TypeError, ValueError):
|
|
375
|
+
errors.append("sample_rate must be a number between 0.0 and 1.0.")
|
|
376
|
+
|
|
377
|
+
return errors
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# ingest
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
|
|
384
|
+
"""Parse a production traffic capture and return a normalised :class:`IngestResult`.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
source:
|
|
389
|
+
The source to ingest. Must provide either ``raw_content`` or a
|
|
390
|
+
valid ``path`` to a HAR or mitmproxy JSON file.
|
|
391
|
+
config:
|
|
392
|
+
Plugin-specific configuration. See class docstring for keys.
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
IngestResult
|
|
397
|
+
Populated result on success; ``success=False`` with ``errors`` on
|
|
398
|
+
failure.
|
|
399
|
+
"""
|
|
400
|
+
# --- Read content ---
|
|
401
|
+
try:
|
|
402
|
+
content = self._read_source(source)
|
|
403
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
404
|
+
return self._make_error_result(source, str(exc))
|
|
405
|
+
|
|
406
|
+
if not content or not content.strip():
|
|
407
|
+
return self._make_error_result(source, "Empty content — nothing to ingest.")
|
|
408
|
+
|
|
409
|
+
# --- Parse JSON ---
|
|
410
|
+
try:
|
|
411
|
+
data: Any = json.loads(content)
|
|
412
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
413
|
+
return self._make_error_result(source, f"Failed to parse traffic JSON: {exc}")
|
|
414
|
+
|
|
415
|
+
# --- Determine format and extract entries ---
|
|
416
|
+
entries: list[dict[str, Any]] = []
|
|
417
|
+
detected_format = "unknown"
|
|
418
|
+
|
|
419
|
+
if isinstance(data, dict) and "log" in data:
|
|
420
|
+
# HAR format
|
|
421
|
+
entries = _parse_har_entries(data)
|
|
422
|
+
detected_format = "har"
|
|
423
|
+
elif isinstance(data, list):
|
|
424
|
+
# mitmproxy JSON list of flows
|
|
425
|
+
entries = _parse_mitmproxy_entries(data)
|
|
426
|
+
detected_format = "mitmproxy"
|
|
427
|
+
else:
|
|
428
|
+
return self._make_error_result(
|
|
429
|
+
source,
|
|
430
|
+
"Unrecognized traffic format. Expected HAR (object with 'log' key) "
|
|
431
|
+
"or mitmproxy JSON (array of flow objects).",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if not entries:
|
|
435
|
+
return self._make_error_result(
|
|
436
|
+
source,
|
|
437
|
+
f"No traffic entries found in {detected_format} capture.",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# --- Config options ---
|
|
441
|
+
base_url_filter: str = (config.get("base_url_filter") or "").strip()
|
|
442
|
+
exclude_static: bool = bool(config.get("exclude_static", True))
|
|
443
|
+
include_response_data: bool = bool(config.get("include_response_data", False))
|
|
444
|
+
min_status: int | None = None
|
|
445
|
+
max_status: int | None = None
|
|
446
|
+
if "min_status" in config:
|
|
447
|
+
try:
|
|
448
|
+
min_status = int(config["min_status"])
|
|
449
|
+
except (TypeError, ValueError):
|
|
450
|
+
pass
|
|
451
|
+
if "max_status" in config:
|
|
452
|
+
try:
|
|
453
|
+
max_status = int(config["max_status"])
|
|
454
|
+
except (TypeError, ValueError):
|
|
455
|
+
pass
|
|
456
|
+
sample_rate: float = 1.0
|
|
457
|
+
if "sample_rate" in config:
|
|
458
|
+
try:
|
|
459
|
+
sample_rate = float(config["sample_rate"])
|
|
460
|
+
except (TypeError, ValueError):
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
# --- Apply sampling ---
|
|
464
|
+
if 0.0 < sample_rate < 1.0:
|
|
465
|
+
import math
|
|
466
|
+
keep_count = max(1, math.ceil(len(entries) * sample_rate))
|
|
467
|
+
# Systematic sampling to preserve temporal distribution
|
|
468
|
+
step = len(entries) / keep_count
|
|
469
|
+
entries = [entries[int(i * step)] for i in range(keep_count)]
|
|
470
|
+
|
|
471
|
+
warnings: list[str] = []
|
|
472
|
+
endpoint_map: dict[str, IngestedEndpoint] = {}
|
|
473
|
+
response_map: dict[str, list[tuple[int, str | None]]] = {}
|
|
474
|
+
|
|
475
|
+
skipped_static = 0
|
|
476
|
+
skipped_filter = 0
|
|
477
|
+
skipped_status = 0
|
|
478
|
+
total_entries = len(entries)
|
|
479
|
+
|
|
480
|
+
for entry in entries:
|
|
481
|
+
if not isinstance(entry, dict):
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
request: dict[str, Any] = entry.get("request") or {}
|
|
485
|
+
response: dict[str, Any] = entry.get("response") or {}
|
|
486
|
+
|
|
487
|
+
if not isinstance(request, dict):
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
raw_url: str = request.get("url", "") or ""
|
|
491
|
+
method: str = (request.get("method", "GET") or "GET").upper()
|
|
492
|
+
|
|
493
|
+
if not raw_url:
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
parsed = urlparse(raw_url)
|
|
498
|
+
except Exception: # noqa: BLE001
|
|
499
|
+
warnings.append(f"Skipped entry with unparseable URL: {raw_url!r}")
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
base_url_part = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else ""
|
|
503
|
+
raw_path = parsed.path or "/"
|
|
504
|
+
|
|
505
|
+
if base_url_filter and not raw_url.startswith(base_url_filter):
|
|
506
|
+
skipped_filter += 1
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
if exclude_static and _is_static_resource(raw_path):
|
|
510
|
+
skipped_static += 1
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
response_status: int = 0
|
|
514
|
+
if isinstance(response, dict):
|
|
515
|
+
try:
|
|
516
|
+
response_status = int(
|
|
517
|
+
response.get("status") or response.get("status_code") or 0
|
|
518
|
+
)
|
|
519
|
+
except (TypeError, ValueError):
|
|
520
|
+
response_status = 0
|
|
521
|
+
|
|
522
|
+
if min_status is not None and response_status < min_status:
|
|
523
|
+
skipped_status += 1
|
|
524
|
+
continue
|
|
525
|
+
if max_status is not None and response_status > max_status:
|
|
526
|
+
skipped_status += 1
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
normalised_path = _normalise_path(raw_path)
|
|
530
|
+
key = _endpoint_key(method, normalised_path)
|
|
531
|
+
|
|
532
|
+
raw_headers: list[dict] = request.get("headers", []) or []
|
|
533
|
+
auth_requirements = _extract_auth_requirements(raw_headers)
|
|
534
|
+
|
|
535
|
+
response_content_type = ""
|
|
536
|
+
response_body_text: str | None = None
|
|
537
|
+
if isinstance(response, dict):
|
|
538
|
+
resp_headers = response.get("headers", []) or []
|
|
539
|
+
for rh in resp_headers:
|
|
540
|
+
if rh.get("name", "").lower() == "content-type":
|
|
541
|
+
response_content_type = rh.get("value", "")
|
|
542
|
+
break
|
|
543
|
+
if include_response_data:
|
|
544
|
+
content_block: dict = response.get("content") or {}
|
|
545
|
+
response_body_text = (
|
|
546
|
+
content_block.get("text")
|
|
547
|
+
or response.get("text")
|
|
548
|
+
or None
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if key not in endpoint_map:
|
|
552
|
+
metadata: dict[str, Any] = {
|
|
553
|
+
"base_url": base_url_part,
|
|
554
|
+
"original_urls": [raw_url],
|
|
555
|
+
"content_types_observed": [],
|
|
556
|
+
"traffic_source": detected_format,
|
|
557
|
+
}
|
|
558
|
+
if response_content_type:
|
|
559
|
+
metadata["content_types_observed"].append(response_content_type)
|
|
560
|
+
|
|
561
|
+
endpoint_map[key] = IngestedEndpoint(
|
|
562
|
+
method=method,
|
|
563
|
+
path=normalised_path,
|
|
564
|
+
summary=f"{method} {normalised_path}",
|
|
565
|
+
description=f"Production traffic: {method} {normalised_path}",
|
|
566
|
+
request_body=request.get("postData"),
|
|
567
|
+
responses={str(response_status): {"description": "Observed response"}}
|
|
568
|
+
if response_status else {},
|
|
569
|
+
auth_requirements=auth_requirements,
|
|
570
|
+
tags=["traffic", detected_format, method.lower()],
|
|
571
|
+
metadata=metadata,
|
|
572
|
+
)
|
|
573
|
+
response_map[key] = [(response_status, response_body_text)]
|
|
574
|
+
else:
|
|
575
|
+
existing = endpoint_map[key]
|
|
576
|
+
if raw_url not in existing.metadata.get("original_urls", []):
|
|
577
|
+
existing.metadata.setdefault("original_urls", []).append(raw_url)
|
|
578
|
+
for scheme in auth_requirements:
|
|
579
|
+
if scheme not in existing.auth_requirements:
|
|
580
|
+
existing.auth_requirements.append(scheme)
|
|
581
|
+
if response_content_type:
|
|
582
|
+
ct_list = existing.metadata.setdefault("content_types_observed", [])
|
|
583
|
+
if response_content_type not in ct_list:
|
|
584
|
+
ct_list.append(response_content_type)
|
|
585
|
+
if response_status and str(response_status) not in existing.responses:
|
|
586
|
+
existing.responses[str(response_status)] = {
|
|
587
|
+
"description": "Observed response"
|
|
588
|
+
}
|
|
589
|
+
response_map[key].append((response_status, response_body_text))
|
|
590
|
+
|
|
591
|
+
if not endpoint_map and total_entries > 0:
|
|
592
|
+
filtered_count = skipped_static + skipped_filter + skipped_status
|
|
593
|
+
if filtered_count == total_entries:
|
|
594
|
+
warnings.append(
|
|
595
|
+
"All entries were filtered out. Check base_url_filter, "
|
|
596
|
+
"exclude_static, and min_status/max_status settings."
|
|
597
|
+
)
|
|
598
|
+
else:
|
|
599
|
+
warnings.append(
|
|
600
|
+
"No valid endpoints could be extracted from the traffic capture."
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if skipped_static:
|
|
604
|
+
warnings.append(
|
|
605
|
+
f"Skipped {skipped_static} static resource entries (exclude_static=True)."
|
|
606
|
+
)
|
|
607
|
+
if skipped_filter:
|
|
608
|
+
warnings.append(
|
|
609
|
+
f"Skipped {skipped_filter} entries that did not match "
|
|
610
|
+
f"base_url_filter={base_url_filter!r}."
|
|
611
|
+
)
|
|
612
|
+
if skipped_status:
|
|
613
|
+
warnings.append(
|
|
614
|
+
f"Skipped {skipped_status} entries outside the status code range."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
endpoints = list(endpoint_map.values())
|
|
618
|
+
test_cases: list[IngestedTestCase] = []
|
|
619
|
+
|
|
620
|
+
for ep in endpoints:
|
|
621
|
+
ep_key = _endpoint_key(ep.method, ep.path)
|
|
622
|
+
recorded_responses = response_map.get(ep_key, [])
|
|
623
|
+
if recorded_responses:
|
|
624
|
+
statuses = [s for s, _ in recorded_responses if s]
|
|
625
|
+
recorded_status = max(set(statuses), key=statuses.count) if statuses else 200
|
|
626
|
+
resp_body = next(
|
|
627
|
+
(b for _, b in recorded_responses if b is not None), None
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
recorded_status = 200
|
|
631
|
+
resp_body = None
|
|
632
|
+
|
|
633
|
+
source_ref = f"{ep.method} {ep.path}"
|
|
634
|
+
steps: list[dict] = [
|
|
635
|
+
{"action": "send_request", "method": ep.method, "path": ep.path},
|
|
636
|
+
{"action": "assert_status", "expected": recorded_status},
|
|
637
|
+
]
|
|
638
|
+
if include_response_data and resp_body:
|
|
639
|
+
steps.append({"action": "assert_response_body", "snapshot": True})
|
|
640
|
+
|
|
641
|
+
test_cases.append(IngestedTestCase(
|
|
642
|
+
name=f"[{ep.method}] {ep.path} — production replay",
|
|
643
|
+
description=(
|
|
644
|
+
f"Replay of observed production {ep.method} request to {ep.path}. "
|
|
645
|
+
f"Expected status: {recorded_status}."
|
|
646
|
+
),
|
|
647
|
+
steps=steps,
|
|
648
|
+
expected_result=f"HTTP {recorded_status} response",
|
|
649
|
+
priority="medium",
|
|
650
|
+
tags=["traffic", detected_format, "replay", ep.method.lower()],
|
|
651
|
+
source_ref=source_ref,
|
|
652
|
+
metadata={
|
|
653
|
+
"test_type": "traffic_replay",
|
|
654
|
+
"traffic_source": detected_format,
|
|
655
|
+
"recorded_status": recorded_status,
|
|
656
|
+
},
|
|
657
|
+
))
|
|
658
|
+
|
|
659
|
+
stats: dict[str, Any] = {
|
|
660
|
+
"total_entries": total_entries,
|
|
661
|
+
"endpoints_count": len(endpoints),
|
|
662
|
+
"test_cases_count": len(test_cases),
|
|
663
|
+
"skipped_static": skipped_static,
|
|
664
|
+
"skipped_filter": skipped_filter,
|
|
665
|
+
"skipped_status": skipped_status,
|
|
666
|
+
"include_response_data": include_response_data,
|
|
667
|
+
"detected_format": detected_format,
|
|
668
|
+
"sample_rate": sample_rate,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return IngestResult(
|
|
672
|
+
success=True,
|
|
673
|
+
source=source,
|
|
674
|
+
endpoints=endpoints,
|
|
675
|
+
test_cases=test_cases,
|
|
676
|
+
errors=[],
|
|
677
|
+
warnings=warnings,
|
|
678
|
+
stats=stats,
|
|
679
|
+
)
|