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,344 @@
|
|
|
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
|
+
"""Mock webhook receiver for event-driven / async API testing.
|
|
7
|
+
|
|
8
|
+
:class:`WebhookReceiver` spins up a lightweight HTTP server that listens for
|
|
9
|
+
incoming webhook payloads. Test cases can:
|
|
10
|
+
|
|
11
|
+
1. Register a webhook URL by starting the receiver.
|
|
12
|
+
2. Trigger the external system (via the API under test or a separate trigger).
|
|
13
|
+
3. Wait for the expected payload to arrive.
|
|
14
|
+
4. Assert its contents and validate the schema.
|
|
15
|
+
|
|
16
|
+
This enables autonomous testing of outbound notification flows (GitHub webhooks,
|
|
17
|
+
Stripe events, Slack messages, etc.) without requiring a publicly-routable
|
|
18
|
+
server — the receiver binds to a local port only.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
receiver = WebhookReceiver(port=8765)
|
|
23
|
+
await receiver.start()
|
|
24
|
+
# ... trigger the webhook ...
|
|
25
|
+
payload = await receiver.wait_for_payload(timeout=10.0)
|
|
26
|
+
await receiver.stop()
|
|
27
|
+
|
|
28
|
+
The receiver also implements the standard ``execute()`` / ``health_check()``
|
|
29
|
+
interface for integration with the NATOrchestrator.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
40
|
+
|
|
41
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
42
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest
|
|
43
|
+
from mannf.core.testing.models import TestCase, TestResult
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _WebhookHandler:
|
|
49
|
+
"""Minimal ASGI-compatible handler that collects incoming webhook payloads."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._payloads: asyncio.Queue = asyncio.Queue()
|
|
53
|
+
self._request_count: int = 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def request_count(self) -> int:
|
|
57
|
+
return self._request_count
|
|
58
|
+
|
|
59
|
+
async def handle(
|
|
60
|
+
self, method: str, path: str, headers: dict, body: bytes
|
|
61
|
+
) -> tuple[int, dict, bytes]:
|
|
62
|
+
"""Process a single incoming HTTP request. Returns (status, headers, body)."""
|
|
63
|
+
self._request_count += 1
|
|
64
|
+
|
|
65
|
+
content_type = (headers.get("content-type") or "").lower()
|
|
66
|
+
payload: Any
|
|
67
|
+
if "json" in content_type:
|
|
68
|
+
try:
|
|
69
|
+
payload = json.loads(body.decode("utf-8"))
|
|
70
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
71
|
+
payload = body.decode("utf-8", errors="replace")
|
|
72
|
+
else:
|
|
73
|
+
payload = body.decode("utf-8", errors="replace")
|
|
74
|
+
|
|
75
|
+
await self._payloads.put({
|
|
76
|
+
"method": method,
|
|
77
|
+
"path": path,
|
|
78
|
+
"headers": dict(headers),
|
|
79
|
+
"payload": payload,
|
|
80
|
+
"received_at": time.time(),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return 200, {"Content-Type": "application/json"}, b'{"ok": true}'
|
|
84
|
+
|
|
85
|
+
async def get_payload(self, timeout: float = 10.0) -> Optional[dict]:
|
|
86
|
+
"""Wait up to *timeout* seconds for the next payload and return it."""
|
|
87
|
+
try:
|
|
88
|
+
return await asyncio.wait_for(self._payloads.get(), timeout=timeout)
|
|
89
|
+
except asyncio.TimeoutError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def get_all_payloads(self) -> list[dict]:
|
|
93
|
+
"""Return all payloads received so far without blocking."""
|
|
94
|
+
results: list[dict] = []
|
|
95
|
+
try:
|
|
96
|
+
while True:
|
|
97
|
+
results.append(self._payloads.get_nowait())
|
|
98
|
+
except asyncio.QueueEmpty:
|
|
99
|
+
pass
|
|
100
|
+
return results
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WebhookReceiver(SystemUnderTest):
|
|
104
|
+
"""Mock HTTP server that captures and validates incoming webhook payloads.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
port:
|
|
109
|
+
Local port to listen on (default: 0 = OS-assigned).
|
|
110
|
+
host:
|
|
111
|
+
Interface to bind to (default: ``"127.0.0.1"``).
|
|
112
|
+
path_filter:
|
|
113
|
+
Only capture payloads whose path matches this prefix (default: all).
|
|
114
|
+
validators:
|
|
115
|
+
List of callables ``(payload: dict) -> bool`` run against each
|
|
116
|
+
received payload. Used to assert schema or content correctness.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
port: int = 0,
|
|
122
|
+
host: str = "127.0.0.1",
|
|
123
|
+
path_filter: str = "",
|
|
124
|
+
validators: Optional[List[Callable[[dict], bool]]] = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.host = host
|
|
127
|
+
self.requested_port = port
|
|
128
|
+
self.actual_port: int = 0
|
|
129
|
+
self.path_filter = path_filter
|
|
130
|
+
self.validators: List[Callable[[dict], bool]] = validators or []
|
|
131
|
+
|
|
132
|
+
self._handler = _WebhookHandler()
|
|
133
|
+
self._server: Optional[asyncio.Server] = None
|
|
134
|
+
self._running = False
|
|
135
|
+
|
|
136
|
+
self._registry = EndpointRegistry()
|
|
137
|
+
self._registry.register(
|
|
138
|
+
Endpoint(
|
|
139
|
+
name="default",
|
|
140
|
+
host=host,
|
|
141
|
+
port=port or 0,
|
|
142
|
+
protocol="http",
|
|
143
|
+
health_check_path="/health",
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Server lifecycle
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
async def start(self) -> None:
|
|
152
|
+
"""Start the webhook receiver server."""
|
|
153
|
+
self._server = await asyncio.start_server(
|
|
154
|
+
self._handle_connection, self.host, self.requested_port
|
|
155
|
+
)
|
|
156
|
+
self.actual_port = self._server.sockets[0].getsockname()[1]
|
|
157
|
+
self._running = True
|
|
158
|
+
|
|
159
|
+
# Update registry with actual port
|
|
160
|
+
self._registry.register(
|
|
161
|
+
Endpoint(
|
|
162
|
+
name="default",
|
|
163
|
+
host=self.host,
|
|
164
|
+
port=self.actual_port,
|
|
165
|
+
protocol="http",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
logger.info("WebhookReceiver listening on %s:%d", self.host, self.actual_port)
|
|
169
|
+
|
|
170
|
+
async def stop(self) -> None:
|
|
171
|
+
"""Stop the webhook receiver server."""
|
|
172
|
+
if self._server is not None:
|
|
173
|
+
self._server.close()
|
|
174
|
+
await self._server.wait_closed()
|
|
175
|
+
self._server = None
|
|
176
|
+
self._running = False
|
|
177
|
+
logger.info("WebhookReceiver stopped")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def url(self) -> str:
|
|
181
|
+
"""Return the base URL of the receiver (e.g. ``http://127.0.0.1:8765``)."""
|
|
182
|
+
return f"http://{self.host}:{self.actual_port}"
|
|
183
|
+
|
|
184
|
+
async def wait_for_payload(self, timeout: float = 10.0) -> Optional[dict]:
|
|
185
|
+
"""Block until a payload is received or *timeout* elapses."""
|
|
186
|
+
return await self._handler.get_payload(timeout=timeout)
|
|
187
|
+
|
|
188
|
+
def get_all_payloads(self) -> list[dict]:
|
|
189
|
+
"""Return all payloads received so far."""
|
|
190
|
+
return self._handler.get_all_payloads()
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def request_count(self) -> int:
|
|
194
|
+
"""Total number of HTTP requests received."""
|
|
195
|
+
return self._handler.request_count
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Raw TCP handler
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def _handle_connection(
|
|
202
|
+
self,
|
|
203
|
+
reader: asyncio.StreamReader,
|
|
204
|
+
writer: asyncio.StreamWriter,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Parse a minimal HTTP/1.1 request and dispatch to the handler."""
|
|
207
|
+
try:
|
|
208
|
+
raw = await asyncio.wait_for(reader.read(65536), timeout=5.0)
|
|
209
|
+
except asyncio.TimeoutError:
|
|
210
|
+
writer.close()
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
# Split headers from body
|
|
215
|
+
header_end = raw.find(b"\r\n\r\n")
|
|
216
|
+
if header_end == -1:
|
|
217
|
+
writer.close()
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
header_bytes = raw[:header_end]
|
|
221
|
+
body = raw[header_end + 4:]
|
|
222
|
+
|
|
223
|
+
lines = header_bytes.decode("utf-8", errors="replace").splitlines()
|
|
224
|
+
if not lines:
|
|
225
|
+
writer.close()
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Request line
|
|
229
|
+
parts = lines[0].split()
|
|
230
|
+
method = parts[0] if len(parts) > 0 else "GET"
|
|
231
|
+
path = parts[1] if len(parts) > 1 else "/"
|
|
232
|
+
|
|
233
|
+
# Parse headers
|
|
234
|
+
headers: dict = {}
|
|
235
|
+
for line in lines[1:]:
|
|
236
|
+
if ":" in line:
|
|
237
|
+
key, _, val = line.partition(":")
|
|
238
|
+
headers[key.strip().lower()] = val.strip()
|
|
239
|
+
|
|
240
|
+
# Apply path filter
|
|
241
|
+
if self.path_filter and not path.startswith(self.path_filter):
|
|
242
|
+
status, resp_headers, resp_body = 404, {}, b"Not Found"
|
|
243
|
+
else:
|
|
244
|
+
status, resp_headers, resp_body = await self._handler.handle(
|
|
245
|
+
method, path, headers, body
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Build minimal HTTP response
|
|
249
|
+
header_lines = [f"HTTP/1.1 {status} OK"]
|
|
250
|
+
for k, v in resp_headers.items():
|
|
251
|
+
header_lines.append(f"{k}: {v}")
|
|
252
|
+
header_lines.append(f"Content-Length: {len(resp_body)}")
|
|
253
|
+
header_lines.append("Connection: close")
|
|
254
|
+
response = "\r\n".join(header_lines) + "\r\n\r\n"
|
|
255
|
+
writer.write(response.encode("utf-8") + resp_body)
|
|
256
|
+
await writer.drain()
|
|
257
|
+
except Exception as exc: # noqa: BLE001
|
|
258
|
+
logger.debug("WebhookReceiver: error handling request: %s", exc)
|
|
259
|
+
finally:
|
|
260
|
+
writer.close()
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# SystemUnderTest interface
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def registry(self) -> EndpointRegistry:
|
|
268
|
+
return self._registry
|
|
269
|
+
|
|
270
|
+
async def execute(self, test_case: TestCase) -> TestResult:
|
|
271
|
+
"""Wait for a webhook payload and validate it.
|
|
272
|
+
|
|
273
|
+
``test_case.target`` is used as a path filter suffix.
|
|
274
|
+
|
|
275
|
+
``test_case.inputs`` may contain:
|
|
276
|
+
|
|
277
|
+
* ``timeout`` — how long to wait for the payload (default: ``10.0``).
|
|
278
|
+
* ``expected_keys`` — list of top-level JSON keys to verify.
|
|
279
|
+
* ``expected_values`` — dict of ``{key: expected_value}`` pairs to
|
|
280
|
+
check against the received payload.
|
|
281
|
+
"""
|
|
282
|
+
timeout = float(test_case.inputs.get("timeout") or 10.0)
|
|
283
|
+
expected_keys: list = test_case.inputs.get("expected_keys") or []
|
|
284
|
+
expected_values: dict = test_case.inputs.get("expected_values") or {}
|
|
285
|
+
|
|
286
|
+
start = time.monotonic()
|
|
287
|
+
record = await self.wait_for_payload(timeout=timeout)
|
|
288
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
289
|
+
|
|
290
|
+
if record is None:
|
|
291
|
+
return TestResult(
|
|
292
|
+
test_case_id=test_case.id,
|
|
293
|
+
passed=False,
|
|
294
|
+
actual_output=None,
|
|
295
|
+
execution_time_ms=elapsed_ms,
|
|
296
|
+
error="No webhook payload received within timeout",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
payload = record.get("payload")
|
|
300
|
+
|
|
301
|
+
# Run validators
|
|
302
|
+
error_msg: Optional[str] = None
|
|
303
|
+
passed = True
|
|
304
|
+
|
|
305
|
+
if expected_keys and isinstance(payload, dict):
|
|
306
|
+
missing = [k for k in expected_keys if k not in payload]
|
|
307
|
+
if missing:
|
|
308
|
+
passed = False
|
|
309
|
+
error_msg = f"Webhook payload missing expected keys: {missing}"
|
|
310
|
+
|
|
311
|
+
if passed and expected_values and isinstance(payload, dict):
|
|
312
|
+
for key, expected_val in expected_values.items():
|
|
313
|
+
actual_val = payload.get(key)
|
|
314
|
+
if actual_val != expected_val:
|
|
315
|
+
passed = False
|
|
316
|
+
error_msg = (
|
|
317
|
+
f"Webhook payload key {key!r}: "
|
|
318
|
+
f"expected {expected_val!r}, got {actual_val!r}"
|
|
319
|
+
)
|
|
320
|
+
break
|
|
321
|
+
|
|
322
|
+
if passed:
|
|
323
|
+
for validator in self.validators:
|
|
324
|
+
try:
|
|
325
|
+
if not validator(payload):
|
|
326
|
+
passed = False
|
|
327
|
+
error_msg = "Custom webhook validator returned False"
|
|
328
|
+
break
|
|
329
|
+
except Exception as exc: # noqa: BLE001
|
|
330
|
+
passed = False
|
|
331
|
+
error_msg = f"Custom webhook validator raised: {exc}"
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
return TestResult(
|
|
335
|
+
test_case_id=test_case.id,
|
|
336
|
+
passed=passed,
|
|
337
|
+
actual_output=payload,
|
|
338
|
+
execution_time_ms=elapsed_ms,
|
|
339
|
+
error=error_msg,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def health_check(self) -> bool:
|
|
343
|
+
"""Return True if the server is currently running."""
|
|
344
|
+
return self._running
|