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.
Files changed (23) hide show
  1. {specforge_api-0.1.5 → specforge_api-0.1.6}/PKG-INFO +1 -1
  2. {specforge_api-0.1.5 → specforge_api-0.1.6}/pyproject.toml +1 -1
  3. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/__init__.py +1 -1
  4. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/adapters/fastapi.py +36 -4
  5. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/adapters/flask.py +7 -1
  6. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/assets/index.html +164 -134
  7. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/cli/main.py +14 -2
  8. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/openapi3.py +2 -0
  9. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/exporters/pytest_export.py +18 -5
  10. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/invariants.py +45 -3
  11. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/planner.py +33 -1
  12. {specforge_api-0.1.5 → specforge_api-0.1.6}/.gitignore +0 -0
  13. {specforge_api-0.1.5 → specforge_api-0.1.6}/LICENSE +0 -0
  14. {specforge_api-0.1.5 → specforge_api-0.1.6}/README.md +0 -0
  15. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/config.py +0 -0
  16. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/models.py +0 -0
  17. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/parser/resolver.py +0 -0
  18. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
  19. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
  20. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
  21. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
  22. {specforge_api-0.1.5 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
  23. {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.5
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/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "specforge-api"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "The interactive API client and automated contract testing engine for Python."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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.5"
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 = False,
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 and hasattr(app, "docs_url"):
58
- app.docs_url = None
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-${op.method}">${op.method}</span>
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).replace(/'/g, "\\'").replace(/"/g, '\\"');
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("button[onclick*='runSingleRouteProbe']");
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;" onclick="event.stopPropagation(); openAddCustomTestModal('${escapeJs(fieldName)}')" title="Add custom probe for this field">
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.payload && typeof tc.payload === 'object') {
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 runSingleRouteProbe(idx, resultElementId) {
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 opts = {
2731
- method: tc.method.toUpperCase(),
2732
- headers: { 'Content-Type': 'application/json' }
2733
- };
2734
- if (tc.payload) opts.body = JSON.stringify(tc.payload);
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 res = await fetch(tc.endpoint, opts);
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
- let pillClass = "pill-pass";
2746
- let statusLabel = "PASS";
2801
+ function classifyProbeResult(tc, status) {
2747
2802
  if (status >= 500) {
2748
- pillClass = "pill-crash";
2749
- statusLabel = "CRASH (500)";
2750
- } else if (status >= 200 && status < 300) {
2751
- pillClass = "pill-leak";
2752
- statusLabel = "LEAK (200)";
2753
- } else if (status === 400 || status === 422 || status === 401) {
2754
- pillClass = "pill-pass";
2755
- statusLabel = `PASS (${status})`;
2756
- } else {
2757
- pillClass = "pill-leak";
2758
- statusLabel = `${status}`;
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-${tc.opMethod}">${tc.opMethod.toUpperCase()}</span> <code style="font-size:0.8rem; color: var(--foreground); margin-left: 0.35rem;">${escapeHtml(tc.opPath)}</code></td>
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>Expected ${tc.expected}</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 start = performance.now();
2876
- let status = 0;
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 = `Expected ${tc.expected} | Got ${status}`;
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 start = performance.now();
2933
- let status = 0;
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
- const res = await fetch(tc.endpoint, opts);
2944
- status = res.status;
2945
- respText = await res.text();
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 = `Expected ${tc.expected} | Got ${status}`;
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", [\n`;
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
- code += ` ("${tc.endpoint}", "${tc.method.toUpperCase()}", ${JSON.stringify(tc.payload)}, ${tc.expected}),\n`;
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, json=payload)\n assert res.status_code != 500, "Server crashed with 500!"\n assert res.status_code == expected\n`;
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
@@ -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 is not None:
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: Negative tests must be cleanly rejected with 400 or 422
76
- if is_negative:
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: Negative test expects a rejection (400 or 422). If 200/201 was returned, it's a schema leak
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
- def build_curl_command(test_case: TestCase, base_url: str = "http://localhost:8000") -> str:
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
- cmd_parts.append(f"-H {shlex.quote(f'{h_name}: {h_val}')}")
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 = NumericFuzzer.generate_mutations(param_name, param.schema)
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