ai-testing-swarm 0.1.16__tar.gz → 0.1.17__tar.gz
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.
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/PKG-INFO +15 -1
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/README.md +14 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/pyproject.toml +1 -1
- ai_testing_swarm-0.1.17/src/ai_testing_swarm/__init__.py +1 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/test_planner_agent.py +52 -1
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/cli.py +88 -5
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/config.py +6 -0
- ai_testing_swarm-0.1.17/src/ai_testing_swarm/core/openapi_fuzzer.py +396 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/orchestrator.py +12 -1
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/PKG-INFO +15 -1
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/SOURCES.txt +2 -0
- ai_testing_swarm-0.1.17/tests/test_openapi_fuzzer.py +91 -0
- ai_testing_swarm-0.1.16/src/ai_testing_swarm/__init__.py +0 -1
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/setup.cfg +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/__init__.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/execution_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/learning_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/llm_reasoning_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/release_gate_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/test_writer_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/ui_agent.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/__init__.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/api_client.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/auth_matrix.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/curl_parser.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openai_client.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_loader.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_validator.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/risk.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/safety.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/__init__.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/dashboard.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/report_writer.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/trend.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/dependency_links.txt +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/entry_points.txt +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/requires.txt +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/top_level.txt +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_batch2_trend_and_auth.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_openapi_loader.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_openapi_validator.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_policy_expected_negatives.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_risk_scoring_and_gate.py +0 -0
- {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_swarm_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-testing-swarm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.17
|
|
4
4
|
Summary: AI-powered testing swarm
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -119,6 +119,10 @@ Reports include:
|
|
|
119
119
|
- Base URL is read from `spec.servers[0].url`.
|
|
120
120
|
- When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
|
|
121
121
|
- If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
|
|
122
|
+
- If an OpenAPI requestBody schema exists for the operation, the planner can also generate **schema-based request-body fuzz cases**:
|
|
123
|
+
- schema-valid edge cases (boundary values that should still validate)
|
|
124
|
+
- schema-invalid variants (missing required fields, wrong types, out-of-range, etc.)
|
|
125
|
+
- Controlled via CLI flags like `--no-openapi-fuzz` / `--openapi-fuzz-max-*`.
|
|
122
126
|
- Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
|
|
123
127
|
|
|
124
128
|
---
|
|
@@ -223,6 +227,16 @@ Reports include:
|
|
|
223
227
|
A static dashboard index is generated at:
|
|
224
228
|
- `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
|
|
225
229
|
|
|
230
|
+
### Regression gate (CI)
|
|
231
|
+
|
|
232
|
+
To fail CI when the run regresses vs the previous JSON report for the same endpoint:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
ai-test --input request.json --fail-on-regression
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
This checks `report.trend.regression_count` and exits with a non-zero code if regressions are detected.
|
|
239
|
+
|
|
226
240
|
SLA threshold:
|
|
227
241
|
- `AI_SWARM_SLA_MS` (default: `2000`)
|
|
228
242
|
|
|
@@ -104,6 +104,10 @@ Reports include:
|
|
|
104
104
|
- Base URL is read from `spec.servers[0].url`.
|
|
105
105
|
- When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
|
|
106
106
|
- If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
|
|
107
|
+
- If an OpenAPI requestBody schema exists for the operation, the planner can also generate **schema-based request-body fuzz cases**:
|
|
108
|
+
- schema-valid edge cases (boundary values that should still validate)
|
|
109
|
+
- schema-invalid variants (missing required fields, wrong types, out-of-range, etc.)
|
|
110
|
+
- Controlled via CLI flags like `--no-openapi-fuzz` / `--openapi-fuzz-max-*`.
|
|
107
111
|
- Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
|
|
108
112
|
|
|
109
113
|
---
|
|
@@ -208,6 +212,16 @@ Reports include:
|
|
|
208
212
|
A static dashboard index is generated at:
|
|
209
213
|
- `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
|
|
210
214
|
|
|
215
|
+
### Regression gate (CI)
|
|
216
|
+
|
|
217
|
+
To fail CI when the run regresses vs the previous JSON report for the same endpoint:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
ai-test --input request.json --fail-on-regression
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This checks `report.trend.regression_count` and exits with a non-zero code if regressions are detected.
|
|
224
|
+
|
|
211
225
|
SLA threshold:
|
|
212
226
|
- `AI_SWARM_SLA_MS` (default: `2000`)
|
|
213
227
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.17"
|
|
@@ -363,7 +363,58 @@ class TestPlannerAgent:
|
|
|
363
363
|
for key, value in body.items():
|
|
364
364
|
add_field_mutations("body", key, value)
|
|
365
365
|
|
|
366
|
-
# 6) Optional
|
|
366
|
+
# 6) Optional OpenAPI schema-based request-body fuzzing (best-effort)
|
|
367
|
+
try:
|
|
368
|
+
from ai_testing_swarm.core.config import (
|
|
369
|
+
AI_SWARM_OPENAPI_FUZZ,
|
|
370
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH,
|
|
371
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_INVALID,
|
|
372
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_VALID,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if AI_SWARM_OPENAPI_FUZZ and isinstance(request.get("_openapi"), dict):
|
|
376
|
+
ctx = request.get("_openapi") or {}
|
|
377
|
+
spec = ctx.get("spec")
|
|
378
|
+
path0 = ctx.get("path")
|
|
379
|
+
method0 = ctx.get("method")
|
|
380
|
+
|
|
381
|
+
if isinstance(spec, dict) and isinstance(path0, str) and isinstance(method0, str):
|
|
382
|
+
from ai_testing_swarm.core.openapi_fuzzer import generate_request_body_fuzz_cases
|
|
383
|
+
|
|
384
|
+
fuzz_cases = generate_request_body_fuzz_cases(
|
|
385
|
+
spec=spec,
|
|
386
|
+
path=path0,
|
|
387
|
+
method=method0,
|
|
388
|
+
max_valid=AI_SWARM_OPENAPI_FUZZ_MAX_VALID,
|
|
389
|
+
max_invalid=AI_SWARM_OPENAPI_FUZZ_MAX_INVALID,
|
|
390
|
+
max_depth=AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
existing = {t["name"] for t in tests}
|
|
394
|
+
for fc in fuzz_cases:
|
|
395
|
+
name = fc.name
|
|
396
|
+
if name in existing:
|
|
397
|
+
continue
|
|
398
|
+
tests.append(
|
|
399
|
+
{
|
|
400
|
+
"name": name,
|
|
401
|
+
"mutation": {
|
|
402
|
+
"target": "body_whole",
|
|
403
|
+
"path": [],
|
|
404
|
+
"operation": "REPLACE",
|
|
405
|
+
"original_value": body,
|
|
406
|
+
"new_value": fc.body,
|
|
407
|
+
"strategy": "openapi_fuzz_valid" if fc.is_valid else "openapi_fuzz_invalid",
|
|
408
|
+
"openapi": {"is_valid": fc.is_valid, "details": fc.details},
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
existing.add(name)
|
|
413
|
+
except Exception:
|
|
414
|
+
# Never fail planning due to fuzzing issues
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# 7) Optional OpenAI augmentation (best-effort)
|
|
367
418
|
try:
|
|
368
419
|
from ai_testing_swarm.core.config import (
|
|
369
420
|
AI_SWARM_MAX_AI_TESTS,
|
|
@@ -107,6 +107,45 @@ def main():
|
|
|
107
107
|
help="Report format to write (default: json)",
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
+
# OpenAPI schema-based request-body fuzzing
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--openapi-fuzz",
|
|
113
|
+
dest="openapi_fuzz",
|
|
114
|
+
action="store_true",
|
|
115
|
+
default=True,
|
|
116
|
+
help="Enable OpenAPI schema-based request-body fuzzing when OpenAPI context is present (default: on)",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--no-openapi-fuzz",
|
|
120
|
+
dest="openapi_fuzz",
|
|
121
|
+
action="store_false",
|
|
122
|
+
help="Disable OpenAPI schema-based request-body fuzzing",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--openapi-fuzz-max-valid",
|
|
126
|
+
type=int,
|
|
127
|
+
default=8,
|
|
128
|
+
help="Max schema-valid OpenAPI fuzz cases to add (default: 8)",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--openapi-fuzz-max-invalid",
|
|
132
|
+
type=int,
|
|
133
|
+
default=8,
|
|
134
|
+
help="Max schema-invalid OpenAPI fuzz cases to add (default: 8)",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--openapi-fuzz-max-depth",
|
|
138
|
+
type=int,
|
|
139
|
+
default=6,
|
|
140
|
+
help="Max recursion depth for OpenAPI schema instance generation (default: 6)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--fail-on-regression",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help="Exit non-zero if the run regresses vs the previous JSON report for this endpoint",
|
|
147
|
+
)
|
|
148
|
+
|
|
110
149
|
parser.add_argument(
|
|
111
150
|
"--auth-matrix",
|
|
112
151
|
default="",
|
|
@@ -146,10 +185,17 @@ def main():
|
|
|
146
185
|
# ------------------------------------------------------------
|
|
147
186
|
# Run swarm
|
|
148
187
|
# ------------------------------------------------------------
|
|
188
|
+
import os
|
|
189
|
+
|
|
149
190
|
if args.public_only:
|
|
150
|
-
import os
|
|
151
191
|
os.environ["AI_SWARM_PUBLIC_ONLY"] = "1"
|
|
152
192
|
|
|
193
|
+
# CLI flags -> env toggles used by core.config
|
|
194
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ"] = "1" if args.openapi_fuzz else "0"
|
|
195
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_VALID"] = str(int(args.openapi_fuzz_max_valid))
|
|
196
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_INVALID"] = str(int(args.openapi_fuzz_max_invalid))
|
|
197
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH"] = str(int(args.openapi_fuzz_max_depth))
|
|
198
|
+
|
|
153
199
|
orch = SwarmOrchestrator()
|
|
154
200
|
|
|
155
201
|
def _print_console(decision, results, *, label: str = ""):
|
|
@@ -163,29 +209,66 @@ def main():
|
|
|
163
209
|
status_code = response.get("status_code")
|
|
164
210
|
print(f"{r.get('name'):25} {str(status_code):5} {r.get('reason')}")
|
|
165
211
|
|
|
212
|
+
def _maybe_fail_on_regression(report_path: str):
|
|
213
|
+
try:
|
|
214
|
+
with open(report_path, "r", encoding="utf-8") as f:
|
|
215
|
+
report = json.load(f)
|
|
216
|
+
except Exception:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
trend = report.get("trend") or {}
|
|
220
|
+
regression_count = trend.get("regression_count")
|
|
221
|
+
try:
|
|
222
|
+
reg_i = int(regression_count or 0)
|
|
223
|
+
except Exception:
|
|
224
|
+
reg_i = 0
|
|
225
|
+
|
|
226
|
+
if reg_i > 0:
|
|
227
|
+
print(f"\n❌ Regression gate failed: regressions={reg_i} (see trend in report)")
|
|
228
|
+
raise SystemExit(2)
|
|
229
|
+
|
|
166
230
|
if args.auth_matrix:
|
|
167
231
|
from ai_testing_swarm.core.auth_matrix import load_auth_matrix, merge_auth_headers
|
|
168
232
|
|
|
169
233
|
cases = load_auth_matrix(args.auth_matrix)
|
|
170
234
|
for c in cases:
|
|
171
235
|
req2 = merge_auth_headers(request, c)
|
|
172
|
-
|
|
236
|
+
report_format = args.report_format
|
|
237
|
+
if args.fail_on_regression and report_format != "json":
|
|
238
|
+
# regression gate needs a machine-readable report
|
|
239
|
+
report_format = "json"
|
|
240
|
+
|
|
241
|
+
decision, results, report_path = orch.run(
|
|
173
242
|
req2,
|
|
174
|
-
report_format=
|
|
243
|
+
report_format=report_format,
|
|
175
244
|
gate_warn=args.gate_warn,
|
|
176
245
|
gate_block=args.gate_block,
|
|
177
246
|
run_label=f"auth-{c.name}",
|
|
247
|
+
fail_on_regression=args.fail_on_regression,
|
|
248
|
+
return_report_path=True,
|
|
178
249
|
)
|
|
179
250
|
_print_console(decision, results, label=c.name)
|
|
251
|
+
|
|
252
|
+
if args.fail_on_regression:
|
|
253
|
+
_maybe_fail_on_regression(report_path)
|
|
180
254
|
else:
|
|
181
|
-
|
|
255
|
+
report_format = args.report_format
|
|
256
|
+
if args.fail_on_regression and report_format != "json":
|
|
257
|
+
report_format = "json"
|
|
258
|
+
|
|
259
|
+
decision, results, report_path = orch.run(
|
|
182
260
|
request,
|
|
183
|
-
report_format=
|
|
261
|
+
report_format=report_format,
|
|
184
262
|
gate_warn=args.gate_warn,
|
|
185
263
|
gate_block=args.gate_block,
|
|
264
|
+
fail_on_regression=args.fail_on_regression,
|
|
265
|
+
return_report_path=True,
|
|
186
266
|
)
|
|
187
267
|
_print_console(decision, results)
|
|
188
268
|
|
|
269
|
+
if args.fail_on_regression:
|
|
270
|
+
_maybe_fail_on_regression(report_path)
|
|
271
|
+
|
|
189
272
|
|
|
190
273
|
if __name__ == "__main__":
|
|
191
274
|
main()
|
|
@@ -20,3 +20,9 @@ AI_SWARM_USE_OPENAI = os.getenv("AI_SWARM_USE_OPENAI", "0").lower() in {"1", "tr
|
|
|
20
20
|
AI_SWARM_OPENAI_MODEL = os.getenv("AI_SWARM_OPENAI_MODEL", "gpt-4.1-mini")
|
|
21
21
|
AI_SWARM_MAX_AI_TESTS = env_int("AI_SWARM_MAX_AI_TESTS", 20)
|
|
22
22
|
AI_SWARM_AI_SUMMARY = os.getenv("AI_SWARM_AI_SUMMARY", "0").lower() in {"1", "true", "yes"}
|
|
23
|
+
|
|
24
|
+
# OpenAPI schema-based request-body fuzzing (on by default when OpenAPI context exists)
|
|
25
|
+
AI_SWARM_OPENAPI_FUZZ = os.getenv("AI_SWARM_OPENAPI_FUZZ", "1").lower() in {"1", "true", "yes"}
|
|
26
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_VALID = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_VALID", 8)
|
|
27
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_INVALID = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_INVALID", 8)
|
|
28
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH", 6)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""OpenAPI request-body schema based fuzzing.
|
|
4
|
+
|
|
5
|
+
This module is intentionally lightweight (no hypothesis/faker deps).
|
|
6
|
+
It generates:
|
|
7
|
+
- schema-valid "edge" payloads (still validate)
|
|
8
|
+
- schema-invalid payloads (should violate validation)
|
|
9
|
+
|
|
10
|
+
Used only when an OpenAPI spec is attached to the request.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class FuzzCase:
|
|
19
|
+
name: str
|
|
20
|
+
body: Any
|
|
21
|
+
is_valid: bool
|
|
22
|
+
details: dict | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_operation(spec: dict, *, path: str, method: str) -> dict | None:
|
|
26
|
+
paths = spec.get("paths") or {}
|
|
27
|
+
op = (paths.get(path) or {}).get(str(method).lower())
|
|
28
|
+
return op if isinstance(op, dict) else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_request_body_schema(*, spec: dict, path: str, method: str) -> dict | None:
|
|
32
|
+
op = _get_operation(spec, path=path, method=method)
|
|
33
|
+
if not op:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
rb = op.get("requestBody")
|
|
37
|
+
if not isinstance(rb, dict):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
content = rb.get("content") or {}
|
|
41
|
+
if not isinstance(content, dict):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# Prefer json
|
|
45
|
+
app_json = content.get("application/json") or content.get("application/*+json")
|
|
46
|
+
if not isinstance(app_json, dict):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
schema = app_json.get("schema")
|
|
50
|
+
if isinstance(schema, dict):
|
|
51
|
+
return schema
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----------------------------
|
|
56
|
+
# Minimal schema resolution
|
|
57
|
+
# ----------------------------
|
|
58
|
+
|
|
59
|
+
def _resolve_ref(schema: dict, *, resolver) -> dict:
|
|
60
|
+
if "$ref" not in schema:
|
|
61
|
+
return schema
|
|
62
|
+
ref = schema.get("$ref")
|
|
63
|
+
try:
|
|
64
|
+
# jsonschema.RefResolver API
|
|
65
|
+
with resolver.resolving(ref) as resolved: # type: ignore[attr-defined]
|
|
66
|
+
if isinstance(resolved, dict):
|
|
67
|
+
return resolved
|
|
68
|
+
except Exception:
|
|
69
|
+
return schema
|
|
70
|
+
return schema
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _choose_subschema(schema: dict) -> dict:
|
|
74
|
+
"""Handle oneOf/anyOf/allOf in a deterministic best-effort manner."""
|
|
75
|
+
for key in ("oneOf", "anyOf"):
|
|
76
|
+
v = schema.get(key)
|
|
77
|
+
if isinstance(v, list) and v and isinstance(v[0], dict):
|
|
78
|
+
return v[0]
|
|
79
|
+
all_of = schema.get("allOf")
|
|
80
|
+
if isinstance(all_of, list) and all_of:
|
|
81
|
+
merged: dict = {}
|
|
82
|
+
for s in all_of:
|
|
83
|
+
if isinstance(s, dict):
|
|
84
|
+
# naive merge
|
|
85
|
+
merged.update({k: v for k, v in s.items() if k not in {"allOf"}})
|
|
86
|
+
return merged or schema
|
|
87
|
+
return schema
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _schema_type(schema: dict) -> str | None:
|
|
91
|
+
t = schema.get("type")
|
|
92
|
+
if isinstance(t, str):
|
|
93
|
+
return t
|
|
94
|
+
if isinstance(t, list) and t:
|
|
95
|
+
# choose first
|
|
96
|
+
if isinstance(t[0], str):
|
|
97
|
+
return t[0]
|
|
98
|
+
# Infer common
|
|
99
|
+
if "properties" in schema:
|
|
100
|
+
return "object"
|
|
101
|
+
if "items" in schema:
|
|
102
|
+
return "array"
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _string_example(schema: dict) -> str:
|
|
107
|
+
if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
|
|
108
|
+
return str(schema["enum"][0])
|
|
109
|
+
if "default" in schema:
|
|
110
|
+
return str(schema["default"])
|
|
111
|
+
if "example" in schema:
|
|
112
|
+
return str(schema["example"])
|
|
113
|
+
|
|
114
|
+
min_len = schema.get("minLength")
|
|
115
|
+
try:
|
|
116
|
+
min_len_i = int(min_len) if min_len is not None else 1
|
|
117
|
+
except Exception:
|
|
118
|
+
min_len_i = 1
|
|
119
|
+
|
|
120
|
+
fmt = str(schema.get("format") or "")
|
|
121
|
+
if fmt == "date-time":
|
|
122
|
+
base = "2020-01-01T00:00:00Z"
|
|
123
|
+
elif fmt == "date":
|
|
124
|
+
base = "2020-01-01"
|
|
125
|
+
elif fmt == "uuid":
|
|
126
|
+
base = "00000000-0000-0000-0000-000000000000"
|
|
127
|
+
elif fmt == "email":
|
|
128
|
+
base = "a@example.com"
|
|
129
|
+
else:
|
|
130
|
+
base = "x"
|
|
131
|
+
|
|
132
|
+
if len(base) >= min_len_i:
|
|
133
|
+
return base
|
|
134
|
+
return base + ("x" * (min_len_i - len(base)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _number_example(schema: dict, *, is_int: bool) -> int | float:
|
|
138
|
+
for k in ("default", "example"):
|
|
139
|
+
if k in schema:
|
|
140
|
+
v = schema.get(k)
|
|
141
|
+
if isinstance(v, (int, float)):
|
|
142
|
+
return int(v) if is_int else float(v)
|
|
143
|
+
|
|
144
|
+
minimum = schema.get("minimum")
|
|
145
|
+
exclusive_minimum = schema.get("exclusiveMinimum")
|
|
146
|
+
|
|
147
|
+
if isinstance(exclusive_minimum, (int, float)):
|
|
148
|
+
x = exclusive_minimum + 1
|
|
149
|
+
return int(x) if is_int else float(x)
|
|
150
|
+
|
|
151
|
+
if isinstance(minimum, (int, float)):
|
|
152
|
+
return int(minimum) if is_int else float(minimum)
|
|
153
|
+
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_minimal_valid_instance(schema: dict, *, resolver=None, max_depth: int = 6):
|
|
158
|
+
if max_depth <= 0:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if resolver and isinstance(schema, dict) and "$ref" in schema:
|
|
162
|
+
schema = _resolve_ref(schema, resolver=resolver)
|
|
163
|
+
|
|
164
|
+
if not isinstance(schema, dict):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
schema = _choose_subschema(schema)
|
|
168
|
+
|
|
169
|
+
if "const" in schema:
|
|
170
|
+
return schema["const"]
|
|
171
|
+
|
|
172
|
+
t = _schema_type(schema)
|
|
173
|
+
|
|
174
|
+
if t == "object":
|
|
175
|
+
props = schema.get("properties") or {}
|
|
176
|
+
req = schema.get("required") or []
|
|
177
|
+
out: dict[str, Any] = {}
|
|
178
|
+
|
|
179
|
+
if isinstance(req, list):
|
|
180
|
+
for k in req:
|
|
181
|
+
if k in props and isinstance(props.get(k), dict):
|
|
182
|
+
out[str(k)] = build_minimal_valid_instance(props[k], resolver=resolver, max_depth=max_depth - 1)
|
|
183
|
+
|
|
184
|
+
# If nothing required, pick the first property to make something non-empty
|
|
185
|
+
if not out and isinstance(props, dict) and props:
|
|
186
|
+
first = next(iter(props.keys()))
|
|
187
|
+
if isinstance(props.get(first), dict):
|
|
188
|
+
out[str(first)] = build_minimal_valid_instance(props[first], resolver=resolver, max_depth=max_depth - 1)
|
|
189
|
+
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
if t == "array":
|
|
193
|
+
items = schema.get("items")
|
|
194
|
+
if not isinstance(items, dict):
|
|
195
|
+
return []
|
|
196
|
+
min_items = schema.get("minItems")
|
|
197
|
+
try:
|
|
198
|
+
n = int(min_items) if min_items is not None else 1
|
|
199
|
+
except Exception:
|
|
200
|
+
n = 1
|
|
201
|
+
n = max(0, min(n, 3))
|
|
202
|
+
return [build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1) for _ in range(n)]
|
|
203
|
+
|
|
204
|
+
if t == "string":
|
|
205
|
+
return _string_example(schema)
|
|
206
|
+
|
|
207
|
+
if t == "integer":
|
|
208
|
+
return int(_number_example(schema, is_int=True))
|
|
209
|
+
|
|
210
|
+
if t == "number":
|
|
211
|
+
return float(_number_example(schema, is_int=False))
|
|
212
|
+
|
|
213
|
+
if t == "boolean":
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
if t == "null":
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# unknown schema, best effort
|
|
220
|
+
if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
|
|
221
|
+
return schema["enum"][0]
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ----------------------------
|
|
227
|
+
# Case generation
|
|
228
|
+
# ----------------------------
|
|
229
|
+
|
|
230
|
+
def _validate(schema: dict, instance, *, spec: dict) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
import jsonschema # type: ignore
|
|
233
|
+
|
|
234
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
235
|
+
cls = jsonschema.validators.validator_for(schema) # type: ignore[attr-defined]
|
|
236
|
+
cls.check_schema(schema)
|
|
237
|
+
v = cls(schema, resolver=resolver)
|
|
238
|
+
v.validate(instance)
|
|
239
|
+
return True
|
|
240
|
+
except Exception:
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def generate_request_body_fuzz_cases(
|
|
245
|
+
*,
|
|
246
|
+
spec: dict,
|
|
247
|
+
path: str,
|
|
248
|
+
method: str,
|
|
249
|
+
max_valid: int = 8,
|
|
250
|
+
max_invalid: int = 8,
|
|
251
|
+
max_depth: int = 6,
|
|
252
|
+
) -> list[FuzzCase]:
|
|
253
|
+
"""Generate fuzz cases for an operation requestBody schema.
|
|
254
|
+
|
|
255
|
+
Requires optional dependency `jsonschema` (installed via ai-testing-swarm[openapi]).
|
|
256
|
+
Returns [] if schema missing or jsonschema not available.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
import jsonschema # type: ignore
|
|
261
|
+
except ImportError:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
schema = get_request_body_schema(spec=spec, path=path, method=method)
|
|
265
|
+
if not schema:
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
269
|
+
|
|
270
|
+
base = build_minimal_valid_instance(schema, resolver=resolver, max_depth=max_depth)
|
|
271
|
+
cases: list[FuzzCase] = []
|
|
272
|
+
|
|
273
|
+
# Always include a minimal valid payload
|
|
274
|
+
if _validate(schema, base, spec=spec):
|
|
275
|
+
cases.append(FuzzCase(name="openapi_valid_minimal", body=base, is_valid=True))
|
|
276
|
+
|
|
277
|
+
# Valid edge-ish variants
|
|
278
|
+
def add_valid(name: str, body, details: dict | None = None):
|
|
279
|
+
if len([c for c in cases if c.is_valid]) >= max_valid:
|
|
280
|
+
return
|
|
281
|
+
if _validate(schema, body, spec=spec):
|
|
282
|
+
cases.append(FuzzCase(name=name, body=body, is_valid=True, details=details))
|
|
283
|
+
|
|
284
|
+
def add_invalid(name: str, body, details: dict | None = None):
|
|
285
|
+
if len([c for c in cases if not c.is_valid]) >= max_invalid:
|
|
286
|
+
return
|
|
287
|
+
# Ensure invalid (best-effort)
|
|
288
|
+
if not _validate(schema, body, spec=spec):
|
|
289
|
+
cases.append(FuzzCase(name=name, body=body, is_valid=False, details=details))
|
|
290
|
+
|
|
291
|
+
# Only attempt structured mutations for object bodies
|
|
292
|
+
schema2 = _choose_subschema(_resolve_ref(schema, resolver=resolver) if isinstance(schema, dict) else {})
|
|
293
|
+
if _schema_type(schema2) == "object" and isinstance(base, dict):
|
|
294
|
+
props = schema2.get("properties") or {}
|
|
295
|
+
required = schema2.get("required") or []
|
|
296
|
+
additional = schema2.get("additionalProperties")
|
|
297
|
+
|
|
298
|
+
# Required field removal (invalid)
|
|
299
|
+
if isinstance(required, list) and required:
|
|
300
|
+
k = str(required[0])
|
|
301
|
+
if k in base:
|
|
302
|
+
b2 = dict(base)
|
|
303
|
+
b2.pop(k, None)
|
|
304
|
+
add_invalid("openapi_invalid_missing_required", b2, {"missing": k})
|
|
305
|
+
|
|
306
|
+
# Extra field when additionalProperties is false (invalid)
|
|
307
|
+
if additional is False:
|
|
308
|
+
b2 = dict(base)
|
|
309
|
+
b2["_unexpected"] = "x"
|
|
310
|
+
add_invalid("openapi_invalid_additional_property", b2)
|
|
311
|
+
|
|
312
|
+
# Per-property boundary tweaks
|
|
313
|
+
if isinstance(props, dict):
|
|
314
|
+
for pk, ps in list(props.items())[: max(1, (max_valid + max_invalid))]:
|
|
315
|
+
if not isinstance(ps, dict):
|
|
316
|
+
continue
|
|
317
|
+
ps = _choose_subschema(_resolve_ref(ps, resolver=resolver))
|
|
318
|
+
t = _schema_type(ps)
|
|
319
|
+
|
|
320
|
+
if t == "string":
|
|
321
|
+
min_len = ps.get("minLength")
|
|
322
|
+
max_len = ps.get("maxLength")
|
|
323
|
+
enum = ps.get("enum")
|
|
324
|
+
|
|
325
|
+
if isinstance(enum, list) and enum:
|
|
326
|
+
add_valid(f"openapi_valid_enum_{pk}", {**base, pk: enum[-1]})
|
|
327
|
+
|
|
328
|
+
if isinstance(min_len, int) and min_len >= 0:
|
|
329
|
+
add_valid(f"openapi_valid_minLength_{pk}", {**base, pk: "x" * min_len})
|
|
330
|
+
if min_len > 0:
|
|
331
|
+
add_invalid(f"openapi_invalid_below_minLength_{pk}", {**base, pk: ""})
|
|
332
|
+
|
|
333
|
+
if isinstance(max_len, int) and max_len >= 0:
|
|
334
|
+
add_valid(f"openapi_valid_maxLength_{pk}", {**base, pk: "x" * max_len})
|
|
335
|
+
add_invalid(f"openapi_invalid_above_maxLength_{pk}", {**base, pk: "x" * (max_len + 1)})
|
|
336
|
+
|
|
337
|
+
# wrong type
|
|
338
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: 123})
|
|
339
|
+
|
|
340
|
+
elif t in {"integer", "number"}:
|
|
341
|
+
minimum = ps.get("minimum")
|
|
342
|
+
maximum = ps.get("maximum")
|
|
343
|
+
if isinstance(minimum, (int, float)):
|
|
344
|
+
add_valid(f"openapi_valid_min_{pk}", {**base, pk: minimum})
|
|
345
|
+
add_invalid(f"openapi_invalid_below_min_{pk}", {**base, pk: minimum - 1})
|
|
346
|
+
if isinstance(maximum, (int, float)):
|
|
347
|
+
add_valid(f"openapi_valid_max_{pk}", {**base, pk: maximum})
|
|
348
|
+
add_invalid(f"openapi_invalid_above_max_{pk}", {**base, pk: maximum + 1})
|
|
349
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "x"})
|
|
350
|
+
|
|
351
|
+
elif t == "boolean":
|
|
352
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "true"})
|
|
353
|
+
|
|
354
|
+
elif t == "array":
|
|
355
|
+
items = ps.get("items") if isinstance(ps.get("items"), dict) else None
|
|
356
|
+
min_items = ps.get("minItems")
|
|
357
|
+
max_items = ps.get("maxItems")
|
|
358
|
+
if items:
|
|
359
|
+
item_ex = build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1)
|
|
360
|
+
else:
|
|
361
|
+
item_ex = "x"
|
|
362
|
+
|
|
363
|
+
if isinstance(min_items, int) and min_items > 0:
|
|
364
|
+
add_valid(f"openapi_valid_minItems_{pk}", {**base, pk: [item_ex for _ in range(min_items)]})
|
|
365
|
+
add_invalid(f"openapi_invalid_below_minItems_{pk}", {**base, pk: []})
|
|
366
|
+
|
|
367
|
+
if isinstance(max_items, int) and max_items >= 0:
|
|
368
|
+
add_valid(f"openapi_valid_maxItems_{pk}", {**base, pk: [item_ex for _ in range(max_items)]})
|
|
369
|
+
add_invalid(
|
|
370
|
+
f"openapi_invalid_above_maxItems_{pk}",
|
|
371
|
+
{**base, pk: [item_ex for _ in range(max_items + 1)]},
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "not-an-array"})
|
|
375
|
+
|
|
376
|
+
# Ensure we have at least one invalid case
|
|
377
|
+
add_invalid("openapi_invalid_body_null", None)
|
|
378
|
+
|
|
379
|
+
else:
|
|
380
|
+
# For non-object bodies, do a couple generic cases
|
|
381
|
+
add_invalid("openapi_invalid_body_null", None)
|
|
382
|
+
add_invalid("openapi_invalid_wrong_type", {"_": "x"} if not isinstance(base, dict) else "x")
|
|
383
|
+
|
|
384
|
+
# De-dupe by name
|
|
385
|
+
seen: set[str] = set()
|
|
386
|
+
out: list[FuzzCase] = []
|
|
387
|
+
for c in cases:
|
|
388
|
+
if c.name in seen:
|
|
389
|
+
continue
|
|
390
|
+
seen.add(c.name)
|
|
391
|
+
out.append(c)
|
|
392
|
+
|
|
393
|
+
# Cap total
|
|
394
|
+
valid = [c for c in out if c.is_valid][:max_valid]
|
|
395
|
+
invalid = [c for c in out if not c.is_valid][:max_invalid]
|
|
396
|
+
return valid + invalid
|
|
@@ -54,12 +54,18 @@ class SwarmOrchestrator:
|
|
|
54
54
|
gate_warn: int = 30,
|
|
55
55
|
gate_block: int = 80,
|
|
56
56
|
run_label: str | None = None,
|
|
57
|
+
fail_on_regression: bool = False,
|
|
58
|
+
return_report_path: bool = False,
|
|
57
59
|
):
|
|
58
|
-
"""Runs the full AI testing swarm
|
|
60
|
+
"""Runs the full AI testing swarm.
|
|
59
61
|
|
|
60
62
|
gate_warn/gate_block:
|
|
61
63
|
Thresholds for PASS/WARN/BLOCK gate based on endpoint risk.
|
|
62
64
|
(Kept optional for backward compatibility.)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
- default: (decision, results)
|
|
68
|
+
- if return_report_path=True: (decision, results, report_path)
|
|
63
69
|
"""
|
|
64
70
|
|
|
65
71
|
# Safety hook (currently no-op; kept for backward compatibility)
|
|
@@ -149,6 +155,9 @@ class SwarmOrchestrator:
|
|
|
149
155
|
if run_label:
|
|
150
156
|
meta["run_label"] = str(run_label)
|
|
151
157
|
|
|
158
|
+
if fail_on_regression:
|
|
159
|
+
meta["fail_on_regression"] = True
|
|
160
|
+
|
|
152
161
|
# Optional AI summary for humans (best-effort)
|
|
153
162
|
try:
|
|
154
163
|
from ai_testing_swarm.core.config import AI_SWARM_AI_SUMMARY, AI_SWARM_OPENAI_MODEL, AI_SWARM_USE_OPENAI
|
|
@@ -169,4 +178,6 @@ class SwarmOrchestrator:
|
|
|
169
178
|
logger.info("📄 Swarm report written to: %s", report_path)
|
|
170
179
|
print(f"📄 Swarm report written to: {report_path}")
|
|
171
180
|
|
|
181
|
+
if return_report_path:
|
|
182
|
+
return decision, results, report_path
|
|
172
183
|
return decision, results
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-testing-swarm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.17
|
|
4
4
|
Summary: AI-powered testing swarm
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -119,6 +119,10 @@ Reports include:
|
|
|
119
119
|
- Base URL is read from `spec.servers[0].url`.
|
|
120
120
|
- When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
|
|
121
121
|
- If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
|
|
122
|
+
- If an OpenAPI requestBody schema exists for the operation, the planner can also generate **schema-based request-body fuzz cases**:
|
|
123
|
+
- schema-valid edge cases (boundary values that should still validate)
|
|
124
|
+
- schema-invalid variants (missing required fields, wrong types, out-of-range, etc.)
|
|
125
|
+
- Controlled via CLI flags like `--no-openapi-fuzz` / `--openapi-fuzz-max-*`.
|
|
122
126
|
- Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
|
|
123
127
|
|
|
124
128
|
---
|
|
@@ -223,6 +227,16 @@ Reports include:
|
|
|
223
227
|
A static dashboard index is generated at:
|
|
224
228
|
- `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
|
|
225
229
|
|
|
230
|
+
### Regression gate (CI)
|
|
231
|
+
|
|
232
|
+
To fail CI when the run regresses vs the previous JSON report for the same endpoint:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
ai-test --input request.json --fail-on-regression
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
This checks `report.trend.regression_count` and exits with a non-zero code if regressions are detected.
|
|
239
|
+
|
|
226
240
|
SLA threshold:
|
|
227
241
|
- `AI_SWARM_SLA_MS` (default: `2000`)
|
|
228
242
|
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/SOURCES.txt
RENAMED
|
@@ -23,6 +23,7 @@ src/ai_testing_swarm/core/auth_matrix.py
|
|
|
23
23
|
src/ai_testing_swarm/core/config.py
|
|
24
24
|
src/ai_testing_swarm/core/curl_parser.py
|
|
25
25
|
src/ai_testing_swarm/core/openai_client.py
|
|
26
|
+
src/ai_testing_swarm/core/openapi_fuzzer.py
|
|
26
27
|
src/ai_testing_swarm/core/openapi_loader.py
|
|
27
28
|
src/ai_testing_swarm/core/openapi_validator.py
|
|
28
29
|
src/ai_testing_swarm/core/risk.py
|
|
@@ -32,6 +33,7 @@ src/ai_testing_swarm/reporting/dashboard.py
|
|
|
32
33
|
src/ai_testing_swarm/reporting/report_writer.py
|
|
33
34
|
src/ai_testing_swarm/reporting/trend.py
|
|
34
35
|
tests/test_batch2_trend_and_auth.py
|
|
36
|
+
tests/test_openapi_fuzzer.py
|
|
35
37
|
tests/test_openapi_loader.py
|
|
36
38
|
tests/test_openapi_validator.py
|
|
37
39
|
tests/test_policy_expected_negatives.py
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _spec():
|
|
5
|
+
return {
|
|
6
|
+
"openapi": "3.0.0",
|
|
7
|
+
"servers": [{"url": "https://example.com"}],
|
|
8
|
+
"paths": {
|
|
9
|
+
"/pets": {
|
|
10
|
+
"post": {
|
|
11
|
+
"requestBody": {
|
|
12
|
+
"required": True,
|
|
13
|
+
"content": {
|
|
14
|
+
"application/json": {
|
|
15
|
+
"schema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": False,
|
|
18
|
+
"required": ["name", "age", "tags"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": {"type": "string", "minLength": 2, "maxLength": 5},
|
|
21
|
+
"age": {"type": "integer", "minimum": 0, "maximum": 120},
|
|
22
|
+
"tags": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"minItems": 1,
|
|
25
|
+
"maxItems": 2,
|
|
26
|
+
"items": {"type": "string", "minLength": 1},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
"responses": {"200": {"description": "ok"}},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_openapi_fuzzer_generates_valid_and_invalid_cases():
|
|
41
|
+
jsonschema = pytest.importorskip("jsonschema")
|
|
42
|
+
|
|
43
|
+
from ai_testing_swarm.core.openapi_fuzzer import (
|
|
44
|
+
generate_request_body_fuzz_cases,
|
|
45
|
+
get_request_body_schema,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
spec = _spec()
|
|
49
|
+
schema = get_request_body_schema(spec=spec, path="/pets", method="POST")
|
|
50
|
+
assert schema and schema.get("type") == "object"
|
|
51
|
+
|
|
52
|
+
cases = generate_request_body_fuzz_cases(spec=spec, path="/pets", method="POST", max_valid=10, max_invalid=10)
|
|
53
|
+
assert any(c.is_valid for c in cases)
|
|
54
|
+
assert any(not c.is_valid for c in cases)
|
|
55
|
+
|
|
56
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
57
|
+
cls = jsonschema.validators.validator_for(schema) # type: ignore[attr-defined]
|
|
58
|
+
v = cls(schema, resolver=resolver)
|
|
59
|
+
|
|
60
|
+
for c in cases:
|
|
61
|
+
if c.is_valid:
|
|
62
|
+
v.validate(c.body)
|
|
63
|
+
else:
|
|
64
|
+
with pytest.raises(Exception):
|
|
65
|
+
v.validate(c.body)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_planner_includes_openapi_fuzz_cases(monkeypatch):
|
|
69
|
+
pytest.importorskip("jsonschema")
|
|
70
|
+
|
|
71
|
+
from ai_testing_swarm.agents.test_planner_agent import TestPlannerAgent
|
|
72
|
+
import ai_testing_swarm.core.config as config
|
|
73
|
+
|
|
74
|
+
# Ensure enabled in this test regardless of env at import time
|
|
75
|
+
monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ", True)
|
|
76
|
+
monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_VALID", 3)
|
|
77
|
+
monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_INVALID", 3)
|
|
78
|
+
monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH", 5)
|
|
79
|
+
|
|
80
|
+
request = {
|
|
81
|
+
"method": "POST",
|
|
82
|
+
"url": "https://example.com/pets",
|
|
83
|
+
"headers": {"content-type": "application/json"},
|
|
84
|
+
"params": {},
|
|
85
|
+
"body": {},
|
|
86
|
+
"_openapi": {"spec": _spec(), "path": "/pets", "method": "POST", "source": "inline"},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
tests = TestPlannerAgent().plan(request)
|
|
90
|
+
names = {t.get("name") for t in tests}
|
|
91
|
+
assert any(str(n).startswith("openapi_") for n in names)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.16"
|
|
File without changes
|
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/execution_agent.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/learning_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/test_writer_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/auth_matrix.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/curl_parser.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openai_client.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_loader.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_validator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/__init__.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/dashboard.py
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/report_writer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/requires.txt
RENAMED
|
File without changes
|
{ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|