nat-engine 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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} }}"