specforge-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specforge/__init__.py +23 -0
- specforge/adapters/fastapi.py +101 -0
- specforge/adapters/flask.py +78 -0
- specforge/assets/index.html +2995 -0
- specforge/cli/main.py +153 -0
- specforge/config.py +42 -0
- specforge/parser/models.py +131 -0
- specforge/parser/openapi3.py +285 -0
- specforge/parser/resolver.py +101 -0
- specforge/testing/exporters/pytest_export.py +89 -0
- specforge/testing/fuzzers/array_fuzzer.py +56 -0
- specforge/testing/fuzzers/boolean_fuzzer.py +38 -0
- specforge/testing/fuzzers/numeric_fuzzer.py +92 -0
- specforge/testing/fuzzers/object_fuzzer.py +99 -0
- specforge/testing/fuzzers/string_fuzzer.py +157 -0
- specforge/testing/invariants.py +170 -0
- specforge/testing/planner.py +155 -0
- specforge/utils/html_injector.py +43 -0
- specforge_api-0.1.0.dist-info/METADATA +260 -0
- specforge_api-0.1.0.dist-info/RECORD +23 -0
- specforge_api-0.1.0.dist-info/WHEEL +4 -0
- specforge_api-0.1.0.dist-info/entry_points.txt +2 -0
- specforge_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""JSON Pointer & $ref reference resolver with circular reference protection."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RefResolver:
|
|
7
|
+
"""Resolves JSON Reference ($ref) pointers within an OpenAPI specification."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, root_spec: dict[str, Any], max_depth: int = 25):
|
|
10
|
+
self.root_spec = root_spec
|
|
11
|
+
self.max_depth = max_depth
|
|
12
|
+
|
|
13
|
+
def resolve_pointer(self, ref: str) -> dict[str, Any]:
|
|
14
|
+
"""Resolve a local JSON Pointer string like '#/components/schemas/User'.
|
|
15
|
+
|
|
16
|
+
Raises ValueError if the reference cannot be located.
|
|
17
|
+
"""
|
|
18
|
+
if not ref.startswith("#/"):
|
|
19
|
+
raise ValueError(f"External references are not supported: {ref}")
|
|
20
|
+
|
|
21
|
+
parts = ref[2:].split("/")
|
|
22
|
+
current: Any = self.root_spec
|
|
23
|
+
|
|
24
|
+
for part in parts:
|
|
25
|
+
# Unescape JSON pointer tokens (~1 for /, ~0 for ~)
|
|
26
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
27
|
+
if isinstance(current, dict) and part in current:
|
|
28
|
+
current = current[part]
|
|
29
|
+
elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
|
|
30
|
+
current = current[int(part)]
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError(f"Could not resolve $ref pointer part '{part}' in '{ref}'")
|
|
33
|
+
|
|
34
|
+
if not isinstance(current, dict):
|
|
35
|
+
return {"type": "object", "value": current}
|
|
36
|
+
return current
|
|
37
|
+
|
|
38
|
+
def dereference(
|
|
39
|
+
self,
|
|
40
|
+
schema: Any,
|
|
41
|
+
visited_refs: Optional[set[str]] = None,
|
|
42
|
+
depth: int = 0,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Recursively dereferences schemas, protecting against infinite cyclic references."""
|
|
45
|
+
if visited_refs is None:
|
|
46
|
+
visited_refs = set()
|
|
47
|
+
|
|
48
|
+
if depth > self.max_depth:
|
|
49
|
+
return {"type": "object", "description": "[Recursion depth exceeded]"}
|
|
50
|
+
|
|
51
|
+
if not isinstance(schema, dict):
|
|
52
|
+
if isinstance(schema, list):
|
|
53
|
+
return [self.dereference(item, visited_refs, depth + 1) for item in schema]
|
|
54
|
+
return schema
|
|
55
|
+
|
|
56
|
+
# Check for $ref
|
|
57
|
+
if "$ref" in schema:
|
|
58
|
+
ref_path = schema["$ref"]
|
|
59
|
+
if ref_path in visited_refs:
|
|
60
|
+
# Cycle detected! Return a stub to avoid infinite recursion
|
|
61
|
+
model_name = ref_path.split("/")[-1]
|
|
62
|
+
return {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"description": f"[Circular reference to {model_name}]",
|
|
65
|
+
"_is_cycle": True,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
resolved = self.resolve_pointer(ref_path)
|
|
69
|
+
new_visited = visited_refs.copy()
|
|
70
|
+
new_visited.add(ref_path)
|
|
71
|
+
|
|
72
|
+
# Dereference the target schema
|
|
73
|
+
dereferenced_target = self.dereference(resolved, new_visited, depth + 1)
|
|
74
|
+
|
|
75
|
+
# Merge any local overrides in the original schema (e.g. description, nullable)
|
|
76
|
+
merged = dict(dereferenced_target)
|
|
77
|
+
for k, v in schema.items():
|
|
78
|
+
if k != "$ref":
|
|
79
|
+
merged[k] = v
|
|
80
|
+
return merged
|
|
81
|
+
|
|
82
|
+
# Otherwise, recursively dereference dictionary keys
|
|
83
|
+
result: dict[str, Any] = {}
|
|
84
|
+
for key, val in schema.items():
|
|
85
|
+
if key in ("properties", "patternProperties") and isinstance(val, dict):
|
|
86
|
+
result[key] = {
|
|
87
|
+
prop_name: self.dereference(prop_schema, visited_refs, depth + 1)
|
|
88
|
+
for prop_name, prop_schema in val.items()
|
|
89
|
+
}
|
|
90
|
+
elif key == "items":
|
|
91
|
+
result[key] = self.dereference(val, visited_refs, depth + 1)
|
|
92
|
+
elif key in ("allOf", "anyOf", "oneOf") and isinstance(val, list):
|
|
93
|
+
result[key] = [self.dereference(item, visited_refs, depth + 1) for item in val]
|
|
94
|
+
elif isinstance(val, dict):
|
|
95
|
+
result[key] = self.dereference(val, visited_refs, depth + 1)
|
|
96
|
+
elif isinstance(val, list):
|
|
97
|
+
result[key] = [self.dereference(item, visited_refs, depth + 1) for item in val]
|
|
98
|
+
else:
|
|
99
|
+
result[key] = val
|
|
100
|
+
|
|
101
|
+
return result
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pytest code generator for SpecForge test plans."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from specforge.testing.invariants import TestCase
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PytestExporter:
|
|
9
|
+
"""Compiles SpecForge TestCases into ready-to-run pytest code."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def export(
|
|
13
|
+
cls,
|
|
14
|
+
test_cases: list[TestCase],
|
|
15
|
+
base_url: str = "http://localhost:8000",
|
|
16
|
+
module_name: str = "test_api_contracts.py",
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Generate clean, formatted pytest code."""
|
|
19
|
+
cases_repr: list[str] = []
|
|
20
|
+
|
|
21
|
+
for tc in test_cases:
|
|
22
|
+
payload_json = json.dumps(tc.payload) if tc.payload is not None else "None"
|
|
23
|
+
query_json = json.dumps(tc.query_params) if tc.query_params else "None"
|
|
24
|
+
desc_escaped = tc.description.replace('"', '\\"')
|
|
25
|
+
|
|
26
|
+
case_str = (
|
|
27
|
+
f' (\n'
|
|
28
|
+
f' "{tc.endpoint}",\n'
|
|
29
|
+
f' "{tc.method.upper()}",\n'
|
|
30
|
+
f' {payload_json},\n'
|
|
31
|
+
f' {query_json},\n'
|
|
32
|
+
f' {tc.expected_status},\n'
|
|
33
|
+
f' {tc.is_negative_test},\n'
|
|
34
|
+
f' "{desc_escaped}",\n'
|
|
35
|
+
f' ),'
|
|
36
|
+
)
|
|
37
|
+
cases_repr.append(case_str)
|
|
38
|
+
|
|
39
|
+
cases_block = "\n".join(cases_repr)
|
|
40
|
+
|
|
41
|
+
code = f'''# Auto-generated by SpecForge
|
|
42
|
+
# Contract Boundary & Invariant Test Suite
|
|
43
|
+
import pytest
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
BASE_URL = "{base_url}"
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"endpoint,method,payload,query_params,expected_status,is_negative,description",
|
|
50
|
+
[
|
|
51
|
+
{cases_block}
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
def test_specforge_contract_boundaries(
|
|
55
|
+
endpoint: str,
|
|
56
|
+
method: str,
|
|
57
|
+
payload,
|
|
58
|
+
query_params,
|
|
59
|
+
expected_status: int,
|
|
60
|
+
is_negative: bool,
|
|
61
|
+
description: str,
|
|
62
|
+
):
|
|
63
|
+
"""Execute boundary test and assert Zero-500s invariant and contract validation."""
|
|
64
|
+
with httpx.Client(base_url=BASE_URL, timeout=10.0) as client:
|
|
65
|
+
kwargs = {{}}
|
|
66
|
+
if payload is not None:
|
|
67
|
+
kwargs["json"] = payload
|
|
68
|
+
if query_params is not None:
|
|
69
|
+
kwargs["params"] = query_params
|
|
70
|
+
|
|
71
|
+
response = client.request(method, endpoint, **kwargs)
|
|
72
|
+
|
|
73
|
+
# Invariant 1: Server must NEVER crash with HTTP 500 on client inputs
|
|
74
|
+
assert response.status_code != 500, (
|
|
75
|
+
f"CRITICAL 500 SERVER CRASH: '{{description}}' triggered an unhandled exception!"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Invariant 2: Negative tests must be cleanly rejected with 400 or 422
|
|
79
|
+
if is_negative:
|
|
80
|
+
assert response.status_code in (400, 422), (
|
|
81
|
+
f"SCHEMA LEAK on '{{description}}': expected 400/422 rejection, "
|
|
82
|
+
f"got HTTP {{response.status_code}}. Response: {{response.text}}"
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
assert 200 <= response.status_code < 300, (
|
|
86
|
+
f"Happy path failed on '{{description}}': got HTTP {{response.status_code}}."
|
|
87
|
+
)
|
|
88
|
+
'''
|
|
89
|
+
return code
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Array field mutation test generator."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from specforge.parser.models import JSONSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArrayFuzzer:
|
|
9
|
+
"""Generates boundary and mutation payloads for array schema fields."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def generate_mutations(
|
|
13
|
+
cls,
|
|
14
|
+
field_name: str,
|
|
15
|
+
schema: JSONSchema,
|
|
16
|
+
valid_example: Any = None,
|
|
17
|
+
) -> list[tuple[str, str, Any, str]]:
|
|
18
|
+
"""Generate mutations for array field."""
|
|
19
|
+
mutations: list[tuple[str, str, Any, str]] = []
|
|
20
|
+
|
|
21
|
+
# 1. Empty array check when minItems >= 1
|
|
22
|
+
if schema.min_items is not None and schema.min_items > 0:
|
|
23
|
+
mutations.append((
|
|
24
|
+
"Boundary: Empty Array",
|
|
25
|
+
f"Reject empty list [] for '{field_name}' (requires at least {schema.min_items})",
|
|
26
|
+
[],
|
|
27
|
+
"Boundary",
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
# 2. Exceeding maxItems
|
|
31
|
+
if schema.max_items is not None:
|
|
32
|
+
mutations.append((
|
|
33
|
+
"Boundary: Exceeds Max Items",
|
|
34
|
+
f"Reject array with {schema.max_items + 1} items (> max {schema.max_items}) for '{field_name}'",
|
|
35
|
+
["item"] * (schema.max_items + 1),
|
|
36
|
+
"Boundary",
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
# 3. Unique items enforcement
|
|
40
|
+
if schema.unique_items:
|
|
41
|
+
mutations.append((
|
|
42
|
+
"Integrity: Duplicate Items",
|
|
43
|
+
f"Reject duplicate items in uniqueItems list for '{field_name}'",
|
|
44
|
+
["duplicate", "duplicate"],
|
|
45
|
+
"Integrity",
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
# 4. Type inversion: Primitive string instead of array
|
|
49
|
+
mutations.append((
|
|
50
|
+
"Type: Scalar String Instead of Array",
|
|
51
|
+
f"Reject single string scalar instead of array for '{field_name}'",
|
|
52
|
+
"not_an_array_string",
|
|
53
|
+
"Type",
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
return mutations
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Boolean field mutation test generator."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from specforge.parser.models import JSONSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BooleanFuzzer:
|
|
9
|
+
"""Generates boundary and mutation payloads for boolean schema fields."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def generate_mutations(
|
|
13
|
+
cls,
|
|
14
|
+
field_name: str,
|
|
15
|
+
schema: JSONSchema,
|
|
16
|
+
valid_example: bool = True,
|
|
17
|
+
) -> list[tuple[str, str, Any, str]]:
|
|
18
|
+
"""Generate mutations for boolean field."""
|
|
19
|
+
return [
|
|
20
|
+
(
|
|
21
|
+
"Type: String 'yes' for Boolean",
|
|
22
|
+
f"Reject ambiguous string 'yes' for boolean field '{field_name}'",
|
|
23
|
+
"yes",
|
|
24
|
+
"Type",
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"Type: Numeric 1 for Boolean",
|
|
28
|
+
f"Reject numeric integer 1 for strict boolean field '{field_name}'",
|
|
29
|
+
1,
|
|
30
|
+
"Type",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"Type: Invalid String for Boolean",
|
|
34
|
+
f"Reject arbitrary string 'not_a_bool' for boolean '{field_name}'",
|
|
35
|
+
"not_a_bool",
|
|
36
|
+
"Type",
|
|
37
|
+
),
|
|
38
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Numeric and integer field boundary test generator."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from specforge.parser.models import JSONSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NumericFuzzer:
|
|
9
|
+
"""Generates boundary and mutation payloads for integer and number schema fields."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def generate_mutations(
|
|
13
|
+
cls,
|
|
14
|
+
field_name: str,
|
|
15
|
+
schema: JSONSchema,
|
|
16
|
+
valid_example: float = 10,
|
|
17
|
+
) -> list[tuple[str, str, Any, str]]:
|
|
18
|
+
"""Generate a list of mutations for a numeric field.
|
|
19
|
+
|
|
20
|
+
Returns tuples of: (mutation_name, description, mutated_value, category)
|
|
21
|
+
"""
|
|
22
|
+
mutations: list[tuple[str, str, Any, str]] = []
|
|
23
|
+
is_integer = (
|
|
24
|
+
schema.type == "integer"
|
|
25
|
+
or (isinstance(schema.type, list) and "integer" in schema.type)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# 1. Minimum boundary
|
|
29
|
+
if schema.minimum is not None:
|
|
30
|
+
subtractor = 1 if is_integer else 0.01
|
|
31
|
+
mutations.append((
|
|
32
|
+
"Boundary: Below Minimum",
|
|
33
|
+
f"Reject value {schema.minimum - subtractor} (< min {schema.minimum}) for '{field_name}'",
|
|
34
|
+
schema.minimum - subtractor,
|
|
35
|
+
"Boundary",
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
# 2. Maximum boundary
|
|
39
|
+
if schema.maximum is not None:
|
|
40
|
+
adder = 1 if is_integer else 0.01
|
|
41
|
+
mutations.append((
|
|
42
|
+
"Boundary: Exceeds Maximum",
|
|
43
|
+
f"Reject value {schema.maximum + adder} (> max {schema.maximum}) for '{field_name}'",
|
|
44
|
+
schema.maximum + adder,
|
|
45
|
+
"Boundary",
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
# 3. Negative value check (if minimum is non-negative)
|
|
49
|
+
if schema.minimum is not None and schema.minimum >= 0:
|
|
50
|
+
mutations.append((
|
|
51
|
+
"Boundary: Negative Number",
|
|
52
|
+
f"Reject negative number (-1) for positive field '{field_name}'",
|
|
53
|
+
-1,
|
|
54
|
+
"Boundary",
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
# 4. Zero check (if minimum is strictly positive)
|
|
58
|
+
if schema.minimum is not None and schema.minimum > 0:
|
|
59
|
+
mutations.append((
|
|
60
|
+
"Boundary: Zero Value",
|
|
61
|
+
f"Reject 0 for field with minimum {schema.minimum} '{field_name}'",
|
|
62
|
+
0,
|
|
63
|
+
"Boundary",
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
# 5. Float into integer field
|
|
67
|
+
if is_integer:
|
|
68
|
+
mutations.append((
|
|
69
|
+
"Type: Float in Integer Field",
|
|
70
|
+
f"Reject non-integer float (42.5) for integer field '{field_name}'",
|
|
71
|
+
42.5,
|
|
72
|
+
"Type",
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
# 6. Integer range overflow
|
|
76
|
+
if is_integer:
|
|
77
|
+
mutations.append((
|
|
78
|
+
"Overflow: 32-bit Integer Limit",
|
|
79
|
+
f"Handle 32-bit integer overflow (2,147,483,648) for '{field_name}'",
|
|
80
|
+
2147483648,
|
|
81
|
+
"Overflow",
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
# 7. Type inversion: String representation of number
|
|
85
|
+
mutations.append((
|
|
86
|
+
"Type: String Instead of Number",
|
|
87
|
+
f"Reject string representation '100' for numeric field '{field_name}'",
|
|
88
|
+
"100",
|
|
89
|
+
"Type",
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
return mutations
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Object schema mutation and required field omission generator."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from specforge.parser.models import JSONSchema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ObjectFuzzer:
|
|
10
|
+
"""Generates mutations for object schemas (request bodies and nested objects)."""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def generate_valid_baseline(cls, schema: JSONSchema) -> dict[str, Any]:
|
|
14
|
+
"""Generate a valid baseline dictionary conforming to the schema."""
|
|
15
|
+
baseline: dict[str, Any] = {}
|
|
16
|
+
|
|
17
|
+
for prop_name, prop_schema in schema.properties.items():
|
|
18
|
+
if prop_schema.example is not None:
|
|
19
|
+
baseline[prop_name] = prop_schema.example
|
|
20
|
+
elif prop_schema.default is not None:
|
|
21
|
+
baseline[prop_name] = prop_schema.default
|
|
22
|
+
elif prop_schema.enum:
|
|
23
|
+
baseline[prop_name] = prop_schema.enum[0]
|
|
24
|
+
elif prop_schema.type == "string":
|
|
25
|
+
if prop_schema.format == "email":
|
|
26
|
+
baseline[prop_name] = "user@example.com"
|
|
27
|
+
elif prop_schema.format == "uuid":
|
|
28
|
+
baseline[prop_name] = "123e4567-e89b-12d3-a456-426614174000"
|
|
29
|
+
elif prop_schema.format in ("date-time", "date"):
|
|
30
|
+
baseline[prop_name] = "2026-01-01T12:00:00Z"
|
|
31
|
+
else:
|
|
32
|
+
baseline[prop_name] = f"sample_{prop_name}"
|
|
33
|
+
elif prop_schema.type == "integer":
|
|
34
|
+
min_val = prop_schema.minimum if prop_schema.minimum is not None else 1
|
|
35
|
+
baseline[prop_name] = int(min_val)
|
|
36
|
+
elif prop_schema.type == "number":
|
|
37
|
+
min_val = prop_schema.minimum if prop_schema.minimum is not None else 1.0
|
|
38
|
+
baseline[prop_name] = float(min_val)
|
|
39
|
+
elif prop_schema.type == "boolean":
|
|
40
|
+
baseline[prop_name] = True
|
|
41
|
+
elif prop_schema.type == "array":
|
|
42
|
+
baseline[prop_name] = []
|
|
43
|
+
elif prop_schema.type == "object":
|
|
44
|
+
baseline[prop_name] = cls.generate_valid_baseline(prop_schema)
|
|
45
|
+
else:
|
|
46
|
+
baseline[prop_name] = "valid_value"
|
|
47
|
+
|
|
48
|
+
return baseline
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def generate_object_mutations(
|
|
52
|
+
cls,
|
|
53
|
+
schema: JSONSchema,
|
|
54
|
+
baseline_payload: dict[str, Any],
|
|
55
|
+
) -> list[tuple[str, str, Any, str]]:
|
|
56
|
+
"""Generate object-level mutations (omissions, unexpected properties, type inversion)."""
|
|
57
|
+
mutations: list[tuple[str, str, Any, str]] = []
|
|
58
|
+
|
|
59
|
+
# 1. Combinatorial omission of required fields
|
|
60
|
+
for req_field in schema.required:
|
|
61
|
+
if req_field in baseline_payload:
|
|
62
|
+
omitted_payload = copy.deepcopy(baseline_payload)
|
|
63
|
+
del omitted_payload[req_field]
|
|
64
|
+
mutations.append((
|
|
65
|
+
f"Structure: Omit Required Field '{req_field}'",
|
|
66
|
+
f"Reject payload missing required property '{req_field}'",
|
|
67
|
+
omitted_payload,
|
|
68
|
+
"Structure",
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
# 2. Completely empty dictionary when required fields exist
|
|
72
|
+
if schema.required:
|
|
73
|
+
mutations.append((
|
|
74
|
+
"Structure: Empty Object Payload",
|
|
75
|
+
"Reject completely empty object {} when required properties exist",
|
|
76
|
+
{},
|
|
77
|
+
"Structure",
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
# 3. Unexpected property injection when additionalProperties is False
|
|
81
|
+
if schema.additional_properties is False:
|
|
82
|
+
extra_payload = copy.deepcopy(baseline_payload)
|
|
83
|
+
extra_payload["__unexpected_field__"] = "invalid"
|
|
84
|
+
mutations.append((
|
|
85
|
+
"Structure: Unexpected Additional Property",
|
|
86
|
+
"Reject unlisted property when additionalProperties is false",
|
|
87
|
+
extra_payload,
|
|
88
|
+
"Structure",
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
# 4. Type inversion: Primitive scalar instead of object dictionary
|
|
92
|
+
mutations.append((
|
|
93
|
+
"Type: String Instead of Object",
|
|
94
|
+
"Reject string primitive instead of JSON dictionary object",
|
|
95
|
+
"invalid_raw_string_payload",
|
|
96
|
+
"Type",
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
return mutations
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""String field boundary and negative test generator."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from specforge.parser.models import JSONSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StringFuzzer:
|
|
9
|
+
"""Generates boundary and mutation payloads for string schema fields."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def generate_mutations(
|
|
13
|
+
cls,
|
|
14
|
+
field_name: str,
|
|
15
|
+
schema: JSONSchema,
|
|
16
|
+
valid_example: str = "valid_sample",
|
|
17
|
+
) -> list[tuple[str, str, Any, str]]:
|
|
18
|
+
"""Generate a list of mutations for a string field.
|
|
19
|
+
|
|
20
|
+
Returns tuples of: (mutation_name, description, mutated_value, category)
|
|
21
|
+
"""
|
|
22
|
+
mutations: list[tuple[str, str, Any, str]] = []
|
|
23
|
+
|
|
24
|
+
# 1. Whitespace Fuzzing
|
|
25
|
+
mutations.append((
|
|
26
|
+
"Whitespace: Single Space",
|
|
27
|
+
f"Reject single space string for '{field_name}'",
|
|
28
|
+
" ",
|
|
29
|
+
"Whitespace",
|
|
30
|
+
))
|
|
31
|
+
mutations.append((
|
|
32
|
+
"Whitespace: Multi-Space Only",
|
|
33
|
+
f"Reject multiple whitespace-only string for '{field_name}'",
|
|
34
|
+
" ",
|
|
35
|
+
"Whitespace",
|
|
36
|
+
))
|
|
37
|
+
mutations.append((
|
|
38
|
+
"Whitespace: Tabs and Newlines",
|
|
39
|
+
f"Reject tab and newline whitespace for '{field_name}'",
|
|
40
|
+
"\t\n\r ",
|
|
41
|
+
"Whitespace",
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
# 2. Empty String
|
|
45
|
+
if schema.min_length is None or schema.min_length > 0:
|
|
46
|
+
mutations.append((
|
|
47
|
+
"Boundary: Empty String",
|
|
48
|
+
f"Reject empty string for '{field_name}'",
|
|
49
|
+
"",
|
|
50
|
+
"Boundary",
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# 3. Min/Max Length Boundaries
|
|
54
|
+
if schema.min_length is not None and schema.min_length > 1:
|
|
55
|
+
mutations.append((
|
|
56
|
+
"Boundary: Below Min Length",
|
|
57
|
+
f"Reject string of length {schema.min_length - 1} (< min {schema.min_length}) for '{field_name}'",
|
|
58
|
+
"a" * (schema.min_length - 1),
|
|
59
|
+
"Boundary",
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
if schema.max_length is not None:
|
|
63
|
+
mutations.append((
|
|
64
|
+
"Boundary: Exceeds Max Length",
|
|
65
|
+
f"Reject string of length {schema.max_length + 1} (> max {schema.max_length}) for '{field_name}'",
|
|
66
|
+
"a" * (schema.max_length + 1),
|
|
67
|
+
"Boundary",
|
|
68
|
+
))
|
|
69
|
+
else:
|
|
70
|
+
# Test buffer DoS guardrails
|
|
71
|
+
mutations.append((
|
|
72
|
+
"Buffer: Large String (5000 chars)",
|
|
73
|
+
f"Safely handle 5,000-character payload for '{field_name}'",
|
|
74
|
+
"x" * 5000,
|
|
75
|
+
"Buffer",
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
# 4. Unicode & Special Characters
|
|
79
|
+
mutations.append((
|
|
80
|
+
"Unicode: 4-Byte Emojis",
|
|
81
|
+
f"Verify 4-byte UTF-8 emoji support (no crash) for '{field_name}'",
|
|
82
|
+
"🚀✨🔥💡",
|
|
83
|
+
"Unicode",
|
|
84
|
+
))
|
|
85
|
+
mutations.append((
|
|
86
|
+
"Unicode: Null Byte Injection",
|
|
87
|
+
f"Reject or escape null byte injection in '{field_name}'",
|
|
88
|
+
"admin\x00root",
|
|
89
|
+
"Security",
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
# 5. Format-Specific Fuzzing
|
|
93
|
+
if schema.format == "email":
|
|
94
|
+
mutations.append((
|
|
95
|
+
"Format: Missing At Symbol",
|
|
96
|
+
f"Reject malformed email without '@' in '{field_name}'",
|
|
97
|
+
"plainaddress_without_at",
|
|
98
|
+
"Format",
|
|
99
|
+
))
|
|
100
|
+
mutations.append((
|
|
101
|
+
"Format: Missing Domain",
|
|
102
|
+
f"Reject incomplete email '@domain.com' in '{field_name}'",
|
|
103
|
+
"@domain.com",
|
|
104
|
+
"Format",
|
|
105
|
+
))
|
|
106
|
+
elif schema.format == "uuid":
|
|
107
|
+
mutations.append((
|
|
108
|
+
"Format: Malformed UUID",
|
|
109
|
+
f"Reject invalid UUID string in '{field_name}'",
|
|
110
|
+
"not-a-valid-uuid-12345",
|
|
111
|
+
"Format",
|
|
112
|
+
))
|
|
113
|
+
elif schema.format in ("date-time", "date"):
|
|
114
|
+
mutations.append((
|
|
115
|
+
"Format: Impossible Date",
|
|
116
|
+
f"Reject invalid date (Feb 31st) in '{field_name}'",
|
|
117
|
+
"2026-02-31T00:00:00Z",
|
|
118
|
+
"Format",
|
|
119
|
+
))
|
|
120
|
+
mutations.append((
|
|
121
|
+
"Format: Non-ISO Date String",
|
|
122
|
+
f"Reject non-ISO date string in '{field_name}'",
|
|
123
|
+
"Tomorrow morning",
|
|
124
|
+
"Format",
|
|
125
|
+
))
|
|
126
|
+
elif schema.format == "uri":
|
|
127
|
+
mutations.append((
|
|
128
|
+
"Format: Missing URI Protocol",
|
|
129
|
+
f"Reject URI missing scheme (http/https) in '{field_name}'",
|
|
130
|
+
"www.example.com/no-protocol",
|
|
131
|
+
"Format",
|
|
132
|
+
))
|
|
133
|
+
|
|
134
|
+
# 6. Injection Probes (Safety heuristics)
|
|
135
|
+
mutations.append((
|
|
136
|
+
"Security: Basic SQL Injection Probe",
|
|
137
|
+
f"Safely validate/escape SQL injection probe in '{field_name}'",
|
|
138
|
+
"' OR '1'='1",
|
|
139
|
+
"Security",
|
|
140
|
+
))
|
|
141
|
+
mutations.append((
|
|
142
|
+
"Security: XSS Script Tag Probe",
|
|
143
|
+
f"Safely validate/escape script tag in '{field_name}'",
|
|
144
|
+
"<script>alert(1)</script>",
|
|
145
|
+
"Security",
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
# 7. Pattern Non-Compliance
|
|
149
|
+
if schema.pattern:
|
|
150
|
+
mutations.append((
|
|
151
|
+
"Pattern: Non-Matching String",
|
|
152
|
+
f"Reject string violating pattern '{schema.pattern}' for '{field_name}'",
|
|
153
|
+
"__INVALID_PATTERN_VIOLATION__!",
|
|
154
|
+
"Pattern",
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
return mutations
|