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,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
|
+
)
|