schemathesis 4.0.26__py3-none-any.whl → 4.1.1__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.
- schemathesis/cli/commands/run/__init__.py +8 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -0
- schemathesis/cli/commands/run/handlers/output.py +27 -17
- schemathesis/config/_operations.py +5 -0
- schemathesis/config/_phases.py +43 -5
- schemathesis/config/_projects.py +18 -0
- schemathesis/config/schema.json +31 -0
- schemathesis/core/__init__.py +1 -0
- schemathesis/core/failures.py +2 -1
- schemathesis/engine/context.py +39 -4
- schemathesis/engine/core.py +30 -9
- schemathesis/engine/events.py +12 -2
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +5 -0
- schemathesis/engine/phases/probes.py +9 -3
- schemathesis/engine/phases/stateful/_executor.py +9 -1
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +13 -0
- schemathesis/filters.py +4 -0
- schemathesis/generation/case.py +1 -1
- schemathesis/generation/coverage.py +16 -2
- schemathesis/generation/hypothesis/builder.py +14 -1
- schemathesis/hooks.py +40 -14
- schemathesis/openapi/loaders.py +1 -1
- schemathesis/pytest/plugin.py +6 -0
- schemathesis/schemas.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +16 -3
- schemathesis/specs/openapi/checks.py +4 -1
- schemathesis/specs/openapi/formats.py +15 -2
- schemathesis/specs/openapi/schemas.py +26 -6
- schemathesis/specs/openapi/stateful/inference.py +250 -0
- schemathesis/transport/requests.py +3 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/METADATA +3 -2
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/RECORD +37 -35
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/licenses/LICENSE +0 -0
@@ -77,6 +77,11 @@ class Phase:
|
|
77
77
|
"""Determine if phase should run based on context & configuration."""
|
78
78
|
return self.is_enabled and not ctx.has_to_stop
|
79
79
|
|
80
|
+
def enable(self) -> None:
|
81
|
+
"""Enable this test phase."""
|
82
|
+
self.is_enabled = True
|
83
|
+
self.skip_reason = None
|
84
|
+
|
80
85
|
|
81
86
|
def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
|
82
87
|
from urllib3.exceptions import InsecureRequestWarning
|
@@ -42,9 +42,15 @@ def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
|
|
42
42
|
for result in probes:
|
43
43
|
if isinstance(result.probe, NullByteInHeader) and result.is_failure:
|
44
44
|
from schemathesis.specs.openapi import formats
|
45
|
-
from schemathesis.specs.openapi.formats import
|
46
|
-
|
47
|
-
|
45
|
+
from schemathesis.specs.openapi.formats import (
|
46
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS,
|
47
|
+
HEADER_FORMAT,
|
48
|
+
header_values,
|
49
|
+
)
|
50
|
+
|
51
|
+
formats.register(
|
52
|
+
HEADER_FORMAT, header_values(exclude_characters=DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00")
|
53
|
+
)
|
48
54
|
payload = Ok(ProbePayload(probes=probes))
|
49
55
|
yield events.PhaseFinished(phase=phase, status=status, payload=payload)
|
50
56
|
|
@@ -330,10 +330,18 @@ def validate_response(
|
|
330
330
|
if stateful_ctx.is_seen_in_suite(failure) or stateful_ctx.is_seen_in_run(failure):
|
331
331
|
return
|
332
332
|
failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
|
333
|
+
|
334
|
+
# Collect the whole chain of cURL commands
|
335
|
+
commands = []
|
336
|
+
parent = recorder.find_parent(case_id=failure_data.case.id)
|
337
|
+
while parent is not None:
|
338
|
+
commands.append(parent.as_curl_command(headers=failure_data.headers, verify=failure_data.verify))
|
339
|
+
parent = recorder.find_parent(case_id=parent.id)
|
340
|
+
commands.append(failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify))
|
333
341
|
recorder.record_check_failure(
|
334
342
|
name=name,
|
335
343
|
case_id=failure_data.case.id,
|
336
|
-
code_sample=
|
344
|
+
code_sample="\n".join(commands),
|
337
345
|
failure=failure,
|
338
346
|
)
|
339
347
|
control.count_failure()
|
@@ -76,6 +76,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
|
76
76
|
status = event.status
|
77
77
|
if event.status in (Status.ERROR, Status.FAILURE):
|
78
78
|
engine.control.count_failure()
|
79
|
+
engine.record_observations(event.recorder)
|
79
80
|
if isinstance(event, events.Interrupted) or engine.is_interrupted:
|
80
81
|
status = Status.INTERRUPTED
|
81
82
|
engine.stop()
|
@@ -49,6 +49,7 @@ from schemathesis.generation.case import Case
|
|
49
49
|
from schemathesis.generation.hypothesis.builder import (
|
50
50
|
InvalidHeadersExampleMark,
|
51
51
|
InvalidRegexMark,
|
52
|
+
MissingPathParameters,
|
52
53
|
NonSerializableMark,
|
53
54
|
UnsatisfiableExampleMark,
|
54
55
|
)
|
@@ -73,6 +74,7 @@ def run_test(
|
|
73
74
|
yield scenario_started
|
74
75
|
errors: list[Exception] = []
|
75
76
|
skip_reason = None
|
77
|
+
error: Exception
|
76
78
|
test_start_time = time.monotonic()
|
77
79
|
recorder = ScenarioRecorder(label=operation.label)
|
78
80
|
state = TestingState()
|
@@ -227,11 +229,15 @@ def run_test(
|
|
227
229
|
and any(check.status == Status.FAILURE for checks in recorder.checks.values() for check in checks)
|
228
230
|
):
|
229
231
|
status = Status.FAILURE
|
232
|
+
|
233
|
+
# Check for various errors during generation (tests may still have been generated)
|
234
|
+
|
230
235
|
if UnsatisfiableExampleMark.is_set(test_function):
|
231
236
|
status = Status.ERROR
|
232
237
|
yield non_fatal_error(
|
233
238
|
hypothesis.errors.Unsatisfiable("Failed to generate test cases from examples for this API operation")
|
234
239
|
)
|
240
|
+
|
235
241
|
non_serializable = NonSerializableMark.get(test_function)
|
236
242
|
if non_serializable is not None and status != Status.ERROR:
|
237
243
|
status = Status.ERROR
|
@@ -248,10 +254,17 @@ def run_test(
|
|
248
254
|
if invalid_regex is not None and status != Status.ERROR:
|
249
255
|
status = Status.ERROR
|
250
256
|
yield non_fatal_error(InvalidRegexPattern.from_schema_error(invalid_regex, from_examples=True))
|
257
|
+
|
251
258
|
invalid_headers = InvalidHeadersExampleMark.get(test_function)
|
252
259
|
if invalid_headers:
|
253
260
|
status = Status.ERROR
|
254
261
|
yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
|
262
|
+
|
263
|
+
missing_path_parameters = MissingPathParameters.get(test_function)
|
264
|
+
if missing_path_parameters:
|
265
|
+
status = Status.ERROR
|
266
|
+
yield non_fatal_error(missing_path_parameters)
|
267
|
+
|
255
268
|
for error in deduplicate_errors(errors):
|
256
269
|
yield non_fatal_error(error)
|
257
270
|
|
schemathesis/filters.py
CHANGED
@@ -175,6 +175,10 @@ class FilterSet:
|
|
175
175
|
"""Whether the filter set does not contain any filters."""
|
176
176
|
return not self._includes and not self._excludes
|
177
177
|
|
178
|
+
def clear(self) -> None:
|
179
|
+
self._includes.clear()
|
180
|
+
self._excludes.clear()
|
181
|
+
|
178
182
|
def include(
|
179
183
|
self,
|
180
184
|
func: MatcherFunc | None = None,
|
schemathesis/generation/case.py
CHANGED
@@ -200,7 +200,7 @@ class Case:
|
|
200
200
|
|
201
201
|
"""
|
202
202
|
hook_context = HookContext(operation=self.operation)
|
203
|
-
dispatch("before_call", hook_context, self, **kwargs)
|
203
|
+
dispatch("before_call", hook_context, self, _with_dual_style_kwargs=True, **kwargs)
|
204
204
|
if self.operation.app is not None:
|
205
205
|
kwargs.setdefault("app", self.operation.app)
|
206
206
|
if "app" in kwargs:
|
@@ -6,7 +6,18 @@ from contextlib import contextmanager, suppress
|
|
6
6
|
from dataclasses import dataclass
|
7
7
|
from functools import lru_cache, partial
|
8
8
|
from itertools import combinations
|
9
|
-
|
9
|
+
|
10
|
+
try:
|
11
|
+
from json.encoder import _make_iterencode # type: ignore[attr-defined]
|
12
|
+
except ImportError:
|
13
|
+
_make_iterencode = None
|
14
|
+
|
15
|
+
try:
|
16
|
+
from json.encoder import c_make_encoder # type: ignore[attr-defined]
|
17
|
+
except ImportError:
|
18
|
+
c_make_encoder = None
|
19
|
+
|
20
|
+
from json.encoder import JSONEncoder, encode_basestring_ascii # type: ignore
|
10
21
|
from typing import Any, Callable, Generator, Iterator, TypeVar, cast
|
11
22
|
from urllib.parse import quote_plus
|
12
23
|
|
@@ -285,10 +296,13 @@ T = TypeVar("T")
|
|
285
296
|
|
286
297
|
if c_make_encoder is not None:
|
287
298
|
_iterencode = c_make_encoder(None, None, encode_basestring_ascii, None, ":", ",", True, False, False)
|
288
|
-
|
299
|
+
elif _make_iterencode is not None:
|
289
300
|
_iterencode = _make_iterencode(
|
290
301
|
None, None, encode_basestring_ascii, None, float.__repr__, ":", ",", True, False, True
|
291
302
|
)
|
303
|
+
else:
|
304
|
+
encoder = JSONEncoder(skipkeys=False, sort_keys=False, indent=None, separators=(":", ","))
|
305
|
+
_iterencode = encoder.iterencode
|
292
306
|
|
293
307
|
|
294
308
|
def _encode(o: Any) -> str:
|
@@ -20,7 +20,7 @@ from requests.models import CaseInsensitiveDict
|
|
20
20
|
from schemathesis import auths
|
21
21
|
from schemathesis.auths import AuthStorage, AuthStorageMark
|
22
22
|
from schemathesis.config import GenerationConfig, ProjectConfig
|
23
|
-
from schemathesis.core import NOT_SET, NotSet, SpecificationFeature, media_types
|
23
|
+
from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, SpecificationFeature, media_types
|
24
24
|
from schemathesis.core.errors import InvalidSchema, SerializationNotPossible
|
25
25
|
from schemathesis.core.marks import Mark
|
26
26
|
from schemathesis.core.transforms import deepclone
|
@@ -185,6 +185,18 @@ def create_test(
|
|
185
185
|
generation_config=generation,
|
186
186
|
)
|
187
187
|
|
188
|
+
injected_path_parameter_names = [
|
189
|
+
parameter.name
|
190
|
+
for parameter in operation.path_parameters
|
191
|
+
if parameter.definition.get(INJECTED_PATH_PARAMETER_KEY)
|
192
|
+
]
|
193
|
+
if injected_path_parameter_names:
|
194
|
+
names = ", ".join(f"'{name}'" for name in injected_path_parameter_names)
|
195
|
+
plural = "s" if len(injected_path_parameter_names) > 1 else ""
|
196
|
+
verb = "are" if len(injected_path_parameter_names) > 1 else "is"
|
197
|
+
error = InvalidSchema(f"Path parameter{plural} {names} {verb} not defined")
|
198
|
+
MissingPathParameters.set(hypothesis_test, error)
|
199
|
+
|
188
200
|
setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
|
189
201
|
|
190
202
|
return hypothesis_test
|
@@ -902,3 +914,4 @@ UnsatisfiableExampleMark = Mark[Unsatisfiable](attr_name="unsatisfiable_example"
|
|
902
914
|
NonSerializableMark = Mark[SerializationNotPossible](attr_name="non_serializable")
|
903
915
|
InvalidRegexMark = Mark[SchemaError](attr_name="invalid_regex")
|
904
916
|
InvalidHeadersExampleMark = Mark[dict[str, str]](attr_name="invalid_example_header")
|
917
|
+
MissingPathParameters = Mark[InvalidSchema](attr_name="missing_path_parameters")
|
schemathesis/hooks.py
CHANGED
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import inspect
|
4
4
|
from collections import defaultdict
|
5
|
+
from contextlib import contextmanager
|
5
6
|
from dataclasses import dataclass, field
|
6
7
|
from enum import Enum, unique
|
7
|
-
from functools import partial
|
8
|
-
from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
|
8
|
+
from functools import lru_cache, partial
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, cast
|
9
10
|
|
10
11
|
from schemathesis.core.marks import Mark
|
11
12
|
from schemathesis.core.transport import Response
|
@@ -54,18 +55,28 @@ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
|
|
54
55
|
filter_used = False
|
55
56
|
filter_set = FilterSet()
|
56
57
|
|
58
|
+
@contextmanager
|
59
|
+
def _reset_on_error() -> Generator:
|
60
|
+
try:
|
61
|
+
yield
|
62
|
+
except Exception:
|
63
|
+
filter_set.clear()
|
64
|
+
raise
|
65
|
+
|
57
66
|
def register(hook: str | Callable) -> Callable:
|
58
67
|
nonlocal filter_set
|
59
68
|
|
60
69
|
if filter_used:
|
61
|
-
|
70
|
+
with _reset_on_error():
|
71
|
+
validate_filterable_hook(hook)
|
62
72
|
|
63
73
|
if isinstance(hook, str):
|
64
74
|
|
65
75
|
def decorator(func: Callable) -> Callable:
|
66
76
|
hook_name = cast(str, hook)
|
67
77
|
if filter_used:
|
68
|
-
|
78
|
+
with _reset_on_error():
|
79
|
+
validate_filterable_hook(hook)
|
69
80
|
func.filter_set = filter_set # type: ignore[attr-defined]
|
70
81
|
return dispatcher.register_hook_with_name(func, hook_name)
|
71
82
|
|
@@ -86,13 +97,15 @@ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
|
|
86
97
|
nonlocal filter_used
|
87
98
|
|
88
99
|
filter_used = True
|
89
|
-
|
100
|
+
with _reset_on_error():
|
101
|
+
filter_set.include(*args, **kwargs)
|
90
102
|
|
91
103
|
def exclude(*args: Any, **kwargs: Any) -> None:
|
92
104
|
nonlocal filter_used
|
93
105
|
|
94
106
|
filter_used = True
|
95
|
-
|
107
|
+
with _reset_on_error():
|
108
|
+
filter_set.exclude(*args, **kwargs)
|
96
109
|
|
97
110
|
attach_filter_chain(target, "apply_to", include)
|
98
111
|
attach_filter_chain(target, "skip_for", exclude)
|
@@ -113,11 +126,9 @@ class HookDispatcher:
|
|
113
126
|
_hooks: defaultdict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
|
114
127
|
_specs: ClassVar[dict[str, RegisteredHook]] = {}
|
115
128
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
def hook(self, hook: str | Callable) -> Callable:
|
120
|
-
raise NotImplementedError
|
129
|
+
@property
|
130
|
+
def hook(self) -> Callable:
|
131
|
+
return to_filterable_hook(self)
|
121
132
|
|
122
133
|
def apply(self, hook: Callable, *, name: str | None = None) -> Callable[[Callable], Callable]:
|
123
134
|
"""Register hook to run only on one test function.
|
@@ -201,6 +212,9 @@ class HookDispatcher:
|
|
201
212
|
"""Get a list of hooks registered for a name."""
|
202
213
|
return self._hooks.get(name, [])
|
203
214
|
|
215
|
+
def get_all(self) -> dict[str, list[Callable]]:
|
216
|
+
return self._hooks
|
217
|
+
|
204
218
|
def apply_to_container(
|
205
219
|
self, strategy: st.SearchStrategy, container: str, context: HookContext
|
206
220
|
) -> st.SearchStrategy:
|
@@ -225,12 +239,18 @@ class HookDispatcher:
|
|
225
239
|
strategy = strategy.flatmap(hook)
|
226
240
|
return strategy
|
227
241
|
|
228
|
-
def dispatch(
|
242
|
+
def dispatch(
|
243
|
+
self, name: str, context: HookContext, *args: Any, _with_dual_style_kwargs: bool = False, **kwargs: Any
|
244
|
+
) -> None:
|
229
245
|
"""Run all hooks for the given name."""
|
230
246
|
for hook in self.get_all_by_name(name):
|
231
247
|
if _should_skip_hook(hook, context):
|
232
248
|
continue
|
233
|
-
|
249
|
+
# NOTE: It is a backward-compat shim to support calling `before_call` with `**kwargs` OR with `kwargs`.
|
250
|
+
if _with_dual_style_kwargs and not has_var_keyword(hook):
|
251
|
+
hook(context, *args, kwargs)
|
252
|
+
else:
|
253
|
+
hook(context, *args, **kwargs)
|
234
254
|
|
235
255
|
def unregister(self, hook: Callable) -> None:
|
236
256
|
"""Unregister a specific hook."""
|
@@ -246,6 +266,12 @@ class HookDispatcher:
|
|
246
266
|
self._hooks = defaultdict(list)
|
247
267
|
|
248
268
|
|
269
|
+
@lru_cache(maxsize=16)
|
270
|
+
def has_var_keyword(hook: Callable) -> bool:
|
271
|
+
"""Check if hook function accepts **kwargs."""
|
272
|
+
return any(p.kind == inspect.Parameter.VAR_KEYWORD for p in inspect.signature(hook).parameters.values())
|
273
|
+
|
274
|
+
|
249
275
|
def _should_skip_hook(hook: Callable, ctx: HookContext) -> bool:
|
250
276
|
filter_set = getattr(hook, "filter_set", None)
|
251
277
|
return filter_set is not None and ctx.operation is not None and not filter_set.match(ctx)
|
@@ -349,7 +375,7 @@ def before_init_operation(context: HookContext, operation: APIOperation) -> None
|
|
349
375
|
|
350
376
|
|
351
377
|
@HookDispatcher.register_spec([HookScope.GLOBAL])
|
352
|
-
def before_call(context: HookContext, case: Case,
|
378
|
+
def before_call(context: HookContext, case: Case, kwargs: dict[str, Any]) -> None:
|
353
379
|
"""Called before every network call in CLI tests.
|
354
380
|
|
355
381
|
Use cases:
|
schemathesis/openapi/loaders.py
CHANGED
@@ -282,7 +282,7 @@ def load_content(content: str, content_type: ContentType) -> dict[str, Any]:
|
|
282
282
|
# If type is unknown, try JSON first, then YAML
|
283
283
|
try:
|
284
284
|
return _load_json(content)
|
285
|
-
except
|
285
|
+
except LoaderError:
|
286
286
|
return _load_yaml(content)
|
287
287
|
|
288
288
|
|
schemathesis/pytest/plugin.py
CHANGED
@@ -298,6 +298,7 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
|
|
298
298
|
from schemathesis.generation.hypothesis.builder import (
|
299
299
|
InvalidHeadersExampleMark,
|
300
300
|
InvalidRegexMark,
|
301
|
+
MissingPathParameters,
|
301
302
|
NonSerializableMark,
|
302
303
|
UnsatisfiableExampleMark,
|
303
304
|
)
|
@@ -333,8 +334,13 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
|
|
333
334
|
pytest.skip(exc.args[0])
|
334
335
|
except SchemaError as exc:
|
335
336
|
raise InvalidRegexPattern.from_schema_error(exc, from_examples=False) from exc
|
337
|
+
|
336
338
|
invalid_headers = InvalidHeadersExampleMark.get(pyfuncitem.obj)
|
337
339
|
if invalid_headers is not None:
|
338
340
|
raise InvalidHeadersExample.from_headers(invalid_headers) from None
|
341
|
+
|
342
|
+
missing_path_parameters = MissingPathParameters.get(pyfuncitem.obj)
|
343
|
+
if missing_path_parameters:
|
344
|
+
raise missing_path_parameters from None
|
339
345
|
else:
|
340
346
|
yield
|
schemathesis/schemas.py
CHANGED
@@ -27,7 +27,7 @@ from schemathesis.generation.case import Case
|
|
27
27
|
from schemathesis.generation.hypothesis import strategies
|
28
28
|
from schemathesis.generation.hypothesis.given import GivenInput, given_proxy
|
29
29
|
from schemathesis.generation.meta import CaseMetadata
|
30
|
-
from schemathesis.hooks import HookDispatcherMark
|
30
|
+
from schemathesis.hooks import HookDispatcherMark, _should_skip_hook
|
31
31
|
|
32
32
|
from .auths import AuthStorage
|
33
33
|
from .filters import (
|
@@ -712,14 +712,22 @@ class APIOperation(Generic[P]):
|
|
712
712
|
def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
|
713
713
|
context = HookContext(operation=self)
|
714
714
|
for hook in dispatcher.get_all_by_name("before_generate_case"):
|
715
|
+
if _should_skip_hook(hook, context):
|
716
|
+
continue
|
715
717
|
_strategy = hook(context, _strategy)
|
716
718
|
for hook in dispatcher.get_all_by_name("filter_case"):
|
719
|
+
if _should_skip_hook(hook, context):
|
720
|
+
continue
|
717
721
|
hook = partial(hook, context)
|
718
722
|
_strategy = _strategy.filter(hook)
|
719
723
|
for hook in dispatcher.get_all_by_name("map_case"):
|
724
|
+
if _should_skip_hook(hook, context):
|
725
|
+
continue
|
720
726
|
hook = partial(hook, context)
|
721
727
|
_strategy = _strategy.map(hook)
|
722
728
|
for hook in dispatcher.get_all_by_name("flatmap_case"):
|
729
|
+
if _should_skip_hook(hook, context):
|
730
|
+
continue
|
723
731
|
hook = partial(hook, context)
|
724
732
|
_strategy = _strategy.flatmap(hook)
|
725
733
|
return _strategy
|
@@ -36,7 +36,13 @@ from ... import auths
|
|
36
36
|
from ...generation import GenerationMode
|
37
37
|
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
38
38
|
from .constants import LOCATION_TO_CONTAINER
|
39
|
-
from .formats import
|
39
|
+
from .formats import (
|
40
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS,
|
41
|
+
HEADER_FORMAT,
|
42
|
+
STRING_FORMATS,
|
43
|
+
get_default_format_strategies,
|
44
|
+
header_values,
|
45
|
+
)
|
40
46
|
from .media_types import MEDIA_TYPES
|
41
47
|
from .negative import negative_schema
|
42
48
|
from .negative.utils import can_negate
|
@@ -410,10 +416,17 @@ def jsonify_python_specific_types(value: dict[str, Any]) -> dict[str, Any]:
|
|
410
416
|
|
411
417
|
def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.SearchStrategy]:
|
412
418
|
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS}
|
419
|
+
header_values_kwargs = {}
|
413
420
|
if generation_config.exclude_header_characters is not None:
|
414
|
-
|
421
|
+
header_values_kwargs["exclude_characters"] = generation_config.exclude_header_characters
|
422
|
+
if not generation_config.allow_x00:
|
423
|
+
header_values_kwargs["exclude_characters"] += "\x00"
|
415
424
|
elif not generation_config.allow_x00:
|
416
|
-
|
425
|
+
header_values_kwargs["exclude_characters"] = DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00"
|
426
|
+
if generation_config.codec is not None:
|
427
|
+
header_values_kwargs["codec"] = generation_config.codec
|
428
|
+
if header_values_kwargs:
|
429
|
+
custom_formats[HEADER_FORMAT] = header_values(**header_values_kwargs)
|
417
430
|
return custom_formats
|
418
431
|
|
419
432
|
|
@@ -366,7 +366,10 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
|
|
366
366
|
|
367
367
|
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
368
368
|
return True
|
369
|
-
|
369
|
+
|
370
|
+
# Only check for use-after-free on successful responses (2xx) or redirects (3xx)
|
371
|
+
# Other status codes indicate request-level issues / server errors, not successful resource access
|
372
|
+
if not (200 <= response.status_code < 400):
|
370
373
|
return None
|
371
374
|
|
372
375
|
for related_case in ctx._find_related(case_id=case.id):
|
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import platform
|
3
4
|
import string
|
4
5
|
from base64 import b64encode
|
5
6
|
from functools import lru_cache
|
@@ -11,7 +12,15 @@ if TYPE_CHECKING:
|
|
11
12
|
from hypothesis import strategies as st
|
12
13
|
|
13
14
|
|
15
|
+
IS_PYPY = platform.python_implementation() == "PyPy"
|
14
16
|
STRING_FORMATS: dict[str, st.SearchStrategy] = {}
|
17
|
+
# For some reason PyPy can't send header values with codepoints > 128, while CPython can
|
18
|
+
if IS_PYPY:
|
19
|
+
MAX_HEADER_CODEPOINT = 128
|
20
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r\x1f\x1e\x1d\x1c"
|
21
|
+
else:
|
22
|
+
MAX_HEADER_CODEPOINT = 255
|
23
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r"
|
15
24
|
|
16
25
|
|
17
26
|
def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
|
@@ -65,11 +74,15 @@ def unregister_string_format(name: str) -> None:
|
|
65
74
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
66
75
|
|
67
76
|
|
68
|
-
def header_values(
|
77
|
+
def header_values(
|
78
|
+
codec: str | None = None, exclude_characters: str = DEFAULT_HEADER_EXCLUDE_CHARACTERS
|
79
|
+
) -> st.SearchStrategy[str]:
|
69
80
|
from hypothesis import strategies as st
|
70
81
|
|
71
82
|
return st.text(
|
72
|
-
alphabet=st.characters(
|
83
|
+
alphabet=st.characters(
|
84
|
+
min_codepoint=0, max_codepoint=MAX_HEADER_CODEPOINT, codec=codec, exclude_characters=exclude_characters
|
85
|
+
)
|
73
86
|
# Header values with leading non-visible chars can't be sent with `requests`
|
74
87
|
).map(str.lstrip)
|
75
88
|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import itertools
|
4
|
+
import string
|
4
5
|
from collections import defaultdict
|
5
6
|
from contextlib import ExitStack, contextmanager, suppress
|
6
7
|
from dataclasses import dataclass, field
|
@@ -29,7 +30,7 @@ from requests.exceptions import InvalidHeader
|
|
29
30
|
from requests.structures import CaseInsensitiveDict
|
30
31
|
from requests.utils import check_header_validity
|
31
32
|
|
32
|
-
from schemathesis.core import NOT_SET, NotSet, Specification, deserialization, media_types
|
33
|
+
from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
|
33
34
|
from schemathesis.core.compat import RefResolutionError
|
34
35
|
from schemathesis.core.errors import InternalError, InvalidSchema, LoaderError, LoaderErrorKind, OperationNotFound
|
35
36
|
from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
|
@@ -89,6 +90,17 @@ def check_header(parameter: dict[str, Any]) -> None:
|
|
89
90
|
raise InvalidSchema(f"Invalid header name: {name}")
|
90
91
|
|
91
92
|
|
93
|
+
def get_template_fields(template: str) -> set[str]:
|
94
|
+
"""Extract named placeholders from a string template."""
|
95
|
+
try:
|
96
|
+
parameters = {name for _, name, _, _ in string.Formatter().parse(template) if name is not None}
|
97
|
+
# Check for malformed params to avoid injecting them - they will be checked later on in the workflow
|
98
|
+
template.format(**dict.fromkeys(parameters, ""))
|
99
|
+
return parameters
|
100
|
+
except (ValueError, IndexError):
|
101
|
+
return set()
|
102
|
+
|
103
|
+
|
92
104
|
@dataclass(eq=False, repr=False)
|
93
105
|
class BaseOpenAPISchema(BaseSchema):
|
94
106
|
nullable_name: ClassVar[str] = ""
|
@@ -100,6 +112,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
100
112
|
# excessive resolving
|
101
113
|
_inline_reference_cache_lock: RLock = field(default_factory=RLock)
|
102
114
|
component_locations: ClassVar[tuple[tuple[str, ...], ...]] = ()
|
115
|
+
_path_parameter_template: ClassVar[dict[str, Any]] = None # type: ignore
|
103
116
|
|
104
117
|
@property
|
105
118
|
def specification(self) -> Specification:
|
@@ -232,7 +245,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
232
245
|
|
233
246
|
return statistic
|
234
247
|
|
235
|
-
def _operation_iter(self) ->
|
248
|
+
def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
|
236
249
|
try:
|
237
250
|
paths = self.raw_schema["paths"]
|
238
251
|
except KeyError:
|
@@ -243,13 +256,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
243
256
|
try:
|
244
257
|
if "$ref" in path_item:
|
245
258
|
_, path_item = resolve(path_item["$ref"])
|
246
|
-
# Straightforward iteration is faster than converting to a set & calculating length.
|
247
259
|
for method, definition in path_item.items():
|
248
260
|
if should_skip(path, method, definition):
|
249
261
|
continue
|
250
|
-
yield definition
|
262
|
+
yield (method, path, definition)
|
251
263
|
except SCHEMA_PARSING_ERRORS:
|
252
|
-
# Ignore errors
|
253
264
|
continue
|
254
265
|
|
255
266
|
def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
|
@@ -393,7 +404,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
393
404
|
resolved: dict[str, Any],
|
394
405
|
scope: str,
|
395
406
|
) -> APIOperation:
|
396
|
-
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
397
407
|
__tracebackhide__ = True
|
398
408
|
base_url = self.get_base_url()
|
399
409
|
operation: APIOperation[OpenAPIParameter] = APIOperation(
|
@@ -406,6 +416,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
406
416
|
)
|
407
417
|
for parameter in parameters:
|
408
418
|
operation.add_parameter(parameter)
|
419
|
+
# Inject unconstrained path parameters if any is missing
|
420
|
+
missing_parameter_names = get_template_fields(operation.path) - {
|
421
|
+
parameter.name for parameter in operation.path_parameters
|
422
|
+
}
|
423
|
+
for name in missing_parameter_names:
|
424
|
+
definition = {"name": name, INJECTED_PATH_PARAMETER_KEY: True, **deepclone(self._path_parameter_template)}
|
425
|
+
for parameter in self.collect_parameters([definition], resolved):
|
426
|
+
operation.add_parameter(parameter)
|
409
427
|
config = self.config.generation_for(operation=operation)
|
410
428
|
if config.with_security_parameters:
|
411
429
|
self.security.process_definitions(self.raw_schema, operation, self.resolver)
|
@@ -837,6 +855,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
837
855
|
security = SwaggerSecurityProcessor()
|
838
856
|
component_locations: ClassVar[tuple[tuple[str, ...], ...]] = (("definitions",),)
|
839
857
|
links_field = "x-links"
|
858
|
+
_path_parameter_template = {"in": "path", "required": True, "type": "string"}
|
840
859
|
|
841
860
|
@property
|
842
861
|
def specification(self) -> Specification:
|
@@ -1005,6 +1024,7 @@ class OpenApi30(SwaggerV20):
|
|
1005
1024
|
security = OpenAPISecurityProcessor()
|
1006
1025
|
component_locations = (("components", "schemas"),)
|
1007
1026
|
links_field = "links"
|
1027
|
+
_path_parameter_template = {"in": "path", "required": True, "schema": {"type": "string"}}
|
1008
1028
|
|
1009
1029
|
@property
|
1010
1030
|
def specification(self) -> Specification:
|