schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- 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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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 +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -1,38 +1,48 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import enum
|
4
|
+
import http.client
|
4
5
|
from dataclasses import dataclass
|
5
6
|
from http.cookies import SimpleCookie
|
6
7
|
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
7
8
|
from urllib.parse import parse_qs, urlparse
|
8
9
|
|
9
|
-
|
10
|
-
from
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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,
|
22
32
|
)
|
23
|
-
from
|
24
|
-
from
|
33
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
34
|
+
from schemathesis.transport.prepare import prepare_path
|
35
|
+
|
25
36
|
from .utils import expand_status_code, expand_status_codes
|
26
37
|
|
27
38
|
if TYPE_CHECKING:
|
28
39
|
from requests import PreparedRequest
|
29
40
|
|
30
|
-
from ...
|
31
|
-
from ...models import APIOperation, Case
|
32
|
-
from ...transports.responses import GenericResponse
|
41
|
+
from ...schemas import APIOperation
|
33
42
|
|
34
43
|
|
35
|
-
|
44
|
+
@schemathesis.check
|
45
|
+
def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
36
46
|
from .schemas import BaseOpenAPISchema
|
37
47
|
|
38
48
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
@@ -45,15 +55,12 @@ def status_code_conformance(ctx: CheckContext, response: GenericResponse, case:
|
|
45
55
|
if response.status_code not in allowed_status_codes:
|
46
56
|
defined_status_codes = list(map(str, responses))
|
47
57
|
responses_list = ", ".join(defined_status_codes)
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
defined_status_codes=defined_status_codes,
|
55
|
-
allowed_status_codes=allowed_status_codes,
|
56
|
-
),
|
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}",
|
57
64
|
)
|
58
65
|
return None # explicitly return None for mypy
|
59
66
|
|
@@ -63,7 +70,8 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
63
70
|
yield from expand_status_code(code)
|
64
71
|
|
65
72
|
|
66
|
-
|
73
|
+
@schemathesis.check
|
74
|
+
def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
67
75
|
from .schemas import BaseOpenAPISchema
|
68
76
|
|
69
77
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
@@ -71,25 +79,24 @@ def content_type_conformance(ctx: CheckContext, response: GenericResponse, case:
|
|
71
79
|
documented_content_types = case.operation.schema.get_content_types(case.operation, response)
|
72
80
|
if not documented_content_types:
|
73
81
|
return None
|
74
|
-
|
75
|
-
if not
|
76
|
-
|
77
|
-
raise
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
media_types=documented_content_types,
|
82
|
-
),
|
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,
|
83
89
|
)
|
90
|
+
content_type = content_types[0]
|
84
91
|
for option in documented_content_types:
|
85
92
|
try:
|
86
|
-
expected_main, expected_sub =
|
87
|
-
except ValueError
|
88
|
-
_reraise_malformed_media_type(case,
|
93
|
+
expected_main, expected_sub = media_types.parse(option)
|
94
|
+
except ValueError:
|
95
|
+
_reraise_malformed_media_type(case, "Schema", option, option)
|
89
96
|
try:
|
90
|
-
received_main, received_sub =
|
91
|
-
except ValueError
|
92
|
-
_reraise_malformed_media_type(case,
|
97
|
+
received_main, received_sub = media_types.parse(content_type)
|
98
|
+
except ValueError:
|
99
|
+
_reraise_malformed_media_type(case, "Response", content_type, option)
|
93
100
|
if (
|
94
101
|
(expected_main == "*" and expected_sub == "*")
|
95
102
|
or (expected_main == received_main and expected_sub == "*")
|
@@ -97,28 +104,25 @@ def content_type_conformance(ctx: CheckContext, response: GenericResponse, case:
|
|
97
104
|
or (expected_main == received_main and expected_sub == received_sub)
|
98
105
|
):
|
99
106
|
return None
|
100
|
-
|
101
|
-
case.operation.
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
context=failures.UndefinedContentType(
|
106
|
-
message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
|
107
|
-
content_type=content_type,
|
108
|
-
defined_content_types=documented_content_types,
|
109
|
-
),
|
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,
|
110
112
|
)
|
111
113
|
|
112
114
|
|
113
|
-
def _reraise_malformed_media_type(case: Case,
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
+
)
|
119
122
|
|
120
123
|
|
121
|
-
|
124
|
+
@schemathesis.check
|
125
|
+
def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
122
126
|
import jsonschema
|
123
127
|
|
124
128
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
@@ -136,23 +140,17 @@ def response_headers_conformance(ctx: CheckContext, response: GenericResponse, c
|
|
136
140
|
missing_headers = [
|
137
141
|
header
|
138
142
|
for header, definition in defined_headers.items()
|
139
|
-
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)
|
140
144
|
]
|
141
|
-
errors = []
|
145
|
+
errors: list[Failure] = []
|
142
146
|
if missing_headers:
|
143
147
|
formatted_headers = [f"\n- `{header}`" for header in missing_headers]
|
144
148
|
message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
|
145
|
-
|
146
|
-
try:
|
147
|
-
raise exc_class(
|
148
|
-
failures.MissingHeaders.title,
|
149
|
-
context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
|
150
|
-
)
|
151
|
-
except Exception as exc:
|
152
|
-
errors.append(exc)
|
149
|
+
errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
|
153
150
|
for name, definition in defined_headers.items():
|
154
|
-
|
155
|
-
if
|
151
|
+
values = response.headers.get(name.lower())
|
152
|
+
if values is not None:
|
153
|
+
value = values[0]
|
156
154
|
with case.operation.schema._validating_response(scopes) as resolver:
|
157
155
|
if "$ref" in definition:
|
158
156
|
_, definition = resolver.resolve(definition["$ref"])
|
@@ -173,14 +171,14 @@ def response_headers_conformance(ctx: CheckContext, response: GenericResponse, c
|
|
173
171
|
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
|
174
172
|
)
|
175
173
|
except jsonschema.ValidationError as exc:
|
176
|
-
|
177
|
-
|
178
|
-
|
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
|
+
)
|
179
181
|
)
|
180
|
-
try:
|
181
|
-
raise exc_class("Response header does not conform to the schema", context=error_ctx) from exc
|
182
|
-
except Exception as exc:
|
183
|
-
errors.append(exc)
|
184
182
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
185
183
|
|
186
184
|
|
@@ -202,11 +200,12 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
|
|
202
200
|
if schema_type == "null" and value.lower() == "null":
|
203
201
|
return None
|
204
202
|
if schema_type == "boolean":
|
205
|
-
return
|
203
|
+
return string_to_boolean(value)
|
206
204
|
return value
|
207
205
|
|
208
206
|
|
209
|
-
|
207
|
+
@schemathesis.check
|
208
|
+
def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
210
209
|
from .schemas import BaseOpenAPISchema
|
211
210
|
|
212
211
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
@@ -214,88 +213,85 @@ def response_schema_conformance(ctx: CheckContext, response: GenericResponse, ca
|
|
214
213
|
return case.operation.validate_response(response)
|
215
214
|
|
216
215
|
|
217
|
-
|
216
|
+
@schemathesis.check
|
217
|
+
def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
218
218
|
from .schemas import BaseOpenAPISchema
|
219
219
|
|
220
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
220
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
|
221
221
|
return True
|
222
222
|
|
223
|
-
config = ctx.config.negative_data_rejection
|
223
|
+
config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
|
224
224
|
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
225
225
|
|
226
226
|
if (
|
227
|
-
case.
|
228
|
-
and case.data_generation_method.is_negative
|
227
|
+
case.meta.generation.mode.is_negative
|
229
228
|
and response.status_code not in allowed_statuses
|
230
229
|
and not has_only_additional_properties_in_non_body_parameters(case)
|
231
230
|
):
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
message=message,
|
238
|
-
status_code=response.status_code,
|
239
|
-
allowed_statuses=config.allowed_statuses,
|
240
|
-
),
|
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,
|
241
236
|
)
|
242
237
|
return None
|
243
238
|
|
244
239
|
|
245
|
-
|
240
|
+
@schemathesis.check
|
241
|
+
def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
246
242
|
from .schemas import BaseOpenAPISchema
|
247
243
|
|
248
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
244
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
|
249
245
|
return True
|
250
246
|
|
251
|
-
config = ctx.config.positive_data_acceptance
|
247
|
+
config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
|
252
248
|
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
253
249
|
|
254
|
-
if
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
exc_class = get_positive_acceptance_error(case.operation.verbose_name, response.status_code)
|
261
|
-
raise exc_class(
|
262
|
-
failures.RejectedPositiveData.title,
|
263
|
-
context=failures.RejectedPositiveData(
|
264
|
-
message=message,
|
265
|
-
status_code=response.status_code,
|
266
|
-
allowed_statuses=config.allowed_statuses,
|
267
|
-
),
|
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,
|
268
256
|
)
|
269
257
|
return None
|
270
258
|
|
271
259
|
|
272
|
-
def missing_required_header(ctx: CheckContext, response:
|
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
|
273
266
|
if (
|
274
|
-
|
275
|
-
and
|
276
|
-
and
|
277
|
-
and
|
278
|
-
and case.meta.description.startswith("Missing ")
|
267
|
+
data.parameter
|
268
|
+
and data.parameter_location == "header"
|
269
|
+
and data.description
|
270
|
+
and data.description.startswith("Missing ")
|
279
271
|
):
|
280
|
-
if
|
272
|
+
if data.parameter.lower() == "authorization":
|
281
273
|
allowed_statuses = {401}
|
282
274
|
else:
|
283
|
-
config = ctx.config.missing_required_header
|
275
|
+
config = ctx.config.get(missing_required_header, MissingRequiredHeaderConfig())
|
284
276
|
allowed_statuses = expand_status_codes(config.allowed_statuses or [])
|
285
277
|
if response.status_code not in allowed_statuses:
|
286
|
-
allowed = f"Allowed statuses: {', '.join(map(str,allowed_statuses))}"
|
278
|
+
allowed = f"Allowed statuses: {', '.join(map(str, allowed_statuses))}"
|
287
279
|
raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
|
288
280
|
return None
|
289
281
|
|
290
282
|
|
291
|
-
def unsupported_method(ctx: CheckContext, response:
|
292
|
-
|
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:"):
|
293
289
|
if response.status_code != 405:
|
294
290
|
raise AssertionError(
|
295
291
|
f"Unexpected response status for unspecified HTTP method: {response.status_code}\nExpected: 405"
|
296
292
|
)
|
297
293
|
|
298
|
-
allow_header = response.headers.get("
|
294
|
+
allow_header = response.headers.get("allow")
|
299
295
|
if not allow_header:
|
300
296
|
raise AssertionError("Missing 'Allow' header in 405 Method Not Allowed response")
|
301
297
|
return None
|
@@ -311,14 +307,17 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
311
307
|
if meta is None:
|
312
308
|
# Ignore manually created cases
|
313
309
|
return False
|
314
|
-
if (meta.
|
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
|
+
):
|
315
314
|
# Body or path negations always imply other negations
|
316
315
|
return False
|
317
316
|
validator_cls = case.operation.schema.validator_cls # type: ignore[attr-defined]
|
318
|
-
for container in (
|
319
|
-
meta_for_location =
|
320
|
-
value = getattr(case, container)
|
321
|
-
if value is not None and meta_for_location is not None and meta_for_location.is_negative:
|
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:
|
322
321
|
parameters = getattr(case.operation, container)
|
323
322
|
value_without_additional_properties = {k: v for k, v in value.items() if k in parameters}
|
324
323
|
schema = get_schema_for_location(case.operation, container, parameters)
|
@@ -329,80 +328,94 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
329
328
|
return True
|
330
329
|
|
331
330
|
|
332
|
-
|
333
|
-
|
331
|
+
@schemathesis.check
|
332
|
+
def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
334
333
|
from .schemas import BaseOpenAPISchema
|
335
334
|
|
336
|
-
if not isinstance(
|
335
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
337
336
|
return True
|
338
|
-
if response.status_code == 404 or
|
337
|
+
if response.status_code == 404 or response.status_code >= 500:
|
339
338
|
return None
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
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
|
+
):
|
345
352
|
if _is_prefix_operation(
|
353
|
+
ResourcePath(related_case.path, related_case.path_parameters or {}),
|
346
354
|
ResourcePath(case.path, case.path_parameters or {}),
|
347
|
-
ResourcePath(original.path, original.path_parameters or {}),
|
348
355
|
):
|
349
|
-
free = f"{
|
350
|
-
usage = f"{
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
raise exc_class(
|
358
|
-
failures.UseAfterFree.title,
|
359
|
-
context=failures.UseAfterFree(
|
360
|
-
message=message,
|
361
|
-
free=free,
|
362
|
-
usage=usage,
|
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}`"
|
363
364
|
),
|
365
|
+
free=free,
|
366
|
+
usage=usage,
|
364
367
|
)
|
365
|
-
|
366
|
-
break
|
367
|
-
response = case.source.response
|
368
|
-
case = case.source.case
|
368
|
+
|
369
369
|
return None
|
370
370
|
|
371
371
|
|
372
|
-
|
373
|
-
|
372
|
+
@schemathesis.check
|
373
|
+
def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
374
374
|
from .schemas import BaseOpenAPISchema
|
375
375
|
|
376
|
-
if not isinstance(
|
376
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
377
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
|
+
|
378
394
|
if (
|
379
395
|
# Response indicates a client error, even though all available parameters were taken from links
|
380
396
|
# and comes from a POST request. This case likely means that the POST request actually did not
|
381
397
|
# save the resource and it is not available for subsequent operations
|
382
398
|
400 <= response.status_code < 500
|
383
|
-
and
|
384
|
-
and
|
385
|
-
and
|
386
|
-
and original.source.overrides_all_parameters
|
399
|
+
and parent.operation.method.upper() == "POST"
|
400
|
+
and 200 <= parent_response.status_code < 400
|
401
|
+
and overrides_all_parameters
|
387
402
|
and _is_prefix_operation(
|
388
|
-
ResourcePath(
|
389
|
-
ResourcePath(
|
403
|
+
ResourcePath(parent.path, parent.path_parameters or {}),
|
404
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
390
405
|
)
|
391
406
|
):
|
392
|
-
created_with =
|
393
|
-
not_available_with =
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
raise exc_class(
|
402
|
-
failures.EnsureResourceAvailability.title,
|
403
|
-
context=failures.EnsureResourceAvailability(
|
404
|
-
message=message, created_with=created_with, not_available_with=not_available_with
|
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}`"
|
405
416
|
),
|
417
|
+
created_with=created_with,
|
418
|
+
not_available_with=not_available_with,
|
406
419
|
)
|
407
420
|
return None
|
408
421
|
|
@@ -412,7 +425,8 @@ class AuthKind(enum.Enum):
|
|
412
425
|
GENERATED = "generated"
|
413
426
|
|
414
427
|
|
415
|
-
|
428
|
+
@schemathesis.check
|
429
|
+
def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
416
430
|
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
417
431
|
from .schemas import BaseOpenAPISchema
|
418
432
|
|
@@ -425,7 +439,7 @@ def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bo
|
|
425
439
|
if auth == AuthKind.EXPLICIT:
|
426
440
|
# Auth is explicitly set, it is expected to be valid
|
427
441
|
# Check if invalid auth will give an error
|
428
|
-
|
442
|
+
no_auth_case = remove_auth(case, security_parameters)
|
429
443
|
kwargs = ctx.transport_kwargs or {}
|
430
444
|
kwargs.copy()
|
431
445
|
if "headers" in kwargs:
|
@@ -433,46 +447,36 @@ def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bo
|
|
433
447
|
_remove_auth_from_explicit_headers(headers, security_parameters)
|
434
448
|
kwargs["headers"] = headers
|
435
449
|
kwargs.pop("session", None)
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
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")
|
440
455
|
# Try to set invalid auth and check if it succeeds
|
441
456
|
for parameter in security_parameters:
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
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")
|
448
464
|
elif auth == AuthKind.GENERATED:
|
449
465
|
# If this auth is generated which means it is likely invalid, then
|
450
466
|
# this request should have been an error
|
451
|
-
_raise_no_auth_error(response, case
|
467
|
+
_raise_no_auth_error(response, case, "with invalid auth")
|
452
468
|
else:
|
453
469
|
# Successful response when there is no auth
|
454
|
-
_raise_no_auth_error(response, case
|
470
|
+
_raise_no_auth_error(response, case, "that requires authentication")
|
455
471
|
return None
|
456
472
|
|
457
473
|
|
458
|
-
def
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
old.__dict__.update(new.__dict__)
|
465
|
-
|
466
|
-
|
467
|
-
def _raise_no_auth_error(response: GenericResponse, operation: str, suffix: str) -> NoReturn:
|
468
|
-
from ...transports.responses import get_reason
|
469
|
-
|
470
|
-
exc_class = get_ignored_auth_error(operation)
|
471
|
-
reason = get_reason(response.status_code)
|
472
|
-
message = f"The API returned `{response.status_code} {reason}` for `{operation}` {suffix}."
|
473
|
-
raise exc_class(
|
474
|
-
failures.IgnoredAuth.title,
|
475
|
-
context=failures.IgnoredAuth(message=message),
|
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,
|
476
480
|
)
|
477
481
|
|
478
482
|
|
@@ -540,19 +544,34 @@ def _contains_auth(
|
|
540
544
|
return None
|
541
545
|
|
542
546
|
|
543
|
-
def
|
547
|
+
def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Case:
|
544
548
|
"""Remove security parameters from a generated case.
|
545
549
|
|
546
550
|
It mutates `case` in place.
|
547
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
|
548
555
|
for parameter in security_parameters:
|
549
556
|
name = parameter["name"]
|
550
|
-
if parameter["in"] == "header" and
|
551
|
-
|
552
|
-
if parameter["in"] == "query" and
|
553
|
-
|
554
|
-
if parameter["in"] == "cookie" and
|
555
|
-
|
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
|
+
)
|
556
575
|
|
557
576
|
|
558
577
|
def _remove_auth_from_explicit_headers(headers: dict, security_parameters: list[SecurityParameter]) -> None:
|