specforge-api 0.1.5__tar.gz → 0.1.6__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.
- {specforge_api-0.1.5 → specforge_api-0.1.6}/PKG-INFO +1 -1
- {specforge_api-0.1.5 → specforge_api-0.1.6}/pyproject.toml +1 -1
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/__init__.py +1 -1
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/adapters/fastapi.py +36 -4
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/adapters/flask.py +7 -1
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/assets/index.html +164 -134
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/cli/main.py +14 -2
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/openapi3.py +2 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/exporters/pytest_export.py +18 -5
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/invariants.py +45 -3
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/planner.py +33 -1
- {specforge_api-0.1.5 → specforge_api-0.1.6}/.gitignore +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/LICENSE +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/README.md +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/config.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/models.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/resolver.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
- {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/utils/html_injector.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: specforge-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: The interactive API client and automated contract testing engine for Python.
|
|
5
5
|
Project-URL: Homepage, https://github.com/specforge-api/specforge
|
|
6
6
|
Project-URL: Documentation, https://specforge-api.github.io/specforge/
|
|
@@ -5,7 +5,7 @@ from specforge.parser.openapi3 import OpenAPIParser
|
|
|
5
5
|
from specforge.testing.exporters.pytest_export import PytestExporter
|
|
6
6
|
from specforge.testing.planner import TestPlanner
|
|
7
7
|
|
|
8
|
-
__version__ = "0.1.
|
|
8
|
+
__version__ = "0.1.6"
|
|
9
9
|
|
|
10
10
|
# Conditionally import FastAPI adapter if available
|
|
11
11
|
try:
|
|
@@ -15,6 +15,12 @@ from specforge.testing.exporters.pytest_export import PytestExporter
|
|
|
15
15
|
from specforge.testing.planner import TestPlanner
|
|
16
16
|
from specforge.utils.html_injector import render_html
|
|
17
17
|
|
|
18
|
+
_SECURITY_HEADERS = {
|
|
19
|
+
"X-Content-Type-Options": "nosniff",
|
|
20
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
21
|
+
"Referrer-Policy": "no-referrer",
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
class SpecForge:
|
|
20
26
|
"""Mounts the SpecForge interactive client and boundary test suite onto a FastAPI application."""
|
|
@@ -27,7 +33,7 @@ class SpecForge:
|
|
|
27
33
|
spec_url: Optional[str] = None,
|
|
28
34
|
theme: str = "dark",
|
|
29
35
|
enable_tests: bool = True,
|
|
30
|
-
disable_default_docs: bool =
|
|
36
|
+
disable_default_docs: bool = True,
|
|
31
37
|
enable_api_endpoints: bool = True,
|
|
32
38
|
dependencies: Optional[Sequence[Any]] = None,
|
|
33
39
|
api_dependencies: Optional[Sequence[Any]] = None,
|
|
@@ -54,11 +60,37 @@ class SpecForge:
|
|
|
54
60
|
**kwargs,
|
|
55
61
|
)
|
|
56
62
|
|
|
57
|
-
if disable_default_docs
|
|
58
|
-
|
|
63
|
+
if disable_default_docs:
|
|
64
|
+
self._remove_conflicting_docs_routes()
|
|
59
65
|
|
|
60
66
|
self._register_routes()
|
|
61
67
|
|
|
68
|
+
def _remove_conflicting_docs_routes(self) -> None:
|
|
69
|
+
"""Remove FastAPI's pre-registered docs routes that conflict with SpecForge."""
|
|
70
|
+
default_docs_url = getattr(self.app, "docs_url", None)
|
|
71
|
+
if default_docs_url:
|
|
72
|
+
self.app.docs_url = None
|
|
73
|
+
if getattr(self.app, "redoc_url", None) == self.path:
|
|
74
|
+
self.app.redoc_url = None
|
|
75
|
+
|
|
76
|
+
router = getattr(self.app, "router", None)
|
|
77
|
+
if router is not None and hasattr(router, "routes"):
|
|
78
|
+
router.routes = [
|
|
79
|
+
route
|
|
80
|
+
for route in router.routes
|
|
81
|
+
if not (
|
|
82
|
+
"GET" in (getattr(route, "methods", None) or set())
|
|
83
|
+
and (
|
|
84
|
+
getattr(route, "path", None) == self.path
|
|
85
|
+
or (
|
|
86
|
+
default_docs_url is not None
|
|
87
|
+
and getattr(route, "path", None) == default_docs_url
|
|
88
|
+
and getattr(route, "name", None) in ("swagger_ui_html", "swagger_ui_redirect")
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
62
94
|
def _register_routes(self) -> None:
|
|
63
95
|
"""Register the documentation UI and test plan endpoints on the FastAPI app."""
|
|
64
96
|
|
|
@@ -70,7 +102,7 @@ class SpecForge:
|
|
|
70
102
|
)
|
|
71
103
|
async def specforge_docs_endpoint(request: Request) -> Response:
|
|
72
104
|
html = render_html(self.config)
|
|
73
|
-
return HTMLResponse(content=html)
|
|
105
|
+
return HTMLResponse(content=html, headers=_SECURITY_HEADERS)
|
|
74
106
|
|
|
75
107
|
if self.config.enable_api_endpoints:
|
|
76
108
|
@self.app.get(
|
|
@@ -17,6 +17,12 @@ from specforge.parser.openapi3 import OpenAPIParser
|
|
|
17
17
|
from specforge.testing.planner import TestPlanner
|
|
18
18
|
from specforge.utils.html_injector import render_html
|
|
19
19
|
|
|
20
|
+
_SECURITY_HEADERS = {
|
|
21
|
+
"X-Content-Type-Options": "nosniff",
|
|
22
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
23
|
+
"Referrer-Policy": "no-referrer",
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
class SpecForge:
|
|
22
28
|
"""Mounts the SpecForge interactive client and boundary test suite onto a Flask application."""
|
|
@@ -60,7 +66,7 @@ class SpecForge:
|
|
|
60
66
|
@bp.route(self.path, methods=["GET"])
|
|
61
67
|
def specforge_docs() -> Any:
|
|
62
68
|
html = render_html(self.config, self.spec_dict)
|
|
63
|
-
return Response(html, mimetype="text/html")
|
|
69
|
+
return Response(html, mimetype="text/html", headers=_SECURITY_HEADERS)
|
|
64
70
|
|
|
65
71
|
if self.config.enable_api_endpoints:
|
|
66
72
|
@bp.route("/specforge/api/plan", methods=["GET"])
|
|
@@ -1550,12 +1550,13 @@
|
|
|
1550
1550
|
const container = document.getElementById("endpoint-list");
|
|
1551
1551
|
container.innerHTML = "";
|
|
1552
1552
|
operations.forEach((op, index) => {
|
|
1553
|
+
const method = safeHttpMethod(op.method);
|
|
1553
1554
|
const item = document.createElement("div");
|
|
1554
1555
|
item.className = `endpoint-item ${index === selectedOpIndex ? 'active' : ''}`;
|
|
1555
1556
|
item.onclick = () => selectOperation(index);
|
|
1556
1557
|
item.innerHTML = `
|
|
1557
|
-
<span class="badge-method badge-${
|
|
1558
|
-
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${op.path}</span>
|
|
1558
|
+
<span class="badge-method badge-${method}">${escapeHtml(method)}</span>
|
|
1559
|
+
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(op.path)}</span>
|
|
1559
1560
|
`;
|
|
1560
1561
|
container.appendChild(item);
|
|
1561
1562
|
});
|
|
@@ -1685,10 +1686,10 @@
|
|
|
1685
1686
|
const tr = document.createElement("tr");
|
|
1686
1687
|
tr.innerHTML = `
|
|
1687
1688
|
<td style="width: 140px; font-weight: 600; font-family: var(--font-mono); font-size: 0.82rem; color: var(--primary);">
|
|
1688
|
-
{${p.name}}
|
|
1689
|
+
{${escapeHtml(p.name)}}
|
|
1689
1690
|
</td>
|
|
1690
1691
|
<td>
|
|
1691
|
-
<input type="text" class="kv-input" value="${p.value}" placeholder="Value..." oninput="onPathParamInput(${idx}, this.value)">
|
|
1692
|
+
<input type="text" class="kv-input" value="${escapeHtml(p.value)}" placeholder="Value..." oninput="onPathParamInput(${idx}, this.value)">
|
|
1692
1693
|
</td>
|
|
1693
1694
|
`;
|
|
1694
1695
|
tbody.appendChild(tr);
|
|
@@ -1718,10 +1719,10 @@
|
|
|
1718
1719
|
<input type="checkbox" ${q.enabled ? 'checked' : ''} onchange="toggleQueryParam(${idx})">
|
|
1719
1720
|
</td>
|
|
1720
1721
|
<td>
|
|
1721
|
-
<input type="text" class="kv-input" value="${q.key}" placeholder="Key" oninput="onQueryParamChange(${idx}, 'key', this.value)">
|
|
1722
|
+
<input type="text" class="kv-input" value="${escapeHtml(q.key)}" placeholder="Key" oninput="onQueryParamChange(${idx}, 'key', this.value)">
|
|
1722
1723
|
</td>
|
|
1723
1724
|
<td>
|
|
1724
|
-
<input type="text" class="kv-input" value="${q.value}" placeholder="Value" oninput="onQueryParamChange(${idx}, 'value', this.value)">
|
|
1725
|
+
<input type="text" class="kv-input" value="${escapeHtml(q.value)}" placeholder="Value" oninput="onQueryParamChange(${idx}, 'value', this.value)">
|
|
1725
1726
|
</td>
|
|
1726
1727
|
<td style="text-align: center;">
|
|
1727
1728
|
<button class="btn-icon" onclick="removeQueryParamRow(${idx})" title="Remove">✕</button>
|
|
@@ -1770,10 +1771,10 @@
|
|
|
1770
1771
|
<input type="checkbox" ${h.enabled ? 'checked' : ''} onchange="toggleHeader(${idx})">
|
|
1771
1772
|
</td>
|
|
1772
1773
|
<td>
|
|
1773
|
-
<input type="text" class="kv-input" value="${h.key}" placeholder="Header Name" oninput="onHeaderChange(${idx}, 'key', this.value)">
|
|
1774
|
+
<input type="text" class="kv-input" value="${escapeHtml(h.key)}" placeholder="Header Name" oninput="onHeaderChange(${idx}, 'key', this.value)">
|
|
1774
1775
|
</td>
|
|
1775
1776
|
<td>
|
|
1776
|
-
<input type="text" class="kv-input" value="${h.value}" placeholder="Value" oninput="onHeaderChange(${idx}, 'value', this.value)">
|
|
1777
|
+
<input type="text" class="kv-input" value="${escapeHtml(h.value)}" placeholder="Value" oninput="onHeaderChange(${idx}, 'value', this.value)">
|
|
1777
1778
|
</td>
|
|
1778
1779
|
<td style="text-align: center;">
|
|
1779
1780
|
<button class="btn-icon" onclick="removeHeaderRow(${idx})" title="Remove">✕</button>
|
|
@@ -1958,6 +1959,7 @@
|
|
|
1958
1959
|
if (!schema) return "value";
|
|
1959
1960
|
schema = resolveRef(schema);
|
|
1960
1961
|
if (schema.example !== undefined) return schema.example;
|
|
1962
|
+
if (Array.isArray(schema.examples) && schema.examples.length > 0) return schema.examples[0];
|
|
1961
1963
|
if (schema.default !== undefined) return schema.default;
|
|
1962
1964
|
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
|
|
1963
1965
|
if (schema.type === 'object' || schema.properties) {
|
|
@@ -2016,7 +2018,7 @@
|
|
|
2016
2018
|
method: op.method.toUpperCase(),
|
|
2017
2019
|
headers: headersObj
|
|
2018
2020
|
};
|
|
2019
|
-
if (payload) opts.body = JSON.stringify(payload);
|
|
2021
|
+
if (payload !== null && payload !== undefined) opts.body = JSON.stringify(payload);
|
|
2020
2022
|
|
|
2021
2023
|
const res = await fetch(resolvedPath, opts);
|
|
2022
2024
|
const elapsed = Math.round(performance.now() - startTime);
|
|
@@ -2073,13 +2075,14 @@
|
|
|
2073
2075
|
endpoint: plan.endpoint,
|
|
2074
2076
|
method: plan.method.toUpperCase(),
|
|
2075
2077
|
field: tc.field_name || tc.target_field || "payload",
|
|
2076
|
-
category: tc.invariant_type || tc.category || "Boundary",
|
|
2077
|
-
desc: tc.description || tc.name,
|
|
2078
|
+
category: tc.mutation_category || tc.invariant_type || tc.category || "Boundary",
|
|
2079
|
+
desc: tc.description || tc.mutation_name || tc.name,
|
|
2078
2080
|
payload: tc.payload,
|
|
2079
2081
|
headers: tc.headers || {},
|
|
2080
2082
|
queryParams: tc.query_params || {},
|
|
2081
2083
|
expected: tc.expected_status || 422,
|
|
2082
|
-
isNegative: tc.is_negative_test !== false
|
|
2084
|
+
isNegative: tc.is_negative_test !== false,
|
|
2085
|
+
allowAccepted: Boolean(tc.allow_accepted)
|
|
2083
2086
|
});
|
|
2084
2087
|
});
|
|
2085
2088
|
});
|
|
@@ -2248,6 +2251,13 @@
|
|
|
2248
2251
|
}
|
|
2249
2252
|
|
|
2250
2253
|
// --- Custom Field Test Probes (Pure Client-Side / LocalStorage) ---
|
|
2254
|
+
const ALLOWED_HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace']);
|
|
2255
|
+
|
|
2256
|
+
function safeHttpMethod(method) {
|
|
2257
|
+
const normalized = String(method || 'get').toLowerCase().trim();
|
|
2258
|
+
return ALLOWED_HTTP_METHODS.has(normalized) ? normalized : 'get';
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2251
2261
|
function escapeHtml(str) {
|
|
2252
2262
|
if (str === null || str === undefined) return "";
|
|
2253
2263
|
return String(str)
|
|
@@ -2260,7 +2270,27 @@
|
|
|
2260
2270
|
|
|
2261
2271
|
function escapeJs(str) {
|
|
2262
2272
|
if (!str) return "";
|
|
2263
|
-
return String(str)
|
|
2273
|
+
return String(str)
|
|
2274
|
+
.replace(/\\/g, "\\\\")
|
|
2275
|
+
.replace(/'/g, "\\'")
|
|
2276
|
+
.replace(/"/g, '\\"')
|
|
2277
|
+
.replace(/\n/g, "\\n")
|
|
2278
|
+
.replace(/\r/g, "\\r")
|
|
2279
|
+
.replace(/\u2028/g, "\\u2028")
|
|
2280
|
+
.replace(/\u2029/g, "\\u2029");
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function toPythonLiteral(val) {
|
|
2284
|
+
if (val === null || val === undefined) return "None";
|
|
2285
|
+
if (typeof val === "boolean") return val ? "True" : "False";
|
|
2286
|
+
if (typeof val === "number") return Number.isFinite(val) ? String(val) : "None";
|
|
2287
|
+
if (typeof val === "string") return JSON.stringify(val);
|
|
2288
|
+
if (Array.isArray(val)) return "[" + val.map(toPythonLiteral).join(", ") + "]";
|
|
2289
|
+
if (typeof val === "object") {
|
|
2290
|
+
const entries = Object.entries(val).map(([k, v]) => `${JSON.stringify(k)}: ${toPythonLiteral(v)}`);
|
|
2291
|
+
return "{" + entries.join(", ") + "}";
|
|
2292
|
+
}
|
|
2293
|
+
return "None";
|
|
2264
2294
|
}
|
|
2265
2295
|
|
|
2266
2296
|
function getCustomProbesFromStorage() {
|
|
@@ -2587,7 +2617,7 @@
|
|
|
2587
2617
|
const chevron = document.getElementById(`chevron-${safeId}`);
|
|
2588
2618
|
if (chevron) chevron.classList.remove("collapsed");
|
|
2589
2619
|
|
|
2590
|
-
const testBtns = body.querySelectorAll("
|
|
2620
|
+
const testBtns = body.querySelectorAll(".btn-run-probe");
|
|
2591
2621
|
for (const btn of testBtns) {
|
|
2592
2622
|
btn.click();
|
|
2593
2623
|
await new Promise(r => setTimeout(r, 60));
|
|
@@ -2626,7 +2656,7 @@
|
|
|
2626
2656
|
|
|
2627
2657
|
// Determine location tag
|
|
2628
2658
|
let locBadge = `<span style="font-size:0.68rem; font-weight:700; text-transform:uppercase; color:var(--muted-foreground); background:var(--secondary); border:1px solid var(--border); padding:0.1rem 0.35rem; border-radius:3px;">`;
|
|
2629
|
-
if (fieldName.startsWith('?')) locBadge += `Query`;
|
|
2659
|
+
if (fieldName.startsWith('?') || items.some(it => it.tc.queryParams && Object.keys(it.tc.queryParams).length > 0)) locBadge += `Query`;
|
|
2630
2660
|
else if (fieldName.startsWith('Header:')) locBadge += `Header`;
|
|
2631
2661
|
else if (fieldName.startsWith('Path')) locBadge += `Path`;
|
|
2632
2662
|
else locBadge += `Body`;
|
|
@@ -2640,13 +2670,13 @@
|
|
|
2640
2670
|
<div class="field-group-header" onclick="toggleFieldGroup('${safeId}')">
|
|
2641
2671
|
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
|
2642
2672
|
${locBadge}
|
|
2643
|
-
<span class="field-tag">${fieldName}</span>
|
|
2673
|
+
<span class="field-tag">${escapeHtml(fieldName)}</span>
|
|
2644
2674
|
<span class="sub-tab-badge" style="background: var(--accent); color: var(--accent-foreground); border: 1px solid var(--border); font-size: 0.7rem; font-weight: 600;">
|
|
2645
2675
|
${countText}
|
|
2646
2676
|
</span>
|
|
2647
2677
|
</div>
|
|
2648
2678
|
<div style="display: flex; align-items: center; gap: 0.4rem;">
|
|
2649
|
-
<button class="btn btn-outline" style="font-size: 0.68rem; padding: 0.16rem 0.45rem;"
|
|
2679
|
+
<button class="btn btn-outline btn-add-custom-probe" style="font-size: 0.68rem; padding: 0.16rem 0.45rem;" title="Add custom probe for this field">
|
|
2650
2680
|
+ Add Test
|
|
2651
2681
|
</button>
|
|
2652
2682
|
<button class="btn btn-outline-primary" style="font-size: 0.7rem; padding: 0.18rem 0.5rem;" onclick="event.stopPropagation(); runFieldGroupProbes('${safeId}')" title="Run all tests for this field">
|
|
@@ -2659,17 +2689,29 @@
|
|
|
2659
2689
|
</div>
|
|
2660
2690
|
`;
|
|
2661
2691
|
|
|
2692
|
+
const addCustomBtn = groupCard.querySelector(".btn-add-custom-probe");
|
|
2693
|
+
if (addCustomBtn) {
|
|
2694
|
+
addCustomBtn.addEventListener("click", (e) => {
|
|
2695
|
+
e.stopPropagation();
|
|
2696
|
+
openAddCustomTestModal(fieldName);
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2662
2700
|
const bodyEl = groupCard.querySelector(`#body-${safeId}`);
|
|
2663
2701
|
|
|
2664
2702
|
items.forEach(({ tc, globalIndex }) => {
|
|
2665
2703
|
let valPreview = "";
|
|
2666
|
-
if (tc.
|
|
2704
|
+
if (tc.queryParams && Object.keys(tc.queryParams).length > 0) {
|
|
2705
|
+
valPreview = JSON.stringify(tc.queryParams);
|
|
2706
|
+
} else if (tc.payload !== null && tc.payload !== undefined && typeof tc.payload === 'object' && tc.field !== '[body_root]') {
|
|
2667
2707
|
const targetVal = tc.payload[tc.field];
|
|
2668
2708
|
if (targetVal !== undefined) {
|
|
2669
2709
|
valPreview = JSON.stringify(targetVal);
|
|
2670
2710
|
} else {
|
|
2671
2711
|
valPreview = `(Field omitted)`;
|
|
2672
2712
|
}
|
|
2713
|
+
} else if (tc.payload !== null && tc.payload !== undefined) {
|
|
2714
|
+
valPreview = JSON.stringify(tc.payload);
|
|
2673
2715
|
} else if (tc.field.startsWith('?')) {
|
|
2674
2716
|
const qVal = tc.endpoint.split('?')[1] || '';
|
|
2675
2717
|
valPreview = decodeURIComponent(qVal);
|
|
@@ -2693,19 +2735,27 @@
|
|
|
2693
2735
|
</div>
|
|
2694
2736
|
</div>
|
|
2695
2737
|
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 0.2rem;">
|
|
2696
|
-
<span style="font-size: 0.74rem; color: var(--muted-foreground);">Expected: <strong style="color: var(--foreground);">${tc.expected}</strong></span>
|
|
2738
|
+
<span style="font-size: 0.74rem; color: var(--muted-foreground);">Expected: <strong style="color: var(--foreground);">${tc.allowAccepted ? '2xx / 4xx (No 500)' : escapeHtml(tc.expected)}</strong></span>
|
|
2697
2739
|
<div id="route-probe-res-${globalIndex}"><span class="pill pill-planned">READY</span></div>
|
|
2698
2740
|
</div>
|
|
2699
|
-
<div style="display: flex; gap: 0.35rem; justify-content: flex-end; align-items: center;">
|
|
2700
|
-
<button class="btn btn-outline-primary" style="font-size: 0.72rem; padding: 0.2rem 0.55rem;" onclick="runSingleRouteProbe(${globalIndex}, 'route-probe-res-${globalIndex}')">
|
|
2741
|
+
<div class="probe-actions" style="display: flex; gap: 0.35rem; justify-content: flex-end; align-items: center;">
|
|
2742
|
+
<button class="btn btn-outline-primary btn-run-probe" style="font-size: 0.72rem; padding: 0.2rem 0.55rem;" onclick="runSingleRouteProbe(${globalIndex}, 'route-probe-res-${globalIndex}')">
|
|
2701
2743
|
▶ Test
|
|
2702
2744
|
</button>
|
|
2703
2745
|
<button class="btn-icon" onclick="inspectSingleRouteProbe(${globalIndex})" title="Inspect payload">
|
|
2704
2746
|
🔍
|
|
2705
2747
|
</button>
|
|
2706
|
-
${tc.isCustom ? `<button class="btn-icon" onclick="deleteCustomProbe('${escapeJs(tc.id)}')" title="Delete custom probe" style="color:var(--destructive); font-size: 0.8rem; padding: 0.2rem 0.35rem;">🗑️</button>` : ''}
|
|
2707
2748
|
</div>
|
|
2708
2749
|
`;
|
|
2750
|
+
if (tc.isCustom) {
|
|
2751
|
+
const delBtn = document.createElement("button");
|
|
2752
|
+
delBtn.className = "btn-icon";
|
|
2753
|
+
delBtn.title = "Delete custom probe";
|
|
2754
|
+
delBtn.style.cssText = "color:var(--destructive); font-size: 0.8rem; padding: 0.2rem 0.35rem;";
|
|
2755
|
+
delBtn.textContent = "🗑️";
|
|
2756
|
+
delBtn.addEventListener("click", () => deleteCustomProbe(tc.id));
|
|
2757
|
+
itemEl.querySelector(".probe-actions").appendChild(delBtn);
|
|
2758
|
+
}
|
|
2709
2759
|
bodyEl.appendChild(itemEl);
|
|
2710
2760
|
});
|
|
2711
2761
|
|
|
@@ -2713,27 +2763,31 @@
|
|
|
2713
2763
|
});
|
|
2714
2764
|
}
|
|
2715
2765
|
|
|
2716
|
-
async function
|
|
2717
|
-
const op = operations[selectedOpIndex];
|
|
2718
|
-
if (!op) return;
|
|
2719
|
-
const routeCases = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path);
|
|
2720
|
-
const tc = routeCases[idx];
|
|
2721
|
-
if (!tc) return;
|
|
2722
|
-
|
|
2723
|
-
const resEl = document.getElementById(resultElementId);
|
|
2724
|
-
if (resEl) resEl.innerHTML = `<span style="font-size:0.75rem; color: var(--text-muted);">Testing...</span>`;
|
|
2725
|
-
|
|
2766
|
+
async function executeProbeFetch(tc) {
|
|
2726
2767
|
const start = performance.now();
|
|
2727
2768
|
let status = 0;
|
|
2728
2769
|
let respText = "";
|
|
2729
2770
|
try {
|
|
2730
|
-
const
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2771
|
+
const method = safeHttpMethod(tc.method).toUpperCase();
|
|
2772
|
+
const headers = { ...(tc.headers || {}) };
|
|
2773
|
+
const hasBody = tc.payload !== null && tc.payload !== undefined && !["GET", "HEAD"].includes(method);
|
|
2774
|
+
if (hasBody && !Object.keys(headers).some(k => k.toLowerCase() === "content-type")) {
|
|
2775
|
+
headers["Content-Type"] = "application/json";
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
let targetUrl = tc.endpoint;
|
|
2779
|
+
if (tc.queryParams && Object.keys(tc.queryParams).length > 0) {
|
|
2780
|
+
const qs = new URLSearchParams();
|
|
2781
|
+
Object.entries(tc.queryParams).forEach(([k, v]) => qs.append(k, String(v)));
|
|
2782
|
+
targetUrl += (targetUrl.includes("?") ? "&" : "?") + qs.toString();
|
|
2783
|
+
}
|
|
2735
2784
|
|
|
2736
|
-
const
|
|
2785
|
+
const opts = { method, headers };
|
|
2786
|
+
if (hasBody) {
|
|
2787
|
+
opts.body = JSON.stringify(tc.payload);
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
const res = await fetch(targetUrl, opts);
|
|
2737
2791
|
status = res.status;
|
|
2738
2792
|
respText = await res.text();
|
|
2739
2793
|
} catch (e) {
|
|
@@ -2741,25 +2795,49 @@
|
|
|
2741
2795
|
respText = e.message;
|
|
2742
2796
|
}
|
|
2743
2797
|
const latency = Math.round(performance.now() - start);
|
|
2798
|
+
return { status, respText, latency };
|
|
2799
|
+
}
|
|
2744
2800
|
|
|
2745
|
-
|
|
2746
|
-
let statusLabel = "PASS";
|
|
2801
|
+
function classifyProbeResult(tc, status) {
|
|
2747
2802
|
if (status >= 500) {
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2803
|
+
return { kind: "crash", pillClass: "pill-crash", statusLabel: "CRASH (500)" };
|
|
2804
|
+
}
|
|
2805
|
+
if (tc.allowAccepted) {
|
|
2806
|
+
if ((status >= 200 && status < 300) || status === 400 || status === 422) {
|
|
2807
|
+
return { kind: "pass", pillClass: "pill-pass", statusLabel: `PASS (${status})` };
|
|
2808
|
+
}
|
|
2809
|
+
return { kind: "other", pillClass: "pill-leak", statusLabel: `${status}` };
|
|
2810
|
+
}
|
|
2811
|
+
if (tc.isNegative === false) {
|
|
2812
|
+
if (status >= 200 && status < 300) {
|
|
2813
|
+
return { kind: "pass", pillClass: "pill-pass", statusLabel: `PASS (${status})` };
|
|
2814
|
+
}
|
|
2815
|
+
return { kind: "other", pillClass: "pill-leak", statusLabel: `FAIL (${status})` };
|
|
2759
2816
|
}
|
|
2817
|
+
if (status >= 200 && status < 300) {
|
|
2818
|
+
return { kind: "leak", pillClass: "pill-leak", statusLabel: `LEAK (${status})` };
|
|
2819
|
+
}
|
|
2820
|
+
if (status === 400 || status === 422 || status === 401) {
|
|
2821
|
+
return { kind: "pass", pillClass: "pill-pass", statusLabel: `PASS (${status})` };
|
|
2822
|
+
}
|
|
2823
|
+
return { kind: "other", pillClass: "pill-leak", statusLabel: `${status}` };
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
async function runSingleRouteProbe(idx, resultElementId) {
|
|
2827
|
+
const op = operations[selectedOpIndex];
|
|
2828
|
+
if (!op) return;
|
|
2829
|
+
const routeCases = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path);
|
|
2830
|
+
const tc = routeCases[idx];
|
|
2831
|
+
if (!tc) return;
|
|
2832
|
+
|
|
2833
|
+
const resEl = document.getElementById(resultElementId);
|
|
2834
|
+
if (resEl) resEl.innerHTML = `<span style="font-size:0.75rem; color: var(--text-muted);">Testing...</span>`;
|
|
2835
|
+
|
|
2836
|
+
const { status, latency } = await executeProbeFetch(tc);
|
|
2837
|
+
const { pillClass, statusLabel } = classifyProbeResult(tc, status);
|
|
2760
2838
|
|
|
2761
2839
|
if (resEl) {
|
|
2762
|
-
resEl.innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span> <span style="font-size:0.72rem; color:var(--text-muted); margin-left:0.25rem;">${latency}ms</span>`;
|
|
2840
|
+
resEl.innerHTML = `<span class="pill ${pillClass}">${escapeHtml(statusLabel)}</span> <span style="font-size:0.72rem; color:var(--text-muted); margin-left:0.25rem;">${latency}ms</span>`;
|
|
2763
2841
|
}
|
|
2764
2842
|
}
|
|
2765
2843
|
|
|
@@ -2812,6 +2890,7 @@
|
|
|
2812
2890
|
}
|
|
2813
2891
|
|
|
2814
2892
|
activeCases.forEach((tc, i) => {
|
|
2893
|
+
const method = safeHttpMethod(tc.opMethod);
|
|
2815
2894
|
const tr = document.createElement("tr");
|
|
2816
2895
|
tr.id = `test-row-${i}`;
|
|
2817
2896
|
tr.innerHTML = `
|
|
@@ -2819,17 +2898,25 @@
|
|
|
2819
2898
|
<span class="pill pill-planned">PLANNED</span>
|
|
2820
2899
|
${tc.isCustom ? `<span style="font-size:0.62rem; font-weight:700; color:var(--primary); background:var(--accent); border:1px solid var(--border); padding:0.06rem 0.35rem; border-radius:3px; margin-left:0.3rem;">CUSTOM</span>` : ''}
|
|
2821
2900
|
</td>
|
|
2822
|
-
<td><span class="badge-method badge-${
|
|
2901
|
+
<td><span class="badge-method badge-${method}">${escapeHtml(method.toUpperCase())}</span> <code style="font-size:0.8rem; color: var(--foreground); margin-left: 0.35rem;">${escapeHtml(tc.opPath)}</code></td>
|
|
2823
2902
|
<td><span class="field-tag">${escapeHtml(tc.field)}</span></td>
|
|
2824
2903
|
<td><strong>${escapeHtml(tc.category)}</strong><br><span style="color:var(--muted-foreground);font-size:0.75rem;">${escapeHtml(tc.desc)}</span></td>
|
|
2825
|
-
<td
|
|
2904
|
+
<td>${tc.allowAccepted ? '2xx / 4xx (No 500)' : `Expected ${escapeHtml(tc.expected)}`}</td>
|
|
2826
2905
|
<td>--</td>
|
|
2827
|
-
<td style="white-space: nowrap;">
|
|
2906
|
+
<td class="table-actions" style="white-space: nowrap;">
|
|
2828
2907
|
<button class="btn btn-outline" style="font-size:0.72rem;padding:0.2rem 0.5rem;" onclick="runSingleBoundaryTest(${i})">▶ Run</button>
|
|
2829
2908
|
<button class="btn btn-outline" style="font-size:0.72rem;padding:0.2rem 0.5rem;" onclick="inspectTestPayload(${i})">Inspect</button>
|
|
2830
|
-
${tc.isCustom ? `<button class="btn-icon" style="color:var(--destructive);" onclick="deleteCustomProbe('${escapeJs(tc.id)}')" title="Delete custom probe">🗑️</button>` : ''}
|
|
2831
2909
|
</td>
|
|
2832
2910
|
`;
|
|
2911
|
+
if (tc.isCustom) {
|
|
2912
|
+
const delBtn = document.createElement("button");
|
|
2913
|
+
delBtn.className = "btn-icon";
|
|
2914
|
+
delBtn.title = "Delete custom probe";
|
|
2915
|
+
delBtn.style.color = "var(--destructive)";
|
|
2916
|
+
delBtn.textContent = "🗑️";
|
|
2917
|
+
delBtn.addEventListener("click", () => deleteCustomProbe(tc.id));
|
|
2918
|
+
tr.querySelector(".table-actions").appendChild(delBtn);
|
|
2919
|
+
}
|
|
2833
2920
|
tbody.appendChild(tr);
|
|
2834
2921
|
});
|
|
2835
2922
|
}
|
|
@@ -2872,44 +2959,14 @@
|
|
|
2872
2959
|
row.cells[0].innerHTML = `<span style="font-size:0.75rem; color:var(--text-muted);">Testing...</span>`;
|
|
2873
2960
|
}
|
|
2874
2961
|
|
|
2875
|
-
const
|
|
2876
|
-
|
|
2877
|
-
let respText = "";
|
|
2878
|
-
try {
|
|
2879
|
-
const opts = {
|
|
2880
|
-
method: tc.method.toUpperCase(),
|
|
2881
|
-
headers: { 'Content-Type': 'application/json' }
|
|
2882
|
-
};
|
|
2883
|
-
if (tc.payload) opts.body = JSON.stringify(tc.payload);
|
|
2884
|
-
|
|
2885
|
-
const res = await fetch(tc.endpoint, opts);
|
|
2886
|
-
status = res.status;
|
|
2887
|
-
respText = await res.text();
|
|
2888
|
-
} catch (e) {
|
|
2889
|
-
status = 0;
|
|
2890
|
-
respText = e.message;
|
|
2891
|
-
}
|
|
2892
|
-
const latency = Math.round(performance.now() - start);
|
|
2893
|
-
|
|
2894
|
-
let pillClass = "pill-pass";
|
|
2895
|
-
let statusLabel = "PASS";
|
|
2896
|
-
if (status >= 500) {
|
|
2897
|
-
pillClass = "pill-crash";
|
|
2898
|
-
statusLabel = "CRASH (500)";
|
|
2899
|
-
} else if (status >= 200 && status < 300) {
|
|
2900
|
-
pillClass = "pill-leak";
|
|
2901
|
-
statusLabel = "LEAK (200)";
|
|
2902
|
-
} else if (status === 400 || status === 422 || status === 401) {
|
|
2903
|
-
pillClass = "pill-pass";
|
|
2904
|
-
statusLabel = `PASS (${status})`;
|
|
2905
|
-
} else {
|
|
2906
|
-
pillClass = "pill-leak";
|
|
2907
|
-
statusLabel = `${status}`;
|
|
2908
|
-
}
|
|
2962
|
+
const { status, latency } = await executeProbeFetch(tc);
|
|
2963
|
+
const { pillClass, statusLabel } = classifyProbeResult(tc, status);
|
|
2909
2964
|
|
|
2910
2965
|
if (row) {
|
|
2911
|
-
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span>`;
|
|
2912
|
-
row.cells[4].textContent =
|
|
2966
|
+
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${escapeHtml(statusLabel)}</span>`;
|
|
2967
|
+
row.cells[4].textContent = tc.allowAccepted
|
|
2968
|
+
? `2xx/4xx | Got ${status}`
|
|
2969
|
+
: `Expected ${tc.expected} | Got ${status}`;
|
|
2913
2970
|
row.cells[5].textContent = `${latency} ms`;
|
|
2914
2971
|
}
|
|
2915
2972
|
}
|
|
@@ -2929,48 +2986,18 @@
|
|
|
2929
2986
|
row.cells[0].innerHTML = `<span style="font-size:0.75rem; color:var(--text-muted);">Testing...</span>`;
|
|
2930
2987
|
}
|
|
2931
2988
|
|
|
2932
|
-
const
|
|
2933
|
-
|
|
2934
|
-
let respText = "";
|
|
2935
|
-
|
|
2936
|
-
try {
|
|
2937
|
-
const opts = {
|
|
2938
|
-
method: tc.method.toUpperCase(),
|
|
2939
|
-
headers: { 'Content-Type': 'application/json' }
|
|
2940
|
-
};
|
|
2941
|
-
if (tc.payload) opts.body = JSON.stringify(tc.payload);
|
|
2989
|
+
const { status, latency } = await executeProbeFetch(tc);
|
|
2990
|
+
const { kind, pillClass, statusLabel } = classifyProbeResult(tc, status);
|
|
2942
2991
|
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
} catch (e) {
|
|
2947
|
-
status = 0;
|
|
2948
|
-
respText = e.message;
|
|
2949
|
-
}
|
|
2950
|
-
const latency = Math.round(performance.now() - start);
|
|
2951
|
-
|
|
2952
|
-
let pillClass = "pill-pass";
|
|
2953
|
-
let statusLabel = "PASS";
|
|
2954
|
-
if (status >= 500) {
|
|
2955
|
-
pillClass = "pill-crash";
|
|
2956
|
-
statusLabel = "CRASH (500)";
|
|
2957
|
-
crashCount++;
|
|
2958
|
-
} else if (status >= 200 && status < 300) {
|
|
2959
|
-
pillClass = "pill-leak";
|
|
2960
|
-
statusLabel = "LEAK (200)";
|
|
2961
|
-
leakCount++;
|
|
2962
|
-
} else if (status === 400 || status === 422 || status === 401) {
|
|
2963
|
-
pillClass = "pill-pass";
|
|
2964
|
-
statusLabel = `PASS (${status})`;
|
|
2965
|
-
passCount++;
|
|
2966
|
-
} else {
|
|
2967
|
-
pillClass = "pill-leak";
|
|
2968
|
-
statusLabel = `${status}`;
|
|
2969
|
-
}
|
|
2992
|
+
if (kind === "crash") crashCount++;
|
|
2993
|
+
else if (kind === "leak") leakCount++;
|
|
2994
|
+
else if (kind === "pass") passCount++;
|
|
2970
2995
|
|
|
2971
2996
|
if (row) {
|
|
2972
|
-
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span>`;
|
|
2973
|
-
row.cells[4].textContent =
|
|
2997
|
+
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${escapeHtml(statusLabel)}</span>`;
|
|
2998
|
+
row.cells[4].textContent = tc.allowAccepted
|
|
2999
|
+
? `2xx/4xx | Got ${status}`
|
|
3000
|
+
: `Expected ${tc.expected} | Got ${status}`;
|
|
2974
3001
|
row.cells[5].textContent = `${latency} ms`;
|
|
2975
3002
|
}
|
|
2976
3003
|
|
|
@@ -2996,11 +3023,14 @@
|
|
|
2996
3023
|
const activeCases = getActiveTestCases();
|
|
2997
3024
|
const sel = document.getElementById("test-scope-select");
|
|
2998
3025
|
const scopeLabel = sel && sel.value !== "all" ? `Scope: ${sel.value.toUpperCase()}` : "Scope: All Endpoints";
|
|
2999
|
-
let code = `# Auto-generated by SpecForge (${scopeLabel})\nimport pytest\nimport httpx\n\nBASE_URL = "http://localhost:8000"\n\n@pytest.mark.parametrize("endpoint,method,payload,expected"
|
|
3026
|
+
let code = `# Auto-generated by SpecForge (${scopeLabel})\nimport pytest\nimport httpx\n\nBASE_URL = ${toPythonLiteral(window.location.origin || "http://localhost:8000")}\n\n@pytest.mark.parametrize(\n "endpoint,method,payload,query_params,headers,expected,is_negative,allow_accepted",\n [\n`;
|
|
3000
3027
|
activeCases.forEach(tc => {
|
|
3001
|
-
|
|
3028
|
+
const method = safeHttpMethod(tc.method).toUpperCase();
|
|
3029
|
+
const isNegative = tc.isNegative !== false;
|
|
3030
|
+
const allowAccepted = Boolean(tc.allowAccepted);
|
|
3031
|
+
code += ` (${toPythonLiteral(tc.endpoint)}, ${toPythonLiteral(method)}, ${toPythonLiteral(tc.payload ?? null)}, ${toPythonLiteral(tc.queryParams || {})}, ${toPythonLiteral(tc.headers || {})}, ${Number(tc.expected) || 422}, ${isNegative ? "True" : "False"}, ${allowAccepted ? "True" : "False"}),\n`;
|
|
3002
3032
|
});
|
|
3003
|
-
code += `])\ndef test_contract_boundaries(endpoint, method, payload, expected):\n with httpx.Client(base_url=BASE_URL) as client:\n res = client.request(method, endpoint,
|
|
3033
|
+
code += ` ],\n)\ndef test_contract_boundaries(endpoint, method, payload, query_params, headers, expected, is_negative, allow_accepted):\n with httpx.Client(base_url=BASE_URL, timeout=10.0) as client:\n kwargs = {}\n if payload is not None and method.upper() not in ("GET", "HEAD"):\n kwargs["json"] = payload\n if query_params:\n kwargs["params"] = query_params\n if headers:\n kwargs["headers"] = headers\n res = client.request(method, endpoint, **kwargs)\n assert res.status_code != 500, "Server crashed with 500!"\n if allow_accepted:\n assert 200 <= res.status_code < 300 or res.status_code in (400, 422)\n elif is_negative:\n assert res.status_code in (400, 422)\n else:\n assert 200 <= res.status_code < 300\n`;
|
|
3004
3034
|
|
|
3005
3035
|
document.getElementById("pytest-code-output").textContent = code;
|
|
3006
3036
|
document.getElementById("pytest-modal").style.display = "flex";
|
|
@@ -19,6 +19,7 @@ from specforge.testing.invariants import TestStatus, evaluate_test_result
|
|
|
19
19
|
from specforge.testing.planner import TestPlanner
|
|
20
20
|
|
|
21
21
|
MAX_SPEC_SIZE = 10 * 1024 * 1024 # 10 MB maximum specification size
|
|
22
|
+
MAX_RESPONSE_BODY_PREVIEW = 100_000 # 100 KB response preview limit in CLI
|
|
22
23
|
DEFAULT_FETCH_TIMEOUT = 30.0 # seconds
|
|
23
24
|
|
|
24
25
|
# Blocked metadata and link-local networks to mitigate cloud metadata SSRF
|
|
@@ -129,6 +130,12 @@ def run_test_command(args: argparse.Namespace) -> int:
|
|
|
129
130
|
base_url = args.base_url or (spec_model.servers[0] if spec_model.servers else "http://localhost:8000")
|
|
130
131
|
base_url = base_url.rstrip("/")
|
|
131
132
|
|
|
133
|
+
try:
|
|
134
|
+
_validate_spec_url(base_url)
|
|
135
|
+
except ValueError as e:
|
|
136
|
+
print(f"\033[1;31mError validating base URL: {e}\033[0m")
|
|
137
|
+
return 1
|
|
138
|
+
|
|
132
139
|
pass_count = 0
|
|
133
140
|
leak_count = 0
|
|
134
141
|
crash_count = 0
|
|
@@ -138,15 +145,17 @@ def run_test_command(args: argparse.Namespace) -> int:
|
|
|
138
145
|
for _idx, tc in enumerate(all_test_cases, 1):
|
|
139
146
|
start = time.perf_counter()
|
|
140
147
|
kwargs: dict[str, Any] = {}
|
|
141
|
-
if tc.payload is not None:
|
|
148
|
+
if tc.payload is not None and tc.method.upper() not in ("GET", "HEAD"):
|
|
142
149
|
kwargs["json"] = tc.payload
|
|
143
150
|
if tc.query_params:
|
|
144
151
|
kwargs["params"] = tc.query_params
|
|
152
|
+
if tc.headers:
|
|
153
|
+
kwargs["headers"] = tc.headers
|
|
145
154
|
|
|
146
155
|
try:
|
|
147
156
|
res = client.request(tc.method.upper(), tc.endpoint, **kwargs)
|
|
148
157
|
actual_status = res.status_code
|
|
149
|
-
resp_text = res.text
|
|
158
|
+
resp_text = res.text[:MAX_RESPONSE_BODY_PREVIEW]
|
|
150
159
|
except Exception as e:
|
|
151
160
|
actual_status = 599
|
|
152
161
|
resp_text = str(e)
|
|
@@ -185,6 +194,9 @@ def run_test_command(args: argparse.Namespace) -> int:
|
|
|
185
194
|
# Export to pytest if requested
|
|
186
195
|
if args.export_pytest:
|
|
187
196
|
out_path = Path(args.export_pytest)
|
|
197
|
+
if out_path.is_symlink():
|
|
198
|
+
print(f"\n\033[1;31mError: Refusing to overwrite symlink export path: {out_path}\033[0m")
|
|
199
|
+
return 1
|
|
188
200
|
if out_path.exists() and out_path.is_dir():
|
|
189
201
|
print(f"\n\033[1;31mError: Export path is a directory: {out_path}\033[0m")
|
|
190
202
|
return 1
|
|
@@ -193,6 +193,8 @@ class OpenAPIParser:
|
|
|
193
193
|
description = derefed.get("description")
|
|
194
194
|
default = derefed.get("default")
|
|
195
195
|
example = derefed.get("example")
|
|
196
|
+
if example is None and isinstance(derefed.get("examples"), list) and derefed["examples"]:
|
|
197
|
+
example = derefed["examples"][0]
|
|
196
198
|
enum = derefed.get("enum")
|
|
197
199
|
|
|
198
200
|
# String constraints
|
{specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/exporters/pytest_export.py
RENAMED
|
@@ -19,6 +19,7 @@ class PytestExporter:
|
|
|
19
19
|
for tc in test_cases:
|
|
20
20
|
payload_repr = repr(tc.payload)
|
|
21
21
|
query_repr = repr(tc.query_params)
|
|
22
|
+
headers_repr = repr(tc.headers)
|
|
22
23
|
|
|
23
24
|
case_str = (
|
|
24
25
|
f' (\n'
|
|
@@ -26,8 +27,10 @@ class PytestExporter:
|
|
|
26
27
|
f' {repr(tc.method.upper())},\n'
|
|
27
28
|
f' {payload_repr},\n'
|
|
28
29
|
f' {query_repr},\n'
|
|
30
|
+
f' {headers_repr},\n'
|
|
29
31
|
f' {tc.expected_status},\n'
|
|
30
32
|
f' {tc.is_negative_test},\n'
|
|
33
|
+
f' {tc.allow_accepted},\n'
|
|
31
34
|
f' {repr(tc.description)},\n'
|
|
32
35
|
f' ),'
|
|
33
36
|
)
|
|
@@ -43,7 +46,7 @@ import httpx
|
|
|
43
46
|
BASE_URL = {repr(base_url)}
|
|
44
47
|
|
|
45
48
|
@pytest.mark.parametrize(
|
|
46
|
-
"endpoint,method,payload,query_params,expected_status,is_negative,description",
|
|
49
|
+
"endpoint,method,payload,query_params,headers,expected_status,is_negative,allow_accepted,description",
|
|
47
50
|
[
|
|
48
51
|
{cases_block}
|
|
49
52
|
]
|
|
@@ -53,17 +56,21 @@ def test_specforge_contract_boundaries(
|
|
|
53
56
|
method: str,
|
|
54
57
|
payload,
|
|
55
58
|
query_params,
|
|
59
|
+
headers,
|
|
56
60
|
expected_status: int,
|
|
57
61
|
is_negative: bool,
|
|
62
|
+
allow_accepted: bool,
|
|
58
63
|
description: str,
|
|
59
64
|
):
|
|
60
65
|
"""Execute boundary test and assert Zero-500s invariant and contract validation."""
|
|
61
66
|
with httpx.Client(base_url=BASE_URL, timeout=10.0) as client:
|
|
62
67
|
kwargs = {{}}
|
|
63
|
-
if payload is not None:
|
|
68
|
+
if payload is not None and method.upper() not in ("GET", "HEAD"):
|
|
64
69
|
kwargs["json"] = payload
|
|
65
|
-
if query_params
|
|
70
|
+
if query_params:
|
|
66
71
|
kwargs["params"] = query_params
|
|
72
|
+
if headers:
|
|
73
|
+
kwargs["headers"] = headers
|
|
67
74
|
|
|
68
75
|
response = client.request(method, endpoint, **kwargs)
|
|
69
76
|
|
|
@@ -72,8 +79,14 @@ def test_specforge_contract_boundaries(
|
|
|
72
79
|
f"CRITICAL 500 SERVER CRASH: '{{description}}' triggered an unhandled exception!"
|
|
73
80
|
)
|
|
74
81
|
|
|
75
|
-
# Invariant 2:
|
|
76
|
-
if
|
|
82
|
+
# Invariant 2: Resilience probes allow safe acceptance (2xx) or clean rejection (400/422)
|
|
83
|
+
if allow_accepted:
|
|
84
|
+
assert 200 <= response.status_code < 300 or response.status_code in (400, 422), (
|
|
85
|
+
f"UNEXPECTED STATUS on '{{description}}': got HTTP {{response.status_code}}. "
|
|
86
|
+
f"Response: {{response.text}}"
|
|
87
|
+
)
|
|
88
|
+
elif is_negative:
|
|
89
|
+
# Invariant 3: Strict negative tests must be cleanly rejected with 400 or 422
|
|
77
90
|
assert response.status_code in (400, 422), (
|
|
78
91
|
f"SCHEMA LEAK on '{{description}}': expected 400/422 rejection, "
|
|
79
92
|
f"got HTTP {{response.status_code}}. Response: {{response.text}}"
|
|
@@ -30,6 +30,7 @@ class TestCase:
|
|
|
30
30
|
query_params: dict[str, Any] = field(default_factory=dict)
|
|
31
31
|
expected_status: int = 422
|
|
32
32
|
is_negative_test: bool = True
|
|
33
|
+
allow_accepted: bool = False
|
|
33
34
|
|
|
34
35
|
def to_dict(self) -> dict[str, Any]:
|
|
35
36
|
"""Convert test case to dictionary for frontend execution or serialization."""
|
|
@@ -46,6 +47,7 @@ class TestCase:
|
|
|
46
47
|
"query_params": self.query_params,
|
|
47
48
|
"expected_status": self.expected_status,
|
|
48
49
|
"is_negative_test": self.is_negative_test,
|
|
50
|
+
"allow_accepted": self.allow_accepted,
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
|
|
@@ -95,7 +97,29 @@ def evaluate_test_result(
|
|
|
95
97
|
curl_command=curl_cmd,
|
|
96
98
|
)
|
|
97
99
|
|
|
98
|
-
# Invariant 2:
|
|
100
|
+
# Invariant 2: Resilience probes allow either clean rejection (400/422) or safe acceptance (2xx)
|
|
101
|
+
if test_case.allow_accepted:
|
|
102
|
+
if 200 <= status_code < 300 or status_code in (400, 422):
|
|
103
|
+
return TestResult(
|
|
104
|
+
test_case=test_case,
|
|
105
|
+
actual_status=status_code,
|
|
106
|
+
latency_ms=latency_ms,
|
|
107
|
+
response_body=response_body,
|
|
108
|
+
status=TestStatus.PASS,
|
|
109
|
+
message=f"PASS: Server safely handled {test_case.mutation_name} with HTTP {status_code}.",
|
|
110
|
+
curl_command=curl_cmd,
|
|
111
|
+
)
|
|
112
|
+
return TestResult(
|
|
113
|
+
test_case=test_case,
|
|
114
|
+
actual_status=status_code,
|
|
115
|
+
latency_ms=latency_ms,
|
|
116
|
+
response_body=response_body,
|
|
117
|
+
status=TestStatus.FAIL_STATUS,
|
|
118
|
+
message=f"UNEXPECTED: Got HTTP {status_code} on resilience probe {test_case.mutation_name}.",
|
|
119
|
+
curl_command=curl_cmd,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Invariant 3: Strict negative test expects a rejection (400 or 422). If 200/201 was returned, it's a schema leak
|
|
99
123
|
if test_case.is_negative_test:
|
|
100
124
|
if 200 <= status_code < 300:
|
|
101
125
|
return TestResult(
|
|
@@ -154,13 +178,31 @@ def evaluate_test_result(
|
|
|
154
178
|
)
|
|
155
179
|
|
|
156
180
|
|
|
157
|
-
|
|
181
|
+
_SENSITIVE_HEADERS = frozenset({
|
|
182
|
+
"authorization",
|
|
183
|
+
"proxy-authorization",
|
|
184
|
+
"x-api-key",
|
|
185
|
+
"cookie",
|
|
186
|
+
"set-cookie",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_curl_command(
|
|
191
|
+
test_case: TestCase,
|
|
192
|
+
base_url: str = "http://localhost:8000",
|
|
193
|
+
redact_secrets: bool = True,
|
|
194
|
+
) -> str:
|
|
158
195
|
"""Generate a clean, shell-escaped cURL reproduction command for a test case."""
|
|
159
196
|
url = f"{base_url.rstrip('/')}{test_case.endpoint}"
|
|
160
197
|
cmd_parts = [f"curl -X {shlex.quote(test_case.method.upper())} {shlex.quote(url)}"]
|
|
161
198
|
|
|
162
199
|
for h_name, h_val in test_case.headers.items():
|
|
163
|
-
|
|
200
|
+
display_val = (
|
|
201
|
+
"<REDACTED>"
|
|
202
|
+
if redact_secrets and h_name.lower() in _SENSITIVE_HEADERS
|
|
203
|
+
else h_val
|
|
204
|
+
)
|
|
205
|
+
cmd_parts.append(f"-H {shlex.quote(f'{h_name}: {display_val}')}")
|
|
164
206
|
|
|
165
207
|
if test_case.payload is not None:
|
|
166
208
|
cmd_parts.append("-H 'Content-Type: application/json'")
|
|
@@ -34,6 +34,24 @@ class TestPlan:
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
_RESILIENCE_MUTATIONS = frozenset({
|
|
38
|
+
"Unicode: 4-Byte Emojis",
|
|
39
|
+
"Buffer: Large String (5000 chars)",
|
|
40
|
+
"Security: Basic SQL Injection Probe",
|
|
41
|
+
"Security: XSS Script Tag Probe",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_resilience_mutation(mutation_name: str, schema: Any) -> bool:
|
|
46
|
+
"""Return True for safety/resilience probes where 2xx or 400/422 are both acceptable (only 500 fails)."""
|
|
47
|
+
if mutation_name in _RESILIENCE_MUTATIONS:
|
|
48
|
+
return not getattr(schema, "pattern", None)
|
|
49
|
+
if mutation_name == "Overflow: 32-bit Integer Limit":
|
|
50
|
+
max_val = getattr(schema, "maximum", None)
|
|
51
|
+
return max_val is None or max_val >= 2147483648
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
37
55
|
class TestPlanner:
|
|
38
56
|
"""Assembles combinatorial test cases by coordinating type fuzzers."""
|
|
39
57
|
|
|
@@ -102,6 +120,7 @@ class TestPlanner:
|
|
|
102
120
|
for mut_name, mut_desc, mut_val, mut_cat in field_mutations:
|
|
103
121
|
mutated_payload = copy.deepcopy(baseline)
|
|
104
122
|
mutated_payload[prop_name] = mut_val
|
|
123
|
+
allow_accepted = _is_resilience_mutation(mut_name, prop_schema)
|
|
105
124
|
|
|
106
125
|
test_cases.append(
|
|
107
126
|
TestCase(
|
|
@@ -115,6 +134,7 @@ class TestPlanner:
|
|
|
115
134
|
payload=mutated_payload,
|
|
116
135
|
expected_status=422,
|
|
117
136
|
is_negative_test=True,
|
|
137
|
+
allow_accepted=allow_accepted,
|
|
118
138
|
)
|
|
119
139
|
)
|
|
120
140
|
|
|
@@ -128,9 +148,20 @@ class TestPlanner:
|
|
|
128
148
|
if param_type == "string":
|
|
129
149
|
param_mutations = StringFuzzer.generate_mutations(param_name, param.schema)
|
|
130
150
|
elif param_type in ("integer", "number"):
|
|
131
|
-
param_mutations =
|
|
151
|
+
param_mutations = [
|
|
152
|
+
(
|
|
153
|
+
"Type: Non-Numeric String",
|
|
154
|
+
f"Reject non-numeric string 'not_a_number' for numeric field '{param_name}'",
|
|
155
|
+
"not_a_number",
|
|
156
|
+
"Type",
|
|
157
|
+
)
|
|
158
|
+
if m[0] == "Type: String Instead of Number"
|
|
159
|
+
else m
|
|
160
|
+
for m in NumericFuzzer.generate_mutations(param_name, param.schema)
|
|
161
|
+
]
|
|
132
162
|
|
|
133
163
|
for mut_name, mut_desc, mut_val, mut_cat in param_mutations:
|
|
164
|
+
allow_accepted = _is_resilience_mutation(mut_name, param.schema)
|
|
134
165
|
test_cases.append(
|
|
135
166
|
TestCase(
|
|
136
167
|
id=str(uuid.uuid4())[:8],
|
|
@@ -144,6 +175,7 @@ class TestPlanner:
|
|
|
144
175
|
query_params={param_name: mut_val},
|
|
145
176
|
expected_status=422,
|
|
146
177
|
is_negative_test=True,
|
|
178
|
+
allow_accepted=allow_accepted,
|
|
147
179
|
)
|
|
148
180
|
)
|
|
149
181
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|