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.
@@ -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