schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1766
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{cli → engine/phases}/probes.py +63 -70
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +153 -39
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +483 -367
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -55
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -765
- schemathesis/cli/output/short.py +0 -40
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -315
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,23 +1,48 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
from
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
2
|
+
|
3
|
+
import enum
|
4
|
+
import http.client
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from http.cookies import SimpleCookie
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
9
|
+
|
10
|
+
import schemathesis
|
11
|
+
from schemathesis.checks import CheckContext
|
12
|
+
from schemathesis.core import media_types, string_to_boolean
|
13
|
+
from schemathesis.core.failures import Failure
|
14
|
+
from schemathesis.core.transport import Response
|
15
|
+
from schemathesis.generation.case import Case
|
16
|
+
from schemathesis.generation.meta import ComponentKind, CoveragePhaseData
|
17
|
+
from schemathesis.openapi.checks import (
|
18
|
+
AcceptedNegativeData,
|
19
|
+
EnsureResourceAvailability,
|
20
|
+
IgnoredAuth,
|
21
|
+
JsonSchemaError,
|
22
|
+
MalformedMediaType,
|
23
|
+
MissingContentType,
|
24
|
+
MissingHeaders,
|
25
|
+
MissingRequiredHeaderConfig,
|
26
|
+
NegativeDataRejectionConfig,
|
27
|
+
PositiveDataAcceptanceConfig,
|
28
|
+
RejectedPositiveData,
|
29
|
+
UndefinedContentType,
|
30
|
+
UndefinedStatusCode,
|
31
|
+
UseAfterFree,
|
11
32
|
)
|
12
|
-
from
|
13
|
-
from .
|
33
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
34
|
+
from schemathesis.transport.prepare import prepare_path
|
35
|
+
|
36
|
+
from .utils import expand_status_code, expand_status_codes
|
14
37
|
|
15
38
|
if TYPE_CHECKING:
|
16
|
-
from
|
17
|
-
|
39
|
+
from requests import PreparedRequest
|
40
|
+
|
41
|
+
from ...schemas import APIOperation
|
18
42
|
|
19
43
|
|
20
|
-
|
44
|
+
@schemathesis.check
|
45
|
+
def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
21
46
|
from .schemas import BaseOpenAPISchema
|
22
47
|
|
23
48
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
@@ -30,15 +55,12 @@ def status_code_conformance(response: GenericResponse, case: Case) -> bool | Non
|
|
30
55
|
if response.status_code not in allowed_status_codes:
|
31
56
|
defined_status_codes = list(map(str, responses))
|
32
57
|
responses_list = ", ".join(defined_status_codes)
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
defined_status_codes=defined_status_codes,
|
40
|
-
allowed_status_codes=allowed_status_codes,
|
41
|
-
),
|
58
|
+
raise UndefinedStatusCode(
|
59
|
+
operation=case.operation.label,
|
60
|
+
status_code=response.status_code,
|
61
|
+
defined_status_codes=defined_status_codes,
|
62
|
+
allowed_status_codes=allowed_status_codes,
|
63
|
+
message=f"Received: {response.status_code}\nDocumented: {responses_list}",
|
42
64
|
)
|
43
65
|
return None # explicitly return None for mypy
|
44
66
|
|
@@ -48,7 +70,8 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
48
70
|
yield from expand_status_code(code)
|
49
71
|
|
50
72
|
|
51
|
-
|
73
|
+
@schemathesis.check
|
74
|
+
def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
52
75
|
from .schemas import BaseOpenAPISchema
|
53
76
|
|
54
77
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
@@ -56,74 +79,549 @@ def content_type_conformance(response: GenericResponse, case: Case) -> bool | No
|
|
56
79
|
documented_content_types = case.operation.schema.get_content_types(case.operation, response)
|
57
80
|
if not documented_content_types:
|
58
81
|
return None
|
59
|
-
|
60
|
-
if not
|
61
|
-
|
62
|
-
raise
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
media_types=documented_content_types,
|
67
|
-
),
|
82
|
+
content_types = response.headers.get("content-type")
|
83
|
+
if not content_types:
|
84
|
+
all_media_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
|
85
|
+
raise MissingContentType(
|
86
|
+
operation=case.operation.label,
|
87
|
+
message=f"The following media types are documented in the schema:{''.join(all_media_types)}",
|
88
|
+
media_types=documented_content_types,
|
68
89
|
)
|
90
|
+
content_type = content_types[0]
|
69
91
|
for option in documented_content_types:
|
70
92
|
try:
|
71
|
-
expected_main, expected_sub =
|
72
|
-
except ValueError
|
73
|
-
_reraise_malformed_media_type(
|
93
|
+
expected_main, expected_sub = media_types.parse(option)
|
94
|
+
except ValueError:
|
95
|
+
_reraise_malformed_media_type(case, "Schema", option, option)
|
74
96
|
try:
|
75
|
-
received_main, received_sub =
|
76
|
-
except ValueError
|
77
|
-
_reraise_malformed_media_type(
|
78
|
-
if (
|
97
|
+
received_main, received_sub = media_types.parse(content_type)
|
98
|
+
except ValueError:
|
99
|
+
_reraise_malformed_media_type(case, "Response", content_type, option)
|
100
|
+
if (
|
101
|
+
(expected_main == "*" and expected_sub == "*")
|
102
|
+
or (expected_main == received_main and expected_sub == "*")
|
103
|
+
or (expected_main == "*" and expected_sub == received_sub)
|
104
|
+
or (expected_main == received_main and expected_sub == received_sub)
|
105
|
+
):
|
79
106
|
return None
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
content_type=content_type,
|
86
|
-
defined_content_types=documented_content_types,
|
87
|
-
),
|
107
|
+
raise UndefinedContentType(
|
108
|
+
operation=case.operation.label,
|
109
|
+
message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
|
110
|
+
content_type=content_type,
|
111
|
+
defined_content_types=documented_content_types,
|
88
112
|
)
|
89
113
|
|
90
114
|
|
91
|
-
def _reraise_malformed_media_type(
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
115
|
+
def _reraise_malformed_media_type(case: Case, location: str, actual: str, defined: str) -> NoReturn:
|
116
|
+
raise MalformedMediaType(
|
117
|
+
operation=case.operation.label,
|
118
|
+
message=f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}",
|
119
|
+
actual=actual,
|
120
|
+
defined=defined,
|
121
|
+
)
|
122
|
+
|
97
123
|
|
124
|
+
@schemathesis.check
|
125
|
+
def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
126
|
+
import jsonschema
|
98
127
|
|
99
|
-
|
100
|
-
from .schemas import BaseOpenAPISchema
|
128
|
+
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
129
|
+
from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
|
101
130
|
|
102
131
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
103
132
|
return True
|
104
|
-
|
133
|
+
resolved = case.operation.schema.get_headers(case.operation, response)
|
134
|
+
if not resolved:
|
135
|
+
return None
|
136
|
+
scopes, defined_headers = resolved
|
105
137
|
if not defined_headers:
|
106
138
|
return None
|
107
139
|
|
108
140
|
missing_headers = [
|
109
141
|
header
|
110
142
|
for header, definition in defined_headers.items()
|
111
|
-
if header not in response.headers and definition.get(case.operation.schema.header_required_field, False)
|
143
|
+
if header.lower() not in response.headers and definition.get(case.operation.schema.header_required_field, False)
|
112
144
|
]
|
113
|
-
|
145
|
+
errors: list[Failure] = []
|
146
|
+
if missing_headers:
|
147
|
+
formatted_headers = [f"\n- `{header}`" for header in missing_headers]
|
148
|
+
message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
|
149
|
+
errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
|
150
|
+
for name, definition in defined_headers.items():
|
151
|
+
values = response.headers.get(name.lower())
|
152
|
+
if values is not None:
|
153
|
+
value = values[0]
|
154
|
+
with case.operation.schema._validating_response(scopes) as resolver:
|
155
|
+
if "$ref" in definition:
|
156
|
+
_, definition = resolver.resolve(definition["$ref"])
|
157
|
+
parameter_definition = {"in": "header", **definition}
|
158
|
+
parameter: OpenAPI20Parameter | OpenAPI30Parameter
|
159
|
+
if isinstance(case.operation.schema, OpenApi30):
|
160
|
+
parameter = OpenAPI30Parameter(parameter_definition)
|
161
|
+
else:
|
162
|
+
parameter = OpenAPI20Parameter(parameter_definition)
|
163
|
+
schema = parameter.as_json_schema(case.operation)
|
164
|
+
coerced = _coerce_header_value(value, schema)
|
165
|
+
try:
|
166
|
+
jsonschema.validate(
|
167
|
+
coerced,
|
168
|
+
schema,
|
169
|
+
cls=case.operation.schema.validator_cls,
|
170
|
+
resolver=resolver,
|
171
|
+
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
|
172
|
+
)
|
173
|
+
except jsonschema.ValidationError as exc:
|
174
|
+
errors.append(
|
175
|
+
JsonSchemaError.from_exception(
|
176
|
+
title="Response header does not conform to the schema",
|
177
|
+
operation=case.operation.label,
|
178
|
+
exc=exc,
|
179
|
+
output_config=case.operation.schema.output_config,
|
180
|
+
)
|
181
|
+
)
|
182
|
+
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
183
|
+
|
184
|
+
|
185
|
+
def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | float | None | bool:
|
186
|
+
schema_type = schema.get("type")
|
187
|
+
|
188
|
+
if schema_type == "string":
|
189
|
+
return value
|
190
|
+
if schema_type == "integer":
|
191
|
+
try:
|
192
|
+
return int(value)
|
193
|
+
except ValueError:
|
194
|
+
return value
|
195
|
+
if schema_type == "number":
|
196
|
+
try:
|
197
|
+
return float(value)
|
198
|
+
except ValueError:
|
199
|
+
return value
|
200
|
+
if schema_type == "null" and value.lower() == "null":
|
114
201
|
return None
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
raise exc_class(
|
119
|
-
failures.MissingHeaders.title,
|
120
|
-
context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
|
121
|
-
)
|
202
|
+
if schema_type == "boolean":
|
203
|
+
return string_to_boolean(value)
|
204
|
+
return value
|
122
205
|
|
123
206
|
|
124
|
-
|
207
|
+
@schemathesis.check
|
208
|
+
def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
125
209
|
from .schemas import BaseOpenAPISchema
|
126
210
|
|
127
211
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
128
212
|
return True
|
129
213
|
return case.operation.validate_response(response)
|
214
|
+
|
215
|
+
|
216
|
+
@schemathesis.check
|
217
|
+
def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
218
|
+
from .schemas import BaseOpenAPISchema
|
219
|
+
|
220
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
|
221
|
+
return True
|
222
|
+
|
223
|
+
config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
|
224
|
+
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
225
|
+
|
226
|
+
if (
|
227
|
+
case.meta.generation.mode.is_negative
|
228
|
+
and response.status_code not in allowed_statuses
|
229
|
+
and not has_only_additional_properties_in_non_body_parameters(case)
|
230
|
+
):
|
231
|
+
raise AcceptedNegativeData(
|
232
|
+
operation=case.operation.label,
|
233
|
+
message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
|
234
|
+
status_code=response.status_code,
|
235
|
+
allowed_statuses=config.allowed_statuses,
|
236
|
+
)
|
237
|
+
return None
|
238
|
+
|
239
|
+
|
240
|
+
@schemathesis.check
|
241
|
+
def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
242
|
+
from .schemas import BaseOpenAPISchema
|
243
|
+
|
244
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
|
245
|
+
return True
|
246
|
+
|
247
|
+
config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
|
248
|
+
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
249
|
+
|
250
|
+
if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
|
251
|
+
raise RejectedPositiveData(
|
252
|
+
operation=case.operation.label,
|
253
|
+
message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
|
254
|
+
status_code=response.status_code,
|
255
|
+
allowed_statuses=config.allowed_statuses,
|
256
|
+
)
|
257
|
+
return None
|
258
|
+
|
259
|
+
|
260
|
+
def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
261
|
+
# NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
|
262
|
+
meta = case.meta
|
263
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
|
264
|
+
return None
|
265
|
+
data = meta.phase.data
|
266
|
+
if (
|
267
|
+
data.parameter
|
268
|
+
and data.parameter_location == "header"
|
269
|
+
and data.description
|
270
|
+
and data.description.startswith("Missing ")
|
271
|
+
):
|
272
|
+
if data.parameter.lower() == "authorization":
|
273
|
+
allowed_statuses = {401}
|
274
|
+
else:
|
275
|
+
config = ctx.config.get(missing_required_header, MissingRequiredHeaderConfig())
|
276
|
+
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
277
|
+
if response.status_code not in allowed_statuses:
|
278
|
+
allowed = f"Allowed statuses: {', '.join(map(str, allowed_statuses))}"
|
279
|
+
raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
|
280
|
+
return None
|
281
|
+
|
282
|
+
|
283
|
+
def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
284
|
+
meta = case.meta
|
285
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
|
286
|
+
return None
|
287
|
+
data = meta.phase.data
|
288
|
+
if data.description and data.description.startswith("Unspecified HTTP method:"):
|
289
|
+
if response.status_code != 405:
|
290
|
+
raise AssertionError(
|
291
|
+
f"Unexpected response status for unspecified HTTP method: {response.status_code}\nExpected: 405"
|
292
|
+
)
|
293
|
+
|
294
|
+
allow_header = response.headers.get("allow")
|
295
|
+
if not allow_header:
|
296
|
+
raise AssertionError("Missing 'Allow' header in 405 Method Not Allowed response")
|
297
|
+
return None
|
298
|
+
|
299
|
+
|
300
|
+
def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
301
|
+
# Check if the case contains only additional properties in query, headers, or cookies.
|
302
|
+
# This function is used to determine if negation is solely in the form of extra properties,
|
303
|
+
# which are often ignored for backward-compatibility by the tested apps
|
304
|
+
from ._hypothesis import get_schema_for_location
|
305
|
+
|
306
|
+
meta = case.meta
|
307
|
+
if meta is None:
|
308
|
+
# Ignore manually created cases
|
309
|
+
return False
|
310
|
+
if (ComponentKind.BODY in meta.components and meta.components[ComponentKind.BODY].mode.is_negative) or (
|
311
|
+
ComponentKind.PATH_PARAMETERS in meta.components
|
312
|
+
and meta.components[ComponentKind.PATH_PARAMETERS].mode.is_negative
|
313
|
+
):
|
314
|
+
# Body or path negations always imply other negations
|
315
|
+
return False
|
316
|
+
validator_cls = case.operation.schema.validator_cls # type: ignore[attr-defined]
|
317
|
+
for container in (ComponentKind.QUERY, ComponentKind.HEADERS, ComponentKind.COOKIES):
|
318
|
+
meta_for_location = meta.components.get(container)
|
319
|
+
value = getattr(case, container.value)
|
320
|
+
if value is not None and meta_for_location is not None and meta_for_location.mode.is_negative:
|
321
|
+
parameters = getattr(case.operation, container)
|
322
|
+
value_without_additional_properties = {k: v for k, v in value.items() if k in parameters}
|
323
|
+
schema = get_schema_for_location(case.operation, container, parameters)
|
324
|
+
if not validator_cls(schema).is_valid(value_without_additional_properties):
|
325
|
+
# Other types of negation found
|
326
|
+
return False
|
327
|
+
# Only additional properties are added
|
328
|
+
return True
|
329
|
+
|
330
|
+
|
331
|
+
@schemathesis.check
|
332
|
+
def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
333
|
+
from .schemas import BaseOpenAPISchema
|
334
|
+
|
335
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
336
|
+
return True
|
337
|
+
if response.status_code == 404 or response.status_code >= 500:
|
338
|
+
return None
|
339
|
+
|
340
|
+
for related_case in ctx.find_related(case_id=case.id):
|
341
|
+
parent = ctx.find_parent(case_id=related_case.id)
|
342
|
+
if not parent:
|
343
|
+
continue
|
344
|
+
|
345
|
+
parent_response = ctx.find_response(case_id=parent.id)
|
346
|
+
|
347
|
+
if (
|
348
|
+
related_case.operation.method.lower() == "delete"
|
349
|
+
and parent_response is not None
|
350
|
+
and 200 <= parent_response.status_code < 300
|
351
|
+
):
|
352
|
+
if _is_prefix_operation(
|
353
|
+
ResourcePath(related_case.path, related_case.path_parameters or {}),
|
354
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
355
|
+
):
|
356
|
+
free = f"{related_case.operation.method.upper()} {prepare_path(related_case.path, related_case.path_parameters)}"
|
357
|
+
usage = f"{case.operation.method.upper()} {prepare_path(case.path, case.path_parameters)}"
|
358
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
359
|
+
raise UseAfterFree(
|
360
|
+
operation=related_case.operation.label,
|
361
|
+
message=(
|
362
|
+
"The API did not return a `HTTP 404 Not Found` response "
|
363
|
+
f"(got `HTTP {response.status_code} {reason}`) for a resource that was previously deleted.\n\nThe resource was deleted with `{free}`"
|
364
|
+
),
|
365
|
+
free=free,
|
366
|
+
usage=usage,
|
367
|
+
)
|
368
|
+
|
369
|
+
return None
|
370
|
+
|
371
|
+
|
372
|
+
@schemathesis.check
|
373
|
+
def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
374
|
+
from .schemas import BaseOpenAPISchema
|
375
|
+
|
376
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
377
|
+
return True
|
378
|
+
|
379
|
+
parent = ctx.find_parent(case_id=case.id)
|
380
|
+
if parent is None:
|
381
|
+
return None
|
382
|
+
parent_response = ctx.find_response(case_id=parent.id)
|
383
|
+
if parent_response is None:
|
384
|
+
return None
|
385
|
+
|
386
|
+
overrides = case._override
|
387
|
+
overrides_all_parameters = True
|
388
|
+
for parameter in case.operation.iter_parameters():
|
389
|
+
container = LOCATION_TO_CONTAINER[parameter.location]
|
390
|
+
if parameter.name not in getattr(overrides, container, {}):
|
391
|
+
overrides_all_parameters = False
|
392
|
+
break
|
393
|
+
|
394
|
+
if (
|
395
|
+
# Response indicates a client error, even though all available parameters were taken from links
|
396
|
+
# and comes from a POST request. This case likely means that the POST request actually did not
|
397
|
+
# save the resource and it is not available for subsequent operations
|
398
|
+
400 <= response.status_code < 500
|
399
|
+
and parent.operation.method.upper() == "POST"
|
400
|
+
and 200 <= parent_response.status_code < 400
|
401
|
+
and overrides_all_parameters
|
402
|
+
and _is_prefix_operation(
|
403
|
+
ResourcePath(parent.path, parent.path_parameters or {}),
|
404
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
405
|
+
)
|
406
|
+
):
|
407
|
+
created_with = parent.operation.label
|
408
|
+
not_available_with = case.operation.label
|
409
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
410
|
+
raise EnsureResourceAvailability(
|
411
|
+
operation=created_with,
|
412
|
+
message=(
|
413
|
+
f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
|
414
|
+
f"Created with : `{created_with}`\n"
|
415
|
+
f"Not available with: `{not_available_with}`"
|
416
|
+
),
|
417
|
+
created_with=created_with,
|
418
|
+
not_available_with=not_available_with,
|
419
|
+
)
|
420
|
+
return None
|
421
|
+
|
422
|
+
|
423
|
+
class AuthKind(enum.Enum):
|
424
|
+
EXPLICIT = "explicit"
|
425
|
+
GENERATED = "generated"
|
426
|
+
|
427
|
+
|
428
|
+
@schemathesis.check
|
429
|
+
def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
430
|
+
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
431
|
+
from .schemas import BaseOpenAPISchema
|
432
|
+
|
433
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
434
|
+
return True
|
435
|
+
security_parameters = _get_security_parameters(case.operation)
|
436
|
+
# Authentication is required for this API operation and response is successful
|
437
|
+
if security_parameters and 200 <= response.status_code < 300:
|
438
|
+
auth = _contains_auth(ctx, case, response.request, security_parameters)
|
439
|
+
if auth == AuthKind.EXPLICIT:
|
440
|
+
# Auth is explicitly set, it is expected to be valid
|
441
|
+
# Check if invalid auth will give an error
|
442
|
+
no_auth_case = remove_auth(case, security_parameters)
|
443
|
+
kwargs = ctx.transport_kwargs or {}
|
444
|
+
kwargs.copy()
|
445
|
+
if "headers" in kwargs:
|
446
|
+
headers = kwargs["headers"].copy()
|
447
|
+
_remove_auth_from_explicit_headers(headers, security_parameters)
|
448
|
+
kwargs["headers"] = headers
|
449
|
+
kwargs.pop("session", None)
|
450
|
+
ctx.record_case(parent_id=case.id, case=no_auth_case)
|
451
|
+
no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
|
452
|
+
ctx.record_response(case_id=no_auth_case.id, response=no_auth_response)
|
453
|
+
if no_auth_response.status_code != 401:
|
454
|
+
_raise_no_auth_error(no_auth_response, no_auth_case, "that requires authentication")
|
455
|
+
# Try to set invalid auth and check if it succeeds
|
456
|
+
for parameter in security_parameters:
|
457
|
+
invalid_auth_case = remove_auth(case, security_parameters)
|
458
|
+
_set_auth_for_case(invalid_auth_case, parameter)
|
459
|
+
ctx.record_case(parent_id=case.id, case=invalid_auth_case)
|
460
|
+
invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
|
461
|
+
ctx.record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
|
462
|
+
if invalid_auth_response.status_code != 401:
|
463
|
+
_raise_no_auth_error(invalid_auth_response, invalid_auth_case, "with any auth")
|
464
|
+
elif auth == AuthKind.GENERATED:
|
465
|
+
# If this auth is generated which means it is likely invalid, then
|
466
|
+
# this request should have been an error
|
467
|
+
_raise_no_auth_error(response, case, "with invalid auth")
|
468
|
+
else:
|
469
|
+
# Successful response when there is no auth
|
470
|
+
_raise_no_auth_error(response, case, "that requires authentication")
|
471
|
+
return None
|
472
|
+
|
473
|
+
|
474
|
+
def _raise_no_auth_error(response: Response, case: Case, suffix: str) -> NoReturn:
|
475
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
476
|
+
raise IgnoredAuth(
|
477
|
+
operation=case.operation.label,
|
478
|
+
message=f"The API returned `{response.status_code} {reason}` for `{case.operation.label}` {suffix}.",
|
479
|
+
case_id=case.id,
|
480
|
+
)
|
481
|
+
|
482
|
+
|
483
|
+
SecurityParameter = Dict[str, Any]
|
484
|
+
|
485
|
+
|
486
|
+
def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]:
|
487
|
+
"""Extract security definitions that are active for the given operation and convert them into parameters."""
|
488
|
+
from .schemas import BaseOpenAPISchema
|
489
|
+
|
490
|
+
schema = cast(BaseOpenAPISchema, operation.schema)
|
491
|
+
return [
|
492
|
+
schema.security._to_parameter(parameter)
|
493
|
+
for parameter in schema.security._get_active_definitions(schema.raw_schema, operation, schema.resolver)
|
494
|
+
if parameter["type"] in ("apiKey", "basic", "http")
|
495
|
+
]
|
496
|
+
|
497
|
+
|
498
|
+
def _contains_auth(
|
499
|
+
ctx: CheckContext, case: Case, request: PreparedRequest, security_parameters: list[SecurityParameter]
|
500
|
+
) -> AuthKind | None:
|
501
|
+
"""Whether a request has authentication declared in the schema."""
|
502
|
+
from requests.cookies import RequestsCookieJar
|
503
|
+
|
504
|
+
# If auth comes from explicit `auth` option or a custom auth, it is always explicit
|
505
|
+
if ctx.auth is not None or case._has_explicit_auth:
|
506
|
+
return AuthKind.EXPLICIT
|
507
|
+
parsed = urlparse(request.url)
|
508
|
+
query = parse_qs(parsed.query) # type: ignore
|
509
|
+
# Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
|
510
|
+
header_cookies: SimpleCookie = SimpleCookie()
|
511
|
+
raw_cookie = request.headers.get("Cookie")
|
512
|
+
if raw_cookie is not None:
|
513
|
+
header_cookies.load(raw_cookie)
|
514
|
+
|
515
|
+
def has_header(p: dict[str, Any]) -> bool:
|
516
|
+
return p["in"] == "header" and p["name"] in request.headers
|
517
|
+
|
518
|
+
def has_query(p: dict[str, Any]) -> bool:
|
519
|
+
return p["in"] == "query" and p["name"] in query
|
520
|
+
|
521
|
+
def has_cookie(p: dict[str, Any]) -> bool:
|
522
|
+
cookies = cast(RequestsCookieJar, request._cookies) # type: ignore
|
523
|
+
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
524
|
+
|
525
|
+
for parameter in security_parameters:
|
526
|
+
name = parameter["name"]
|
527
|
+
if has_header(parameter):
|
528
|
+
if (ctx.headers is not None and name in ctx.headers) or (ctx.override and name in ctx.override.headers):
|
529
|
+
return AuthKind.EXPLICIT
|
530
|
+
return AuthKind.GENERATED
|
531
|
+
if has_cookie(parameter):
|
532
|
+
if ctx.headers is not None and "Cookie" in ctx.headers:
|
533
|
+
cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
|
534
|
+
if name in cookies:
|
535
|
+
return AuthKind.EXPLICIT
|
536
|
+
if ctx.override and name in ctx.override.cookies:
|
537
|
+
return AuthKind.EXPLICIT
|
538
|
+
return AuthKind.GENERATED
|
539
|
+
if has_query(parameter):
|
540
|
+
if ctx.override and name in ctx.override.query:
|
541
|
+
return AuthKind.EXPLICIT
|
542
|
+
return AuthKind.GENERATED
|
543
|
+
|
544
|
+
return None
|
545
|
+
|
546
|
+
|
547
|
+
def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Case:
|
548
|
+
"""Remove security parameters from a generated case.
|
549
|
+
|
550
|
+
It mutates `case` in place.
|
551
|
+
"""
|
552
|
+
headers = case.headers.copy() if case.headers else None
|
553
|
+
query = case.query.copy() if case.query else None
|
554
|
+
cookies = case.cookies.copy() if case.cookies else None
|
555
|
+
for parameter in security_parameters:
|
556
|
+
name = parameter["name"]
|
557
|
+
if parameter["in"] == "header" and headers:
|
558
|
+
headers.pop(name, None)
|
559
|
+
if parameter["in"] == "query" and query:
|
560
|
+
query.pop(name, None)
|
561
|
+
if parameter["in"] == "cookie" and cookies:
|
562
|
+
cookies.pop(name, None)
|
563
|
+
return Case(
|
564
|
+
operation=case.operation,
|
565
|
+
method=case.method,
|
566
|
+
path=case.path,
|
567
|
+
path_parameters=case.path_parameters.copy() if case.path_parameters else None,
|
568
|
+
headers=headers,
|
569
|
+
cookies=cookies,
|
570
|
+
query=query,
|
571
|
+
body=case.body.copy() if isinstance(case.body, (list, dict)) else case.body,
|
572
|
+
media_type=case.media_type,
|
573
|
+
meta=case.meta,
|
574
|
+
)
|
575
|
+
|
576
|
+
|
577
|
+
def _remove_auth_from_explicit_headers(headers: dict, security_parameters: list[SecurityParameter]) -> None:
|
578
|
+
for parameter in security_parameters:
|
579
|
+
name = parameter["name"]
|
580
|
+
if parameter["in"] == "header":
|
581
|
+
headers.pop(name, None)
|
582
|
+
|
583
|
+
|
584
|
+
def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
585
|
+
name = parameter["name"]
|
586
|
+
for location, attr_name in (
|
587
|
+
("header", "headers"),
|
588
|
+
("query", "query"),
|
589
|
+
("cookie", "cookies"),
|
590
|
+
):
|
591
|
+
if parameter["in"] == location:
|
592
|
+
container = getattr(case, attr_name, {})
|
593
|
+
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
594
|
+
setattr(case, attr_name, container)
|
595
|
+
|
596
|
+
|
597
|
+
@dataclass
|
598
|
+
class ResourcePath:
|
599
|
+
"""A path to a resource with variables."""
|
600
|
+
|
601
|
+
value: str
|
602
|
+
variables: dict[str, str]
|
603
|
+
|
604
|
+
__slots__ = ("value", "variables")
|
605
|
+
|
606
|
+
def get(self, key: str) -> str:
|
607
|
+
return self.variables[key.lstrip("{").rstrip("}")]
|
608
|
+
|
609
|
+
|
610
|
+
def _is_prefix_operation(lhs: ResourcePath, rhs: ResourcePath) -> bool:
|
611
|
+
lhs_parts = lhs.value.rstrip("/").split("/")
|
612
|
+
rhs_parts = rhs.value.rstrip("/").split("/")
|
613
|
+
|
614
|
+
# Left has more parts, can't be a prefix
|
615
|
+
if len(lhs_parts) > len(rhs_parts):
|
616
|
+
return False
|
617
|
+
|
618
|
+
for left, right in zip(lhs_parts, rhs_parts):
|
619
|
+
if left.startswith("{") and right.startswith("{"):
|
620
|
+
if str(lhs.get(left)) != str(rhs.get(right)):
|
621
|
+
return False
|
622
|
+
elif left != right and left.rstrip("s") != right.rstrip("s"):
|
623
|
+
# Parts don't match, not a prefix
|
624
|
+
return False
|
625
|
+
|
626
|
+
# If we've reached this point, the LHS path is a prefix of the RHS path
|
627
|
+
return True
|