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