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,190 @@
|
|
|
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
|
+
"""Pluggable authentication strategies for :class:`HttpApiSUT`.
|
|
7
|
+
|
|
8
|
+
Each strategy implements :meth:`AuthStrategy.apply` which receives the
|
|
9
|
+
``kwargs`` dict that will be forwarded to ``httpx.AsyncClient.request`` and
|
|
10
|
+
returns a (possibly modified) copy with the appropriate credentials injected.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from typing import Dict, Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthStrategy(ABC):
|
|
20
|
+
"""Base class for pluggable authentication strategies."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def apply(self, request_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
24
|
+
"""Inject authentication credentials into *request_kwargs*.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
request_kwargs:
|
|
29
|
+
The keyword arguments that will be passed to
|
|
30
|
+
``httpx.AsyncClient.request``. This method should return a
|
|
31
|
+
(possibly new) dict with credentials added.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
dict
|
|
36
|
+
Modified request kwargs with credentials applied.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NoAuth(AuthStrategy):
|
|
41
|
+
"""Passthrough strategy — no authentication is applied."""
|
|
42
|
+
|
|
43
|
+
async def apply(self, request_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
+
return request_kwargs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiKeyAuth(AuthStrategy):
|
|
48
|
+
"""Adds an API key to a request header.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
key:
|
|
53
|
+
The API key value.
|
|
54
|
+
header_name:
|
|
55
|
+
The HTTP header name to use (default: ``"X-API-Key"``).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, key: str, header_name: str = "X-API-Key") -> None:
|
|
59
|
+
self.key = key
|
|
60
|
+
self.header_name = header_name
|
|
61
|
+
|
|
62
|
+
async def apply(self, request_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
63
|
+
kwargs = dict(request_kwargs)
|
|
64
|
+
headers = dict(kwargs.get("headers") or {})
|
|
65
|
+
headers[self.header_name] = self.key
|
|
66
|
+
kwargs["headers"] = headers
|
|
67
|
+
return kwargs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BearerTokenAuth(AuthStrategy):
|
|
71
|
+
"""Adds a Bearer token to the ``Authorization`` header.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
token:
|
|
76
|
+
The bearer token value (without the ``Bearer `` prefix).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, token: str) -> None:
|
|
80
|
+
self.token = token
|
|
81
|
+
|
|
82
|
+
async def apply(self, request_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
kwargs = dict(request_kwargs)
|
|
84
|
+
headers = dict(kwargs.get("headers") or {})
|
|
85
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
86
|
+
kwargs["headers"] = headers
|
|
87
|
+
return kwargs
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OAuth2Auth(AuthStrategy):
|
|
91
|
+
"""OAuth2 authentication with automatic token refresh.
|
|
92
|
+
|
|
93
|
+
Supports ``client_credentials`` and ``password`` grant flows.
|
|
94
|
+
Caches the token and refreshes automatically on expiry.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
token_url:
|
|
99
|
+
URL of the OAuth2 token endpoint.
|
|
100
|
+
client_id:
|
|
101
|
+
OAuth2 client ID.
|
|
102
|
+
client_secret:
|
|
103
|
+
OAuth2 client secret.
|
|
104
|
+
scopes:
|
|
105
|
+
List of OAuth2 scopes to request.
|
|
106
|
+
grant_type:
|
|
107
|
+
OAuth2 grant type — ``"client_credentials"`` (default) or ``"password"``.
|
|
108
|
+
username:
|
|
109
|
+
Resource-owner username (required for ``password`` grant).
|
|
110
|
+
password:
|
|
111
|
+
Resource-owner password (required for ``password`` grant).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
token_url: str,
|
|
117
|
+
client_id: str,
|
|
118
|
+
client_secret: str,
|
|
119
|
+
scopes: list = None,
|
|
120
|
+
grant_type: str = "client_credentials",
|
|
121
|
+
username: str = None,
|
|
122
|
+
password: str = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
import time as _time_mod # noqa: PLC0415 — stored for test injection
|
|
125
|
+
self.token_url = token_url
|
|
126
|
+
self.client_id = client_id
|
|
127
|
+
self.client_secret = client_secret
|
|
128
|
+
self.scopes: list = scopes or []
|
|
129
|
+
self.grant_type = grant_type
|
|
130
|
+
self.username = username
|
|
131
|
+
self.password = password
|
|
132
|
+
self._access_token: str | None = None
|
|
133
|
+
self._token_expiry: float = 0.0
|
|
134
|
+
self._time = _time_mod # stored for easier unit-testing
|
|
135
|
+
|
|
136
|
+
async def refresh_token(self) -> str:
|
|
137
|
+
"""Fetch a new access token via an httpx POST to ``token_url``.
|
|
138
|
+
|
|
139
|
+
Parses ``access_token`` and ``expires_in`` from the JSON response and
|
|
140
|
+
caches the result with a 10 % safety margin on the expiry time.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
str
|
|
145
|
+
The new access token.
|
|
146
|
+
|
|
147
|
+
Raises
|
|
148
|
+
------
|
|
149
|
+
ValueError
|
|
150
|
+
If the token endpoint returns a non-2xx response.
|
|
151
|
+
"""
|
|
152
|
+
import httpx
|
|
153
|
+
|
|
154
|
+
data: Dict[str, Any] = {
|
|
155
|
+
"grant_type": self.grant_type,
|
|
156
|
+
"client_id": self.client_id,
|
|
157
|
+
"client_secret": self.client_secret,
|
|
158
|
+
}
|
|
159
|
+
if self.scopes:
|
|
160
|
+
data["scope"] = " ".join(self.scopes)
|
|
161
|
+
if self.grant_type == "password":
|
|
162
|
+
data["username"] = self.username or ""
|
|
163
|
+
data["password"] = self.password or ""
|
|
164
|
+
|
|
165
|
+
async with httpx.AsyncClient() as client:
|
|
166
|
+
response = await client.post(self.token_url, data=data)
|
|
167
|
+
|
|
168
|
+
if response.status_code >= 400:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"OAuth2 token request failed with status {response.status_code}: "
|
|
171
|
+
f"{response.text}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
payload = response.json()
|
|
175
|
+
self._access_token = payload["access_token"]
|
|
176
|
+
expires_in: float = float(payload.get("expires_in", 3600))
|
|
177
|
+
# Use 90 % of the lifetime so we refresh before the token actually expires
|
|
178
|
+
self._token_expiry = self._time.monotonic() + expires_in * 0.9
|
|
179
|
+
return self._access_token
|
|
180
|
+
|
|
181
|
+
async def apply(self, request_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
182
|
+
"""Add an OAuth2 Bearer token, refreshing automatically if expired."""
|
|
183
|
+
if self._access_token is None or self._time.monotonic() >= self._token_expiry:
|
|
184
|
+
await self.refresh_token()
|
|
185
|
+
|
|
186
|
+
kwargs = dict(request_kwargs)
|
|
187
|
+
headers = dict(kwargs.get("headers") or {})
|
|
188
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
189
|
+
kwargs["headers"] = headers
|
|
190
|
+
return kwargs
|
|
@@ -0,0 +1,436 @@
|
|
|
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
|
+
"""GraphQL introspection parser and test-case generator.
|
|
7
|
+
|
|
8
|
+
Parses the result of a standard GraphQL introspection query into
|
|
9
|
+
:class:`GraphQLSchema` and produces :class:`~mannf.core.testing.models.TestCase`
|
|
10
|
+
objects that exercise the schema in both positive and negative ways.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from mannf.core.testing.models import TestCase
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from mannf.product.llm.base import LLMProvider
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GraphQLArg:
|
|
29
|
+
"""A single argument on a GraphQL field or operation."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
type_name: str
|
|
33
|
+
is_required: bool
|
|
34
|
+
default_value: Any = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class GraphQLField:
|
|
39
|
+
"""A field belonging to a GraphQL type."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
type_name: str
|
|
43
|
+
is_required: bool
|
|
44
|
+
is_list: bool
|
|
45
|
+
args: List[GraphQLArg] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class GraphQLOperation:
|
|
50
|
+
"""A top-level query or mutation in the schema."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
kind: str # "query" | "mutation"
|
|
54
|
+
args: List[GraphQLArg] = field(default_factory=list)
|
|
55
|
+
return_type: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class GraphQLSchema:
|
|
60
|
+
"""Parsed representation of a GraphQL schema."""
|
|
61
|
+
|
|
62
|
+
query_type: str
|
|
63
|
+
mutation_type: Optional[str]
|
|
64
|
+
subscription_type: Optional[str]
|
|
65
|
+
types: Dict[str, List[GraphQLField]]
|
|
66
|
+
queries: List[GraphQLOperation]
|
|
67
|
+
mutations: List[GraphQLOperation]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unwrap_type(type_ref: Optional[dict]) -> tuple[str, bool, bool]:
|
|
71
|
+
"""Recursively unwrap NON_NULL / LIST wrappers.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
(type_name, is_required, is_list)
|
|
76
|
+
"""
|
|
77
|
+
if type_ref is None:
|
|
78
|
+
return ("", False, False)
|
|
79
|
+
|
|
80
|
+
is_required = False
|
|
81
|
+
is_list = False
|
|
82
|
+
|
|
83
|
+
node = type_ref
|
|
84
|
+
while node:
|
|
85
|
+
kind = node.get("kind", "")
|
|
86
|
+
if kind == "NON_NULL":
|
|
87
|
+
is_required = True
|
|
88
|
+
node = node.get("ofType")
|
|
89
|
+
elif kind == "LIST":
|
|
90
|
+
is_list = True
|
|
91
|
+
node = node.get("ofType")
|
|
92
|
+
else:
|
|
93
|
+
return (node.get("name") or "", is_required, is_list)
|
|
94
|
+
|
|
95
|
+
return ("", is_required, is_list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_args(raw_args: list) -> List[GraphQLArg]:
|
|
99
|
+
result: List[GraphQLArg] = []
|
|
100
|
+
for arg in raw_args or []:
|
|
101
|
+
name = arg.get("name", "")
|
|
102
|
+
type_name, is_required, _ = _unwrap_type(arg.get("type"))
|
|
103
|
+
default_value = arg.get("defaultValue")
|
|
104
|
+
result.append(GraphQLArg(name=name, type_name=type_name, is_required=is_required, default_value=default_value))
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GraphQLParser:
|
|
109
|
+
"""Parse GraphQL introspection results and generate test cases.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
introspection_result:
|
|
114
|
+
The raw dict returned by a ``{ __schema { ... } }`` introspection query.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, introspection_result: dict, llm_provider: Optional["LLMProvider"] = None) -> None:
|
|
118
|
+
self._raw = introspection_result
|
|
119
|
+
self.llm_provider = llm_provider
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Public API
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def parse(self) -> GraphQLSchema:
|
|
126
|
+
"""Parse the introspection result into a :class:`GraphQLSchema`."""
|
|
127
|
+
data = self._raw.get("data") or self._raw
|
|
128
|
+
schema = data.get("__schema") or {}
|
|
129
|
+
|
|
130
|
+
query_type = (schema.get("queryType") or {}).get("name") or "Query"
|
|
131
|
+
mutation_type_info = schema.get("mutationType")
|
|
132
|
+
mutation_type: Optional[str] = (mutation_type_info or {}).get("name") if mutation_type_info else None
|
|
133
|
+
subscription_type_info = schema.get("subscriptionType")
|
|
134
|
+
subscription_type: Optional[str] = (subscription_type_info or {}).get("name") if subscription_type_info else None
|
|
135
|
+
|
|
136
|
+
types: Dict[str, List[GraphQLField]] = {}
|
|
137
|
+
queries: List[GraphQLOperation] = []
|
|
138
|
+
mutations: List[GraphQLOperation] = []
|
|
139
|
+
|
|
140
|
+
for type_def in schema.get("types") or []:
|
|
141
|
+
type_name = type_def.get("name") or ""
|
|
142
|
+
# Skip built-in introspection types
|
|
143
|
+
if type_name.startswith("__"):
|
|
144
|
+
continue
|
|
145
|
+
if type_def.get("kind") not in ("OBJECT", "INTERFACE"):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
fields: List[GraphQLField] = []
|
|
149
|
+
for f in type_def.get("fields") or []:
|
|
150
|
+
field_name = f.get("name") or ""
|
|
151
|
+
field_type_name, field_required, field_list = _unwrap_type(f.get("type"))
|
|
152
|
+
field_args = _parse_args(f.get("args") or [])
|
|
153
|
+
fields.append(
|
|
154
|
+
GraphQLField(
|
|
155
|
+
name=field_name,
|
|
156
|
+
type_name=field_type_name,
|
|
157
|
+
is_required=field_required,
|
|
158
|
+
is_list=field_list,
|
|
159
|
+
args=field_args,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
types[type_name] = fields
|
|
164
|
+
|
|
165
|
+
# Register query/mutation operations
|
|
166
|
+
if type_name == query_type:
|
|
167
|
+
for gf in fields:
|
|
168
|
+
queries.append(
|
|
169
|
+
GraphQLOperation(
|
|
170
|
+
name=gf.name,
|
|
171
|
+
kind="query",
|
|
172
|
+
args=gf.args,
|
|
173
|
+
return_type=gf.type_name,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
elif mutation_type and type_name == mutation_type:
|
|
177
|
+
for gf in fields:
|
|
178
|
+
mutations.append(
|
|
179
|
+
GraphQLOperation(
|
|
180
|
+
name=gf.name,
|
|
181
|
+
kind="mutation",
|
|
182
|
+
args=gf.args,
|
|
183
|
+
return_type=gf.type_name,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return GraphQLSchema(
|
|
188
|
+
query_type=query_type,
|
|
189
|
+
mutation_type=mutation_type,
|
|
190
|
+
subscription_type=subscription_type,
|
|
191
|
+
types=types,
|
|
192
|
+
queries=queries,
|
|
193
|
+
mutations=mutations,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def generate_test_cases(self, schema: GraphQLSchema, base_url: str) -> List[TestCase]:
|
|
197
|
+
"""Generate :class:`~mannf.core.testing.models.TestCase` objects from *schema*.
|
|
198
|
+
|
|
199
|
+
For each operation the following cases are generated:
|
|
200
|
+
|
|
201
|
+
* **valid** — query with sensible variable placeholders.
|
|
202
|
+
* **missing required vars** — query without required variables.
|
|
203
|
+
* **wrong type vars** — query with invalid variable types.
|
|
204
|
+
* **depth attack** — deeply nested query (10 levels).
|
|
205
|
+
* **batch query** — array of 5 identical queries.
|
|
206
|
+
* **introspection** — probe to detect whether introspection is exposed.
|
|
207
|
+
"""
|
|
208
|
+
cases: List[TestCase] = []
|
|
209
|
+
|
|
210
|
+
for op in schema.queries + schema.mutations:
|
|
211
|
+
# -- positive case --------------------------------------------------
|
|
212
|
+
valid_query = self._build_operation_query(op)
|
|
213
|
+
valid_vars = self._build_valid_variables(op)
|
|
214
|
+
cases.append(
|
|
215
|
+
TestCase(
|
|
216
|
+
target=base_url,
|
|
217
|
+
inputs={
|
|
218
|
+
"method": "POST",
|
|
219
|
+
"json": {"query": valid_query, "variables": valid_vars},
|
|
220
|
+
"expected_status_codes": [200],
|
|
221
|
+
},
|
|
222
|
+
expected_behavior=f"Valid {op.kind} '{op.name}' returns 200 with data",
|
|
223
|
+
metadata={"operation": op.name, "case_type": "valid"},
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# -- missing required variables -------------------------------------
|
|
228
|
+
if any(a.is_required for a in op.args):
|
|
229
|
+
cases.append(
|
|
230
|
+
TestCase(
|
|
231
|
+
target=base_url,
|
|
232
|
+
inputs={
|
|
233
|
+
"method": "POST",
|
|
234
|
+
"json": {"query": valid_query, "variables": {}},
|
|
235
|
+
"expected_status_codes": [200],
|
|
236
|
+
},
|
|
237
|
+
expected_behavior=(
|
|
238
|
+
f"'{op.name}' with missing required variables returns "
|
|
239
|
+
"200 with GraphQL errors"
|
|
240
|
+
),
|
|
241
|
+
metadata={"operation": op.name, "case_type": "missing_vars"},
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# -- wrong type variables -------------------------------------------
|
|
246
|
+
wrong_vars = self._build_wrong_type_variables(op)
|
|
247
|
+
if wrong_vars:
|
|
248
|
+
cases.append(
|
|
249
|
+
TestCase(
|
|
250
|
+
target=base_url,
|
|
251
|
+
inputs={
|
|
252
|
+
"method": "POST",
|
|
253
|
+
"json": {"query": valid_query, "variables": wrong_vars},
|
|
254
|
+
"expected_status_codes": [200],
|
|
255
|
+
},
|
|
256
|
+
expected_behavior=(
|
|
257
|
+
f"'{op.name}' with wrong-type variables returns "
|
|
258
|
+
"200 with GraphQL errors"
|
|
259
|
+
),
|
|
260
|
+
metadata={"operation": op.name, "case_type": "wrong_type"},
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# -- depth attack (schema-independent) ----------------------------------
|
|
265
|
+
depth_query = self._build_depth_query(10)
|
|
266
|
+
cases.append(
|
|
267
|
+
TestCase(
|
|
268
|
+
target=base_url,
|
|
269
|
+
inputs={
|
|
270
|
+
"method": "POST",
|
|
271
|
+
"json": {"query": depth_query},
|
|
272
|
+
"expected_status_codes": [200],
|
|
273
|
+
},
|
|
274
|
+
expected_behavior="Deeply nested query should be rejected or limited by the server",
|
|
275
|
+
metadata={"case_type": "depth_attack"},
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# -- batch query --------------------------------------------------------
|
|
280
|
+
first_op_query = "{ __typename }"
|
|
281
|
+
if schema.queries:
|
|
282
|
+
first_op_query = self._build_operation_query(schema.queries[0])
|
|
283
|
+
batch = [{"query": first_op_query} for _ in range(5)]
|
|
284
|
+
cases.append(
|
|
285
|
+
TestCase(
|
|
286
|
+
target=base_url,
|
|
287
|
+
inputs={
|
|
288
|
+
"method": "POST",
|
|
289
|
+
"json": batch,
|
|
290
|
+
"expected_status_codes": [200],
|
|
291
|
+
},
|
|
292
|
+
expected_behavior="Batch of 5 queries — server should limit batching",
|
|
293
|
+
metadata={"case_type": "batch_query"},
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# -- introspection probe ------------------------------------------------
|
|
298
|
+
introspection_query = "{ __schema { types { name } } }"
|
|
299
|
+
cases.append(
|
|
300
|
+
TestCase(
|
|
301
|
+
target=base_url,
|
|
302
|
+
inputs={
|
|
303
|
+
"method": "POST",
|
|
304
|
+
"json": {"query": introspection_query},
|
|
305
|
+
"expected_status_codes": [200],
|
|
306
|
+
},
|
|
307
|
+
expected_behavior="Introspection query — production endpoints should disable this",
|
|
308
|
+
metadata={"case_type": "introspection"},
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return cases
|
|
313
|
+
|
|
314
|
+
async def generate_test_cases_with_llm(
|
|
315
|
+
self, schema: GraphQLSchema, base_url: str
|
|
316
|
+
) -> List[TestCase]:
|
|
317
|
+
"""Generate test cases and augment with LLM-generated cases when available.
|
|
318
|
+
|
|
319
|
+
Falls back to :meth:`generate_test_cases` when no LLM provider is
|
|
320
|
+
configured or when it is unavailable (fail-safe).
|
|
321
|
+
"""
|
|
322
|
+
cases = self.generate_test_cases(schema, base_url)
|
|
323
|
+
|
|
324
|
+
if self.llm_provider is None or not self.llm_provider.is_available():
|
|
325
|
+
return cases
|
|
326
|
+
|
|
327
|
+
for op in schema.queries + schema.mutations:
|
|
328
|
+
try:
|
|
329
|
+
endpoint_info = {
|
|
330
|
+
"method": "POST",
|
|
331
|
+
"path": base_url,
|
|
332
|
+
"description": f"GraphQL {op.kind} '{op.name}'",
|
|
333
|
+
"parameters": [
|
|
334
|
+
{"name": a.name, "type": a.type_name, "required": a.is_required}
|
|
335
|
+
for a in op.args
|
|
336
|
+
],
|
|
337
|
+
"schema": {
|
|
338
|
+
"operation_name": op.name,
|
|
339
|
+
"kind": op.kind,
|
|
340
|
+
"return_type": op.return_type,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
raw_cases = await self.llm_provider.generate_test_cases(
|
|
344
|
+
endpoint_info, {}, n=3
|
|
345
|
+
)
|
|
346
|
+
for raw in raw_cases:
|
|
347
|
+
if not isinstance(raw, dict):
|
|
348
|
+
continue
|
|
349
|
+
inputs = raw.get("inputs", {})
|
|
350
|
+
if not isinstance(inputs, dict):
|
|
351
|
+
inputs = {}
|
|
352
|
+
inputs.setdefault("method", "POST")
|
|
353
|
+
cases.append(
|
|
354
|
+
TestCase(
|
|
355
|
+
target=base_url,
|
|
356
|
+
inputs=inputs,
|
|
357
|
+
expected_behavior=raw.get(
|
|
358
|
+
"expected_behavior", f"[llm] {op.kind} '{op.name}'"
|
|
359
|
+
),
|
|
360
|
+
metadata={
|
|
361
|
+
"operation": op.name,
|
|
362
|
+
"case_type": raw.get("test_type", "llm"),
|
|
363
|
+
"generator": "llm",
|
|
364
|
+
"generation_strategy": "llm_edge_case",
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
except Exception as exc: # noqa: BLE001
|
|
369
|
+
logger.warning(
|
|
370
|
+
"GraphQLParser: LLM generation failed for %s '%s': %s",
|
|
371
|
+
op.kind, op.name, exc,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return cases
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
# Helpers
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
@staticmethod
|
|
381
|
+
def _build_operation_query(op: GraphQLOperation) -> str:
|
|
382
|
+
"""Build a minimal valid GraphQL operation string."""
|
|
383
|
+
if op.args:
|
|
384
|
+
arg_str = ", ".join(
|
|
385
|
+
f"${a.name}: {a.type_name}{'!' if a.is_required else ''}"
|
|
386
|
+
for a in op.args
|
|
387
|
+
)
|
|
388
|
+
pass_str = ", ".join(f"{a.name}: ${a.name}" for a in op.args)
|
|
389
|
+
return (
|
|
390
|
+
f"{op.kind} ({arg_str}) {{ "
|
|
391
|
+
f"{op.name}({pass_str}) {{ id name }} }}"
|
|
392
|
+
)
|
|
393
|
+
return f"{{ {op.name} {{ id name }} }}"
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _build_valid_variables(op: GraphQLOperation) -> dict:
|
|
397
|
+
"""Build a variables dict with sensible placeholder values."""
|
|
398
|
+
vars_: dict = {}
|
|
399
|
+
for arg in op.args:
|
|
400
|
+
tn = (arg.type_name or "").upper()
|
|
401
|
+
if "INT" in tn:
|
|
402
|
+
vars_[arg.name] = 1
|
|
403
|
+
elif "FLOAT" in tn or "DOUBLE" in tn:
|
|
404
|
+
vars_[arg.name] = 1.0
|
|
405
|
+
elif "BOOL" in tn:
|
|
406
|
+
vars_[arg.name] = True
|
|
407
|
+
elif "ID" in tn:
|
|
408
|
+
vars_[arg.name] = "1"
|
|
409
|
+
else:
|
|
410
|
+
vars_[arg.name] = "test"
|
|
411
|
+
return vars_
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _build_wrong_type_variables(op: GraphQLOperation) -> dict:
|
|
415
|
+
"""Build a variables dict where values have the wrong type."""
|
|
416
|
+
vars_: dict = {}
|
|
417
|
+
for arg in op.args:
|
|
418
|
+
tn = (arg.type_name or "").upper()
|
|
419
|
+
if "INT" in tn or "FLOAT" in tn:
|
|
420
|
+
vars_[arg.name] = "not-a-number"
|
|
421
|
+
elif "BOOL" in tn:
|
|
422
|
+
vars_[arg.name] = "not-a-bool"
|
|
423
|
+
elif "ID" in tn:
|
|
424
|
+
vars_[arg.name] = {"nested": "object"}
|
|
425
|
+
else:
|
|
426
|
+
vars_[arg.name] = 12345
|
|
427
|
+
return vars_
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _build_depth_query(depth: int) -> str:
|
|
431
|
+
"""Build a deeply nested query string *depth* levels deep."""
|
|
432
|
+
inner = "id"
|
|
433
|
+
field_name = "a"
|
|
434
|
+
for _ in range(depth):
|
|
435
|
+
inner = f"{field_name} {{ {inner} }}"
|
|
436
|
+
return f"{{ {inner} }}"
|