schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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/__init__.py +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -1,119 +1,74 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from typing import Any, Callable, Dict, Iterable, NoReturn, Optional, Tuple, Union
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Iterable, Optional, Union, cast
|
|
7
6
|
from urllib.parse import quote_plus
|
|
8
|
-
from weakref import WeakKeyDictionary
|
|
9
7
|
|
|
8
|
+
import jsonschema.protocols
|
|
9
|
+
from hypothesis import event, note, reject
|
|
10
10
|
from hypothesis import strategies as st
|
|
11
11
|
from hypothesis_jsonschema import from_schema
|
|
12
|
-
from requests.auth import _basic_auth_str
|
|
13
12
|
from requests.structures import CaseInsensitiveDict
|
|
14
13
|
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
from
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
from .
|
|
23
|
-
|
|
14
|
+
from schemathesis.config import GenerationConfig
|
|
15
|
+
from schemathesis.core import NOT_SET, media_types
|
|
16
|
+
from schemathesis.core.control import SkipTest
|
|
17
|
+
from schemathesis.core.errors import SERIALIZERS_SUGGESTION_MESSAGE, MalformedMediaType, SerializationNotPossible
|
|
18
|
+
from schemathesis.core.jsonschema.types import JsonSchema
|
|
19
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
20
|
+
from schemathesis.core.transport import prepare_urlencoded
|
|
21
|
+
from schemathesis.generation.meta import (
|
|
22
|
+
CaseMetadata,
|
|
23
|
+
ComponentInfo,
|
|
24
|
+
ExamplesPhaseData,
|
|
25
|
+
FuzzingPhaseData,
|
|
26
|
+
GenerationInfo,
|
|
27
|
+
PhaseInfo,
|
|
28
|
+
StatefulPhaseData,
|
|
29
|
+
TestPhase,
|
|
30
|
+
)
|
|
31
|
+
from schemathesis.openapi.generation.filters import is_valid_urlencoded
|
|
32
|
+
from schemathesis.schemas import APIOperation
|
|
33
|
+
from schemathesis.specs.openapi.adapter.parameters import FORM_MEDIA_TYPES, OpenApiBody, OpenApiParameterSet
|
|
34
|
+
from schemathesis.specs.openapi.negative.mutations import MutationMetadata
|
|
35
|
+
|
|
36
|
+
from ... import auths
|
|
37
|
+
from ...generation import GenerationMode
|
|
38
|
+
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
|
39
|
+
from .formats import (
|
|
40
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS,
|
|
41
|
+
HEADER_FORMAT,
|
|
42
|
+
STRING_FORMATS,
|
|
43
|
+
get_default_format_strategies,
|
|
44
|
+
header_values,
|
|
45
|
+
)
|
|
46
|
+
from .media_types import MEDIA_TYPES
|
|
47
|
+
from .negative import GeneratedValue, negative_schema
|
|
24
48
|
from .negative.utils import can_negate
|
|
25
|
-
from .parameters import OpenAPIBody, parameters_to_json_schema
|
|
26
|
-
from .utils import is_header_location
|
|
27
49
|
|
|
28
|
-
HEADER_FORMAT = "_header_value"
|
|
29
|
-
PARAMETERS = frozenset(("path_parameters", "headers", "cookies", "query", "body"))
|
|
30
50
|
SLASH = "/"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
|
|
36
|
-
"""Register a new strategy for generating data for specific string "format".
|
|
37
|
-
|
|
38
|
-
:param str name: Format name. It should correspond the one used in the API schema as the "format" keyword value.
|
|
39
|
-
:param strategy: Hypothesis strategy you'd like to use to generate values for this format.
|
|
40
|
-
"""
|
|
41
|
-
if not isinstance(name, str):
|
|
42
|
-
raise TypeError(f"name must be of type {str}, not {type(name)}")
|
|
43
|
-
if not isinstance(strategy, st.SearchStrategy):
|
|
44
|
-
raise TypeError(f"strategy must be of type {st.SearchStrategy}, not {type(strategy)}")
|
|
45
|
-
|
|
46
|
-
STRING_FORMATS[name] = strategy
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def init_default_strategies() -> None:
|
|
50
|
-
"""Register all default "format" strategies."""
|
|
51
|
-
register_string_format("binary", st.binary())
|
|
52
|
-
register_string_format("byte", st.binary().map(lambda x: b64encode(x).decode()))
|
|
53
|
-
|
|
54
|
-
def make_basic_auth_str(item: Tuple[str, str]) -> str:
|
|
55
|
-
return _basic_auth_str(*item)
|
|
56
|
-
|
|
57
|
-
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
58
|
-
|
|
59
|
-
# RFC 7230, Section 3.2.6
|
|
60
|
-
register_string_format(
|
|
61
|
-
"_header_name",
|
|
62
|
-
st.text(min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)),
|
|
63
|
-
)
|
|
64
|
-
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
65
|
-
header_value = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters="\n\r"))
|
|
66
|
-
# Header values with leading non-visible chars can't be sent with `requests`
|
|
67
|
-
register_string_format(HEADER_FORMAT, header_value.map(str.lstrip))
|
|
68
|
-
register_string_format("_basic_auth", st.tuples(latin1_text, latin1_text).map(make_basic_auth_str)) # type: ignore
|
|
69
|
-
register_string_format(
|
|
70
|
-
"_bearer_auth",
|
|
71
|
-
header_value.map("Bearer {}".format),
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def is_valid_header(headers: Dict[str, Any]) -> bool:
|
|
76
|
-
"""Verify if the generated headers are valid."""
|
|
77
|
-
for name, value in headers.items():
|
|
78
|
-
if not utils.is_latin_1_encodable(value):
|
|
79
|
-
return False
|
|
80
|
-
if utils.has_invalid_characters(name, value):
|
|
81
|
-
return False
|
|
82
|
-
return True
|
|
83
|
-
|
|
51
|
+
StrategyFactory = Callable[
|
|
52
|
+
[JsonSchema, str, ParameterLocation, Optional[str], GenerationConfig, type[jsonschema.protocols.Validator]],
|
|
53
|
+
st.SearchStrategy,
|
|
54
|
+
]
|
|
84
55
|
|
|
85
|
-
def is_illegal_surrogate(item: Any) -> bool:
|
|
86
|
-
def check(value: Any) -> bool:
|
|
87
|
-
return isinstance(value, str) and bool(re.search(r"[\ud800-\udfff]", value))
|
|
88
56
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def is_valid_query(query: Dict[str, Any]) -> bool:
|
|
95
|
-
"""Surrogates are not allowed in a query string.
|
|
96
|
-
|
|
97
|
-
`requests` and `werkzeug` will fail to send it to the application.
|
|
98
|
-
"""
|
|
99
|
-
for name, value in query.items():
|
|
100
|
-
if is_illegal_surrogate(name) or is_illegal_surrogate(value):
|
|
101
|
-
return False
|
|
102
|
-
return True
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@st.composite # type: ignore
|
|
106
|
-
def get_case_strategy( # pylint: disable=too-many-locals
|
|
107
|
-
draw: Callable,
|
|
57
|
+
@st.composite # type: ignore[misc]
|
|
58
|
+
def openapi_cases(
|
|
59
|
+
draw: st.DrawFn,
|
|
60
|
+
*,
|
|
108
61
|
operation: APIOperation,
|
|
109
|
-
hooks:
|
|
110
|
-
auth_storage:
|
|
111
|
-
|
|
112
|
-
path_parameters:
|
|
113
|
-
headers:
|
|
114
|
-
cookies:
|
|
115
|
-
query:
|
|
62
|
+
hooks: HookDispatcher | None = None,
|
|
63
|
+
auth_storage: auths.AuthStorage | None = None,
|
|
64
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
65
|
+
path_parameters: dict[str, Any] | None = None,
|
|
66
|
+
headers: dict[str, Any] | None = None,
|
|
67
|
+
cookies: dict[str, Any] | None = None,
|
|
68
|
+
query: dict[str, Any] | None = None,
|
|
116
69
|
body: Any = NOT_SET,
|
|
70
|
+
media_type: str | None = None,
|
|
71
|
+
phase: TestPhase = TestPhase.FUZZING,
|
|
117
72
|
) -> Any:
|
|
118
73
|
"""A strategy that creates `Case` instances.
|
|
119
74
|
|
|
@@ -127,272 +82,573 @@ def get_case_strategy( # pylint: disable=too-many-locals
|
|
|
127
82
|
The primary purpose of this behavior is to prevent sending incomplete explicit examples by generating missing parts
|
|
128
83
|
as it works with `body`.
|
|
129
84
|
"""
|
|
130
|
-
|
|
85
|
+
start = time.monotonic()
|
|
131
86
|
|
|
132
|
-
|
|
87
|
+
generation_config = operation.schema.config.generation_for(operation=operation, phase=phase.value)
|
|
133
88
|
|
|
134
|
-
|
|
135
|
-
path_parameters, "path", draw, operation, hook_context, hooks, to_strategy
|
|
136
|
-
)
|
|
137
|
-
headers_value = get_parameters_value(headers, "header", draw, operation, hook_context, hooks, to_strategy)
|
|
138
|
-
cookies_value = get_parameters_value(cookies, "cookie", draw, operation, hook_context, hooks, to_strategy)
|
|
139
|
-
query_value = get_parameters_value(query, "query", draw, operation, hook_context, hooks, to_strategy)
|
|
89
|
+
ctx = HookContext(operation=operation)
|
|
140
90
|
|
|
141
|
-
|
|
142
|
-
|
|
91
|
+
path_parameters_ = generate_parameter(
|
|
92
|
+
ParameterLocation.PATH, path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
93
|
+
)
|
|
94
|
+
headers_ = generate_parameter(
|
|
95
|
+
ParameterLocation.HEADER, headers, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
96
|
+
)
|
|
97
|
+
cookies_ = generate_parameter(
|
|
98
|
+
ParameterLocation.COOKIE, cookies, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
99
|
+
)
|
|
100
|
+
query_ = generate_parameter(
|
|
101
|
+
ParameterLocation.QUERY, query, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
143
102
|
)
|
|
144
103
|
|
|
145
|
-
media_type = None
|
|
146
104
|
if body is NOT_SET:
|
|
147
105
|
if operation.body:
|
|
148
|
-
|
|
106
|
+
body_generator = generation_mode
|
|
107
|
+
if generation_mode.is_negative:
|
|
149
108
|
# Consider only schemas that are possible to negate
|
|
150
|
-
candidates = [item for item in operation.body.items if can_negate(item.
|
|
151
|
-
# Not possible to negate body
|
|
109
|
+
candidates = [item for item in operation.body.items if can_negate(item.optimized_schema)]
|
|
110
|
+
# Not possible to negate body, fallback to positive data generation
|
|
152
111
|
if not candidates:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if has_generated_parameters:
|
|
156
|
-
candidates = operation.body.items
|
|
157
|
-
to_strategy = make_positive_strategy
|
|
158
|
-
else:
|
|
159
|
-
skip(operation.verbose_name)
|
|
112
|
+
candidates = operation.body.items
|
|
113
|
+
body_generator = GenerationMode.POSITIVE
|
|
160
114
|
else:
|
|
161
115
|
candidates = operation.body.items
|
|
162
116
|
parameter = draw(st.sampled_from(candidates))
|
|
163
|
-
strategy = _get_body_strategy(parameter,
|
|
164
|
-
strategy = apply_hooks(operation,
|
|
117
|
+
strategy = _get_body_strategy(parameter, operation, generation_config, draw, body_generator)
|
|
118
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, ParameterLocation.BODY)
|
|
165
119
|
# Parameter may have a wildcard media type. In this case, choose any supported one
|
|
166
|
-
possible_media_types = sorted(
|
|
120
|
+
possible_media_types = sorted(
|
|
121
|
+
operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
|
|
122
|
+
)
|
|
167
123
|
if not possible_media_types:
|
|
168
124
|
all_media_types = operation.get_request_payload_content_types()
|
|
169
|
-
if all(
|
|
125
|
+
if all(
|
|
126
|
+
operation.schema.transport.get_first_matching_media_type(media_type) is None
|
|
127
|
+
for media_type in all_media_types
|
|
128
|
+
):
|
|
170
129
|
# None of media types defined for this operation are not supported
|
|
171
|
-
raise SerializationNotPossible.from_media_types(*all_media_types)
|
|
130
|
+
raise SerializationNotPossible.from_media_types(*all_media_types) from None
|
|
172
131
|
# Other media types are possible - avoid choosing this media type in the future
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
132
|
+
event_text = f"Can't serialize data to `{parameter.media_type}`."
|
|
133
|
+
note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
|
|
134
|
+
event(event_text)
|
|
135
|
+
reject()
|
|
136
|
+
media_type, _ = draw(st.sampled_from(possible_media_types))
|
|
137
|
+
if media_type is not None and media_types.parse(media_type) == (
|
|
138
|
+
"application",
|
|
139
|
+
"x-www-form-urlencoded",
|
|
140
|
+
):
|
|
141
|
+
if body_generator.is_negative:
|
|
142
|
+
# For negative strategies, unwrap GeneratedValue, apply transformation, then rewrap
|
|
143
|
+
strategy = strategy.map(
|
|
144
|
+
lambda x: GeneratedValue(prepare_urlencoded(x.value), x.meta)
|
|
145
|
+
if isinstance(x, GeneratedValue)
|
|
146
|
+
else prepare_urlencoded(x)
|
|
147
|
+
).filter(lambda x: is_valid_urlencoded(x.value if isinstance(x, GeneratedValue) else x))
|
|
148
|
+
else:
|
|
149
|
+
strategy = strategy.map(prepare_urlencoded).filter(is_valid_urlencoded)
|
|
150
|
+
body_result = draw(strategy)
|
|
151
|
+
body_metadata = None
|
|
152
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
153
|
+
if isinstance(body_result, GeneratedValue):
|
|
154
|
+
body_metadata = body_result.meta
|
|
155
|
+
body_result = body_result.value
|
|
156
|
+
body_ = ValueContainer(value=body_result, location="body", generator=body_generator, meta=body_metadata)
|
|
157
|
+
else:
|
|
158
|
+
body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
|
|
176
159
|
else:
|
|
177
|
-
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
160
|
+
# This explicit body payload comes for a media type that has a custom strategy registered
|
|
161
|
+
# Such strategies only support binary payloads, otherwise they can't be serialized
|
|
162
|
+
if not isinstance(body, bytes) and media_type and _find_media_type_strategy(media_type) is not None:
|
|
163
|
+
all_media_types = operation.get_request_payload_content_types()
|
|
164
|
+
raise SerializationNotPossible.from_media_types(*all_media_types)
|
|
165
|
+
body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
|
|
166
|
+
|
|
167
|
+
# If we need to generate negative cases but no generated values were negated, then skip the whole test
|
|
168
|
+
if generation_mode.is_negative and not any_negated_values([query_, cookies_, headers_, path_parameters_, body_]):
|
|
169
|
+
if generation_config.modes == [GenerationMode.NEGATIVE]:
|
|
170
|
+
raise SkipTest("Impossible to generate negative test cases")
|
|
171
|
+
else:
|
|
172
|
+
reject()
|
|
173
|
+
|
|
174
|
+
# Extract mutation metadata from negated values and create phase-appropriate data
|
|
175
|
+
if generation_mode.is_negative:
|
|
176
|
+
negated_container = None
|
|
177
|
+
for container in [query_, cookies_, headers_, path_parameters_, body_]:
|
|
178
|
+
if container.generator == GenerationMode.NEGATIVE and container.meta is not None:
|
|
179
|
+
negated_container = container
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if negated_container and negated_container.meta:
|
|
183
|
+
metadata = negated_container.meta
|
|
184
|
+
location_map = {
|
|
185
|
+
"query": ParameterLocation.QUERY,
|
|
186
|
+
"path_parameters": ParameterLocation.PATH,
|
|
187
|
+
"headers": ParameterLocation.HEADER,
|
|
188
|
+
"cookies": ParameterLocation.COOKIE,
|
|
189
|
+
"body": ParameterLocation.BODY,
|
|
190
|
+
}
|
|
191
|
+
parameter_location = location_map.get(negated_container.location)
|
|
192
|
+
_phase_data = {
|
|
193
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
194
|
+
description=metadata.description,
|
|
195
|
+
parameter=metadata.parameter,
|
|
196
|
+
parameter_location=parameter_location,
|
|
197
|
+
location=metadata.location,
|
|
198
|
+
),
|
|
199
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
200
|
+
description=metadata.description,
|
|
201
|
+
parameter=metadata.parameter,
|
|
202
|
+
parameter_location=parameter_location,
|
|
203
|
+
location=metadata.location,
|
|
204
|
+
),
|
|
205
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
206
|
+
description=metadata.description,
|
|
207
|
+
parameter=metadata.parameter,
|
|
208
|
+
parameter_location=parameter_location,
|
|
209
|
+
location=metadata.location,
|
|
210
|
+
),
|
|
211
|
+
}[phase]
|
|
212
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
213
|
+
else:
|
|
214
|
+
_phase_data = {
|
|
215
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
216
|
+
description="Schema mutated",
|
|
217
|
+
parameter=None,
|
|
218
|
+
parameter_location=None,
|
|
219
|
+
location=None,
|
|
220
|
+
),
|
|
221
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
222
|
+
description="Schema mutated",
|
|
223
|
+
parameter=None,
|
|
224
|
+
parameter_location=None,
|
|
225
|
+
location=None,
|
|
226
|
+
),
|
|
227
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
228
|
+
description="Schema mutated",
|
|
229
|
+
parameter=None,
|
|
230
|
+
parameter_location=None,
|
|
231
|
+
location=None,
|
|
232
|
+
),
|
|
233
|
+
}[phase]
|
|
234
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
235
|
+
else:
|
|
236
|
+
_phase_data = {
|
|
237
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
238
|
+
description="Positive test case",
|
|
239
|
+
parameter=None,
|
|
240
|
+
parameter_location=None,
|
|
241
|
+
location=None,
|
|
242
|
+
),
|
|
243
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
244
|
+
description="Positive test case",
|
|
245
|
+
parameter=None,
|
|
246
|
+
parameter_location=None,
|
|
247
|
+
location=None,
|
|
248
|
+
),
|
|
249
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
250
|
+
description="Positive test case",
|
|
251
|
+
parameter=None,
|
|
252
|
+
parameter_location=None,
|
|
253
|
+
location=None,
|
|
254
|
+
),
|
|
255
|
+
}[phase]
|
|
256
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
257
|
+
|
|
258
|
+
instance = operation.Case(
|
|
191
259
|
media_type=media_type,
|
|
192
|
-
path_parameters=
|
|
193
|
-
headers=CaseInsensitiveDict(
|
|
194
|
-
cookies=
|
|
195
|
-
query=
|
|
196
|
-
body=
|
|
197
|
-
|
|
260
|
+
path_parameters=path_parameters_.value or {},
|
|
261
|
+
headers=headers_.value or CaseInsensitiveDict(),
|
|
262
|
+
cookies=cookies_.value or {},
|
|
263
|
+
query=query_.value or {},
|
|
264
|
+
body=body_.value,
|
|
265
|
+
_meta=CaseMetadata(
|
|
266
|
+
generation=GenerationInfo(
|
|
267
|
+
time=time.monotonic() - start,
|
|
268
|
+
mode=generation_mode,
|
|
269
|
+
),
|
|
270
|
+
phase=PhaseInfo(name=phase, data=phase_data),
|
|
271
|
+
components={
|
|
272
|
+
kind: ComponentInfo(mode=value.generator)
|
|
273
|
+
for kind, value in [
|
|
274
|
+
(ParameterLocation.QUERY, query_),
|
|
275
|
+
(ParameterLocation.PATH, path_parameters_),
|
|
276
|
+
(ParameterLocation.HEADER, headers_),
|
|
277
|
+
(ParameterLocation.COOKIE, cookies_),
|
|
278
|
+
(ParameterLocation.BODY, body_),
|
|
279
|
+
]
|
|
280
|
+
if value.generator is not None
|
|
281
|
+
},
|
|
282
|
+
),
|
|
198
283
|
)
|
|
199
|
-
auth_context =
|
|
284
|
+
auth_context = auths.AuthContext(
|
|
200
285
|
operation=operation,
|
|
201
286
|
app=operation.app,
|
|
202
287
|
)
|
|
203
|
-
|
|
288
|
+
auths.set_on_case(instance, auth_context, auth_storage)
|
|
204
289
|
return instance
|
|
205
290
|
|
|
206
291
|
|
|
207
|
-
|
|
208
|
-
|
|
292
|
+
OPTIONAL_BODY_RATE = 0.05
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _find_media_type_strategy(content_type: str) -> st.SearchStrategy[bytes] | None:
|
|
296
|
+
"""Find a registered strategy for a content type, supporting wildcard patterns."""
|
|
297
|
+
# Try exact match first
|
|
298
|
+
if content_type in MEDIA_TYPES:
|
|
299
|
+
return MEDIA_TYPES[content_type]
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
main, sub = media_types.parse(content_type)
|
|
303
|
+
except MalformedMediaType:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# Check registered media types for wildcard matches
|
|
307
|
+
for registered_type, strategy in MEDIA_TYPES.items():
|
|
308
|
+
try:
|
|
309
|
+
target_main, target_sub = media_types.parse(registered_type)
|
|
310
|
+
except MalformedMediaType:
|
|
311
|
+
continue
|
|
312
|
+
# Match if both main and sub types are compatible
|
|
313
|
+
# "*" in either the requested or registered type acts as a wildcard
|
|
314
|
+
main_match = main == "*" or target_main == "*" or main == target_main
|
|
315
|
+
sub_match = sub == "*" or target_sub == "*" or sub == target_sub
|
|
316
|
+
if main_match and sub_match:
|
|
317
|
+
return strategy
|
|
209
318
|
|
|
319
|
+
return None
|
|
210
320
|
|
|
211
|
-
|
|
321
|
+
|
|
322
|
+
def _build_form_strategy_with_encoding(
|
|
323
|
+
parameter: OpenApiBody,
|
|
324
|
+
operation: APIOperation,
|
|
325
|
+
generation_config: GenerationConfig,
|
|
326
|
+
generation_mode: GenerationMode,
|
|
327
|
+
) -> st.SearchStrategy | None:
|
|
328
|
+
"""Build a strategy for form bodies that have custom encoding contentType.
|
|
329
|
+
|
|
330
|
+
Supports wildcard media type matching (e.g., "image/*" matches "image/png").
|
|
331
|
+
|
|
332
|
+
Returns `None` if no custom encoding with registered strategies is found.
|
|
333
|
+
"""
|
|
334
|
+
schema = parameter.optimized_schema
|
|
335
|
+
if not isinstance(schema, dict) or schema.get("type") != "object":
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
properties = schema.get("properties", {})
|
|
339
|
+
if not properties:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Check which properties have custom content types with registered strategies
|
|
343
|
+
custom_property_strategies = {}
|
|
344
|
+
for property_name in properties:
|
|
345
|
+
content_type = parameter.get_property_content_type(property_name)
|
|
346
|
+
|
|
347
|
+
if content_type is not None and not isinstance(content_type, str):
|
|
348
|
+
# Happens in broken schemas
|
|
349
|
+
continue # type: ignore[unreachable]
|
|
350
|
+
|
|
351
|
+
if content_type:
|
|
352
|
+
# Handle multiple content types (e.g., "image/png, image/jpeg")
|
|
353
|
+
content_types = [ct.strip() for ct in content_type.split(",")]
|
|
354
|
+
strategies_for_types = []
|
|
355
|
+
for ct in content_types:
|
|
356
|
+
strategy = _find_media_type_strategy(ct)
|
|
357
|
+
if strategy is not None:
|
|
358
|
+
strategies_for_types.append(strategy)
|
|
359
|
+
|
|
360
|
+
if strategies_for_types:
|
|
361
|
+
custom_property_strategies[property_name] = st.one_of(*strategies_for_types)
|
|
362
|
+
|
|
363
|
+
if not custom_property_strategies:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# Build strategies for properties
|
|
367
|
+
property_strategies = {}
|
|
368
|
+
for property_name, subschema in properties.items():
|
|
369
|
+
if property_name in custom_property_strategies:
|
|
370
|
+
property_strategies[property_name] = custom_property_strategies[property_name]
|
|
371
|
+
else:
|
|
372
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
373
|
+
|
|
374
|
+
assert isinstance(operation.schema, BaseOpenAPISchema)
|
|
375
|
+
strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generation_mode]
|
|
376
|
+
property_strategies[property_name] = strategy_factory(
|
|
377
|
+
subschema,
|
|
378
|
+
operation.label,
|
|
379
|
+
ParameterLocation.BODY,
|
|
380
|
+
parameter.media_type,
|
|
381
|
+
generation_config,
|
|
382
|
+
operation.schema.adapter.jsonschema_validator_cls,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Build fixed dictionary strategy with optional properties
|
|
386
|
+
required = set(schema.get("required", []))
|
|
387
|
+
required_strategies = {k: v for k, v in property_strategies.items() if k in required}
|
|
388
|
+
optional_strategies = {k: st.just(NOT_SET) | v for k, v in property_strategies.items() if k not in required}
|
|
389
|
+
|
|
390
|
+
@st.composite # type: ignore[misc]
|
|
391
|
+
def build_body(draw: st.DrawFn) -> dict[str, Any]:
|
|
392
|
+
body: dict[str, Any] = {}
|
|
393
|
+
# Generate required properties
|
|
394
|
+
for key, strategy in required_strategies.items():
|
|
395
|
+
body[key] = draw(strategy)
|
|
396
|
+
# Generate optional properties, filtering out NOT_SET
|
|
397
|
+
for key, strategy in optional_strategies.items():
|
|
398
|
+
value = draw(strategy)
|
|
399
|
+
if value is not NOT_SET:
|
|
400
|
+
body[key] = value
|
|
401
|
+
return body
|
|
402
|
+
|
|
403
|
+
return build_body()
|
|
212
404
|
|
|
213
405
|
|
|
214
406
|
def _get_body_strategy(
|
|
215
|
-
parameter:
|
|
216
|
-
to_strategy: StrategyFactory,
|
|
407
|
+
parameter: OpenApiBody,
|
|
217
408
|
operation: APIOperation,
|
|
409
|
+
generation_config: GenerationConfig,
|
|
410
|
+
draw: st.DrawFn,
|
|
411
|
+
generation_mode: GenerationMode,
|
|
218
412
|
) -> st.SearchStrategy:
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
413
|
+
# Check for custom encoding in form bodies (multipart/form-data or application/x-www-form-urlencoded)
|
|
414
|
+
if parameter.media_type in FORM_MEDIA_TYPES:
|
|
415
|
+
custom_strategy = _build_form_strategy_with_encoding(parameter, operation, generation_config, generation_mode)
|
|
416
|
+
if custom_strategy is not None:
|
|
417
|
+
return custom_strategy
|
|
418
|
+
|
|
419
|
+
# Check for custom media type strategy
|
|
420
|
+
custom_strategy = _find_media_type_strategy(parameter.media_type)
|
|
421
|
+
if custom_strategy is not None:
|
|
422
|
+
return custom_strategy
|
|
423
|
+
|
|
424
|
+
# Use the cached strategy from the parameter
|
|
425
|
+
strategy = parameter.get_strategy(operation, generation_config, generation_mode)
|
|
426
|
+
|
|
427
|
+
# It is likely will be rejected, hence choose it rarely
|
|
428
|
+
if (
|
|
429
|
+
not parameter.is_required
|
|
430
|
+
and draw(st.floats(min_value=0.0, max_value=1.0, allow_infinity=False, allow_nan=False, allow_subnormal=False))
|
|
431
|
+
< OPTIONAL_BODY_RATE
|
|
432
|
+
):
|
|
227
433
|
strategy |= st.just(NOT_SET)
|
|
228
|
-
_BODY_STRATEGIES_CACHE.setdefault(parameter, {})[to_strategy] = strategy
|
|
229
434
|
return strategy
|
|
230
435
|
|
|
231
436
|
|
|
232
437
|
def get_parameters_value(
|
|
233
|
-
value:
|
|
234
|
-
location:
|
|
235
|
-
draw:
|
|
438
|
+
value: dict[str, Any] | None,
|
|
439
|
+
location: ParameterLocation,
|
|
440
|
+
draw: st.DrawFn,
|
|
236
441
|
operation: APIOperation,
|
|
237
|
-
|
|
238
|
-
hooks:
|
|
239
|
-
|
|
240
|
-
|
|
442
|
+
ctx: HookContext,
|
|
443
|
+
hooks: HookDispatcher | None,
|
|
444
|
+
generation_mode: GenerationMode,
|
|
445
|
+
generation_config: GenerationConfig,
|
|
446
|
+
) -> tuple[dict[str, Any] | None, Any]:
|
|
241
447
|
"""Get the final value for the specified location.
|
|
242
448
|
|
|
243
449
|
If the value is not set, then generate it from the relevant strategy. Otherwise, check what is missing in it and
|
|
244
450
|
generate those parts.
|
|
245
451
|
"""
|
|
246
|
-
if
|
|
247
|
-
strategy = get_parameters_strategy(operation,
|
|
248
|
-
strategy = apply_hooks(operation,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
452
|
+
if value is None:
|
|
453
|
+
strategy = get_parameters_strategy(operation, generation_mode, location, generation_config)
|
|
454
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
|
455
|
+
result = draw(strategy)
|
|
456
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
457
|
+
if isinstance(result, GeneratedValue):
|
|
458
|
+
return result.value, result.meta
|
|
459
|
+
return result, None
|
|
460
|
+
strategy = get_parameters_strategy(operation, generation_mode, location, generation_config, exclude=value.keys())
|
|
461
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
|
462
|
+
new = draw(strategy)
|
|
463
|
+
metadata = None
|
|
464
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
465
|
+
if isinstance(new, GeneratedValue):
|
|
466
|
+
new, metadata = new.value, new.meta
|
|
467
|
+
if new is not None:
|
|
468
|
+
copied = dict(value)
|
|
469
|
+
copied.update(new)
|
|
470
|
+
return copied, metadata
|
|
471
|
+
return value, metadata
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@dataclass
|
|
475
|
+
class ValueContainer:
|
|
476
|
+
"""Container for a value generated by a data generator or explicitly provided."""
|
|
477
|
+
|
|
478
|
+
value: Any
|
|
479
|
+
location: str
|
|
480
|
+
generator: GenerationMode | None
|
|
481
|
+
meta: MutationMetadata | None
|
|
482
|
+
|
|
483
|
+
__slots__ = ("value", "location", "generator", "meta")
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def is_generated(self) -> bool:
|
|
487
|
+
"""If value was generated."""
|
|
488
|
+
return self.generator is not None and (self.location == "body" or self.value is not None)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def any_negated_values(values: list[ValueContainer]) -> bool:
|
|
492
|
+
"""Check if any generated values are negated."""
|
|
493
|
+
return any(value.generator == GenerationMode.NEGATIVE for value in values if value.is_generated)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def generate_parameter(
|
|
497
|
+
location: ParameterLocation,
|
|
498
|
+
explicit: dict[str, Any] | None,
|
|
499
|
+
operation: APIOperation,
|
|
500
|
+
draw: st.DrawFn,
|
|
501
|
+
ctx: HookContext,
|
|
502
|
+
hooks: HookDispatcher | None,
|
|
503
|
+
generator: GenerationMode,
|
|
504
|
+
generation_config: GenerationConfig,
|
|
505
|
+
) -> ValueContainer:
|
|
506
|
+
"""Generate a value for a parameter.
|
|
507
|
+
|
|
508
|
+
Fallback to positive data generator if parameter can not be negated.
|
|
509
|
+
"""
|
|
510
|
+
if generator.is_negative and (
|
|
511
|
+
(location == ParameterLocation.PATH and not can_negate_path_parameters(operation))
|
|
512
|
+
or (location.is_in_header and not can_negate_headers(operation, location))
|
|
513
|
+
):
|
|
514
|
+
# If we can't negate any parameter, generate positive ones
|
|
515
|
+
# If nothing else will be negated, then skip the test completely
|
|
516
|
+
generator = GenerationMode.POSITIVE
|
|
517
|
+
value, metadata = get_parameters_value(
|
|
518
|
+
explicit, location, draw, operation, ctx, hooks, generator, generation_config
|
|
519
|
+
)
|
|
520
|
+
used_generator: GenerationMode | None = generator
|
|
521
|
+
if value == explicit:
|
|
522
|
+
# When we pass `explicit`, then its parts are excluded from generation of the final value
|
|
523
|
+
# If the final value is the same, then other parameters were generated at all
|
|
524
|
+
if value is not None and location == ParameterLocation.PATH:
|
|
525
|
+
value = quote_all(value)
|
|
526
|
+
used_generator = None
|
|
527
|
+
return ValueContainer(value=value, location=location, generator=used_generator, meta=metadata)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def can_negate_path_parameters(operation: APIOperation) -> bool:
|
|
531
|
+
"""Check if any path parameter can be negated."""
|
|
532
|
+
# No path parameters to negate
|
|
533
|
+
parameters = cast(OpenApiParameterSet, operation.path_parameters).schema["properties"]
|
|
534
|
+
if not parameters:
|
|
535
|
+
return True
|
|
536
|
+
return any(can_negate(parameter) for parameter in parameters.values())
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def can_negate_headers(operation: APIOperation, location: ParameterLocation) -> bool:
|
|
540
|
+
"""Check if any header can be negated."""
|
|
541
|
+
container = getattr(operation, location.container_name)
|
|
542
|
+
# No headers to negate
|
|
543
|
+
headers = container.schema["properties"]
|
|
544
|
+
if not headers:
|
|
545
|
+
return True
|
|
546
|
+
return any(
|
|
547
|
+
header not in ({"type": "string"}, {"type": "string", "format": HEADER_FORMAT}) for header in headers.values()
|
|
548
|
+
)
|
|
258
549
|
|
|
259
550
|
|
|
260
551
|
def get_parameters_strategy(
|
|
261
552
|
operation: APIOperation,
|
|
262
|
-
|
|
263
|
-
location:
|
|
553
|
+
generation_mode: GenerationMode,
|
|
554
|
+
location: ParameterLocation,
|
|
555
|
+
generation_config: GenerationConfig,
|
|
264
556
|
exclude: Iterable[str] = (),
|
|
265
557
|
) -> st.SearchStrategy:
|
|
266
558
|
"""Create a new strategy for the case's component from the API operation parameters."""
|
|
267
|
-
|
|
268
|
-
if
|
|
269
|
-
|
|
270
|
-
nested_cache_key = (to_strategy, location, tuple(sorted(exclude)))
|
|
271
|
-
if operation in _PARAMETER_STRATEGIES_CACHE and nested_cache_key in _PARAMETER_STRATEGIES_CACHE[operation]:
|
|
272
|
-
return _PARAMETER_STRATEGIES_CACHE[operation][nested_cache_key]
|
|
273
|
-
schema = parameters_to_json_schema(operation, parameters)
|
|
274
|
-
if not operation.schema.validate_schema and location == "path":
|
|
275
|
-
# If schema validation is disabled, we try to generate data even if the parameter definition
|
|
276
|
-
# contains errors.
|
|
277
|
-
# In this case, we know that the `required` keyword should always be `True`.
|
|
278
|
-
schema["required"] = list(schema["properties"])
|
|
279
|
-
schema = operation.schema.prepare_schema(schema)
|
|
280
|
-
for name in exclude:
|
|
281
|
-
# Values from `exclude` are not necessarily valid for the schema - they come from user-defined examples
|
|
282
|
-
# that may be invalid
|
|
283
|
-
schema["properties"].pop(name, None)
|
|
284
|
-
with suppress(ValueError):
|
|
285
|
-
schema["required"].remove(name)
|
|
286
|
-
strategy = to_strategy(schema, operation.verbose_name, location, None)
|
|
287
|
-
serialize = operation.get_parameter_serializer(location)
|
|
288
|
-
if serialize is not None:
|
|
289
|
-
strategy = strategy.map(serialize)
|
|
290
|
-
filter_func = {
|
|
291
|
-
"path": is_valid_path,
|
|
292
|
-
"header": is_valid_header,
|
|
293
|
-
"cookie": is_valid_header,
|
|
294
|
-
"query": is_valid_query,
|
|
295
|
-
}[location]
|
|
296
|
-
# Headers with special format do not need filtration
|
|
297
|
-
if not (is_header_location(location) and _can_skip_header_filter(schema)):
|
|
298
|
-
strategy = strategy.filter(filter_func)
|
|
299
|
-
# Path & query parameters will be cast to string anyway, but having their JSON equivalents for
|
|
300
|
-
# `True` / `False` / `None` improves chances of them passing validation in apps that expect boolean / null types
|
|
301
|
-
# and not aware of Python-specific representation of those types
|
|
302
|
-
map_func = {
|
|
303
|
-
"path": compose(quote_all, jsonify_python_specific_types),
|
|
304
|
-
"query": jsonify_python_specific_types,
|
|
305
|
-
}.get(location)
|
|
306
|
-
if map_func:
|
|
307
|
-
strategy = strategy.map(map_func) # type: ignore
|
|
308
|
-
_PARAMETER_STRATEGIES_CACHE.setdefault(operation, {})[nested_cache_key] = strategy
|
|
309
|
-
return strategy
|
|
559
|
+
container = getattr(operation, location.container_name)
|
|
560
|
+
if container:
|
|
561
|
+
return container.get_strategy(operation, generation_config, generation_mode, exclude)
|
|
310
562
|
# No parameters defined for this location
|
|
311
563
|
return st.none()
|
|
312
564
|
|
|
313
565
|
|
|
314
|
-
def
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
566
|
+
def jsonify_python_specific_types(value: dict[str, Any]) -> dict[str, Any]:
|
|
567
|
+
"""Convert Python-specific values to their JSON equivalents."""
|
|
568
|
+
stack: list = [value]
|
|
569
|
+
while stack:
|
|
570
|
+
item = stack.pop()
|
|
571
|
+
if isinstance(item, dict):
|
|
572
|
+
for key, sub_item in item.items():
|
|
573
|
+
if isinstance(sub_item, bool):
|
|
574
|
+
item[key] = "true" if sub_item else "false"
|
|
575
|
+
elif sub_item is None:
|
|
576
|
+
item[key] = "null"
|
|
577
|
+
elif isinstance(sub_item, dict):
|
|
578
|
+
stack.append(sub_item)
|
|
579
|
+
elif isinstance(sub_item, list):
|
|
580
|
+
stack.extend(item)
|
|
581
|
+
elif isinstance(item, list):
|
|
582
|
+
stack.extend(item)
|
|
324
583
|
return value
|
|
325
584
|
|
|
326
585
|
|
|
327
|
-
def
|
|
328
|
-
|
|
329
|
-
|
|
586
|
+
def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.SearchStrategy]:
|
|
587
|
+
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS}
|
|
588
|
+
header_values_kwargs = {}
|
|
589
|
+
if generation_config.exclude_header_characters is not None:
|
|
590
|
+
header_values_kwargs["exclude_characters"] = generation_config.exclude_header_characters
|
|
591
|
+
if not generation_config.allow_x00:
|
|
592
|
+
header_values_kwargs["exclude_characters"] += "\x00"
|
|
593
|
+
elif not generation_config.allow_x00:
|
|
594
|
+
header_values_kwargs["exclude_characters"] = DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00"
|
|
595
|
+
if generation_config.codec is not None:
|
|
596
|
+
header_values_kwargs["codec"] = generation_config.codec
|
|
597
|
+
if header_values_kwargs:
|
|
598
|
+
custom_formats[HEADER_FORMAT] = header_values(**header_values_kwargs)
|
|
599
|
+
return custom_formats
|
|
330
600
|
|
|
331
601
|
|
|
332
602
|
def make_positive_strategy(
|
|
333
|
-
schema:
|
|
603
|
+
schema: JsonSchema,
|
|
334
604
|
operation_name: str,
|
|
335
|
-
location:
|
|
336
|
-
media_type:
|
|
337
|
-
|
|
605
|
+
location: ParameterLocation,
|
|
606
|
+
media_type: str | None,
|
|
607
|
+
generation_config: GenerationConfig,
|
|
608
|
+
validator_cls: type[jsonschema.protocols.Validator],
|
|
338
609
|
) -> st.SearchStrategy:
|
|
339
610
|
"""Strategy for generating values that fit the schema."""
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return from_schema(schema, custom_formats={**STRING_FORMATS, **(custom_formats or {})})
|
|
611
|
+
custom_formats = _build_custom_formats(generation_config)
|
|
612
|
+
return from_schema(
|
|
613
|
+
schema,
|
|
614
|
+
custom_formats=custom_formats,
|
|
615
|
+
allow_x00=generation_config.allow_x00,
|
|
616
|
+
codec=generation_config.codec,
|
|
617
|
+
)
|
|
348
618
|
|
|
349
619
|
|
|
350
|
-
def _can_skip_header_filter(schema:
|
|
620
|
+
def _can_skip_header_filter(schema: dict[str, Any]) -> bool:
|
|
351
621
|
# All headers should contain HEADER_FORMAT in order to avoid header filter
|
|
352
622
|
return all(sub_schema.get("format") == HEADER_FORMAT for sub_schema in schema.get("properties", {}).values())
|
|
353
623
|
|
|
354
624
|
|
|
355
625
|
def make_negative_strategy(
|
|
356
|
-
schema:
|
|
626
|
+
schema: JsonSchema,
|
|
357
627
|
operation_name: str,
|
|
358
|
-
location:
|
|
359
|
-
media_type:
|
|
360
|
-
|
|
628
|
+
location: ParameterLocation,
|
|
629
|
+
media_type: str | None,
|
|
630
|
+
generation_config: GenerationConfig,
|
|
631
|
+
validator_cls: type[jsonschema.protocols.Validator],
|
|
361
632
|
) -> st.SearchStrategy:
|
|
633
|
+
custom_formats = _build_custom_formats(generation_config)
|
|
362
634
|
return negative_schema(
|
|
363
635
|
schema,
|
|
364
636
|
operation_name=operation_name,
|
|
365
637
|
location=location,
|
|
366
638
|
media_type=media_type,
|
|
367
|
-
custom_formats=
|
|
639
|
+
custom_formats=custom_formats,
|
|
640
|
+
generation_config=generation_config,
|
|
641
|
+
validator_cls=validator_cls,
|
|
368
642
|
)
|
|
369
643
|
|
|
370
644
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
645
|
+
GENERATOR_MODE_TO_STRATEGY_FACTORY = {
|
|
646
|
+
GenerationMode.POSITIVE: make_positive_strategy,
|
|
647
|
+
GenerationMode.NEGATIVE: make_negative_strategy,
|
|
374
648
|
}
|
|
375
649
|
|
|
376
650
|
|
|
377
|
-
def
|
|
378
|
-
"""Empty strings ("") are excluded from path by urllib3.
|
|
379
|
-
|
|
380
|
-
A path containing to "/" or "%2F" will lead to ambiguous path resolution in
|
|
381
|
-
many frameworks and libraries, such behaviour have been observed in both
|
|
382
|
-
WSGI and ASGI applications.
|
|
383
|
-
|
|
384
|
-
In this case one variable in the path template will be empty, which will lead to 404 in most of the cases.
|
|
385
|
-
Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
|
|
386
|
-
"""
|
|
387
|
-
disallowed_values = (SLASH, "")
|
|
388
|
-
|
|
389
|
-
return not any(
|
|
390
|
-
(value in disallowed_values or is_illegal_surrogate(value) or isinstance(value, str) and SLASH in value)
|
|
391
|
-
for value in parameters.values()
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
651
|
+
def quote_all(parameters: dict[str, Any]) -> dict[str, Any]:
|
|
396
652
|
"""Apply URL quotation for all values in a dictionary."""
|
|
397
653
|
# Even though, "." is an unreserved character, it has a special meaning in "." and ".." strings.
|
|
398
654
|
# It will change the path:
|
|
@@ -400,42 +656,23 @@ def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
400
656
|
# - http://localhost/foo/../ -> http://localhost/
|
|
401
657
|
# Which is not desired as we need to test precisely the original path structure.
|
|
402
658
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return
|
|
659
|
+
for key, value in parameters.items():
|
|
660
|
+
if isinstance(value, str):
|
|
661
|
+
if value == ".":
|
|
662
|
+
parameters[key] = "%2E"
|
|
663
|
+
elif value == "..":
|
|
664
|
+
parameters[key] = "%2E%2E"
|
|
665
|
+
else:
|
|
666
|
+
parameters[key] = quote_plus(value)
|
|
667
|
+
return parameters
|
|
412
668
|
|
|
413
669
|
|
|
414
670
|
def apply_hooks(
|
|
415
671
|
operation: APIOperation,
|
|
416
|
-
|
|
417
|
-
hooks:
|
|
672
|
+
ctx: HookContext,
|
|
673
|
+
hooks: HookDispatcher | None,
|
|
418
674
|
strategy: st.SearchStrategy,
|
|
419
|
-
location:
|
|
420
|
-
) -> st.SearchStrategy:
|
|
421
|
-
"""Apply all `before_generate_` hooks related to the given location."""
|
|
422
|
-
strategy = _apply_hooks(context, GLOBAL_HOOK_DISPATCHER, strategy, location)
|
|
423
|
-
strategy = _apply_hooks(context, operation.schema.hooks, strategy, location)
|
|
424
|
-
if hooks is not None:
|
|
425
|
-
strategy = _apply_hooks(context, hooks, strategy, location)
|
|
426
|
-
return strategy
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def _apply_hooks(
|
|
430
|
-
context: HookContext, hooks: HookDispatcher, strategy: st.SearchStrategy, location: str
|
|
675
|
+
location: ParameterLocation,
|
|
431
676
|
) -> st.SearchStrategy:
|
|
432
|
-
"""Apply all
|
|
433
|
-
|
|
434
|
-
for hook in hooks.get_all_by_name(f"before_generate_{container}"):
|
|
435
|
-
strategy = hook(context, strategy)
|
|
436
|
-
return strategy
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def clear_cache() -> None:
|
|
440
|
-
_PARAMETER_STRATEGIES_CACHE.clear()
|
|
441
|
-
_BODY_STRATEGIES_CACHE.clear()
|
|
677
|
+
"""Apply all hooks related to the given location."""
|
|
678
|
+
return apply_to_all_dispatchers(operation, ctx, hooks, strategy, location.container_name)
|