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.
Files changed (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +783 -432
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +22 -5
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +258 -112
  23. schemathesis/cli/output/short.py +23 -8
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +318 -211
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +50 -15
  63. schemathesis/runner/events.py +65 -5
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +388 -177
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/runner/probes.py +11 -9
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +7 -2
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +45 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +78 -60
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +126 -12
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +360 -241
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.6.dist-info/METADATA +0 -356
  144. schemathesis-3.25.6.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {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
- from typing import TYPE_CHECKING, Any, Generator, NoReturn
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 (expected_main, expected_sub) == (received_main, received_sub):
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(f"{expected_main}_{expected_sub}", f"{received_main}_{received_sub}")
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
- from .schemas import BaseOpenAPISchema
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
- defined_headers = case.operation.schema.get_headers(case.operation, response)
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
- if not missing_headers:
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
- formatted_headers = [f"\n- `{header}`" for header in missing_headers]
116
- message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
117
- exc_class = get_headers_error(message)
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], *, nullable_name: str, copy: bool = True, is_response_schema: bool = False
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(schema, to_json_schema, nullable_name=nullable_name, is_response_schema=is_response_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
+ )