schemathesis 3.39.15__py3-none-any.whl → 4.0.0__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 (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -1,43 +1,54 @@
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
- from ... import failures
10
- from ...exceptions import (
11
- get_ensure_resource_availability_error,
12
- get_headers_error,
13
- get_ignored_auth_error,
14
- get_malformed_media_type_error,
15
- get_missing_content_type_error,
16
- get_negative_rejection_error,
17
- get_positive_acceptance_error,
18
- get_response_type_error,
19
- get_schema_validation_error,
20
- get_status_code_error,
21
- get_use_after_free_error,
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
+ MissingHeaderNotRejected,
25
+ MissingHeaders,
26
+ RejectedPositiveData,
27
+ UndefinedContentType,
28
+ UndefinedStatusCode,
29
+ UnsupportedMethodResponse,
30
+ UseAfterFree,
22
31
  )
23
- from ...internal.transformation import convert_boolean_string
24
- from ...transports.content_types import parse_content_type
32
+ from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
33
+ from schemathesis.transport.prepare import prepare_path
34
+
25
35
  from .utils import expand_status_code, expand_status_codes
26
36
 
27
37
  if TYPE_CHECKING:
28
- from requests import PreparedRequest
29
-
30
- from ...internal.checks import CheckContext
31
- from ...models import APIOperation, Case
32
- from ...transports.responses import GenericResponse
38
+ from schemathesis.schemas import APIOperation
33
39
 
34
40
 
35
41
  def is_unexpected_http_status_case(case: Case) -> bool:
36
42
  # Skip checks for requests using HTTP methods not defined in the API spec
37
- return bool(case.meta and case.meta.description and case.meta.description.startswith("Unspecified HTTP method"))
43
+ return bool(
44
+ case.meta
45
+ and isinstance(case.meta.phase.data, CoveragePhaseData)
46
+ and case.meta.phase.data.description.startswith("Unspecified HTTP method")
47
+ )
38
48
 
39
49
 
40
- def status_code_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
50
+ @schemathesis.check
51
+ def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
41
52
  from .schemas import BaseOpenAPISchema
42
53
 
43
54
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
@@ -50,15 +61,12 @@ def status_code_conformance(ctx: CheckContext, response: GenericResponse, case:
50
61
  if response.status_code not in allowed_status_codes:
51
62
  defined_status_codes = list(map(str, responses))
52
63
  responses_list = ", ".join(defined_status_codes)
53
- exc_class = get_status_code_error(case.operation.verbose_name, response.status_code)
54
- raise exc_class(
55
- failures.UndefinedStatusCode.title,
56
- context=failures.UndefinedStatusCode(
57
- message=f"Received: {response.status_code}\nDocumented: {responses_list}",
58
- status_code=response.status_code,
59
- defined_status_codes=defined_status_codes,
60
- allowed_status_codes=allowed_status_codes,
61
- ),
64
+ raise UndefinedStatusCode(
65
+ operation=case.operation.label,
66
+ status_code=response.status_code,
67
+ defined_status_codes=defined_status_codes,
68
+ allowed_status_codes=allowed_status_codes,
69
+ message=f"Received: {response.status_code}\nDocumented: {responses_list}",
62
70
  )
63
71
  return None # explicitly return None for mypy
64
72
 
@@ -68,7 +76,8 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
68
76
  yield from expand_status_code(code)
69
77
 
70
78
 
71
- def content_type_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
79
+ @schemathesis.check
80
+ def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
72
81
  from .schemas import BaseOpenAPISchema
73
82
 
74
83
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
@@ -76,25 +85,24 @@ def content_type_conformance(ctx: CheckContext, response: GenericResponse, case:
76
85
  documented_content_types = case.operation.schema.get_content_types(case.operation, response)
77
86
  if not documented_content_types:
78
87
  return None
79
- content_type = response.headers.get("Content-Type")
80
- if not content_type:
81
- formatted_content_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
82
- raise get_missing_content_type_error(case.operation.verbose_name)(
83
- failures.MissingContentType.title,
84
- context=failures.MissingContentType(
85
- message=f"The following media types are documented in the schema:{''.join(formatted_content_types)}",
86
- media_types=documented_content_types,
87
- ),
88
+ content_types = response.headers.get("content-type")
89
+ if not content_types:
90
+ all_media_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
91
+ raise MissingContentType(
92
+ operation=case.operation.label,
93
+ message=f"The following media types are documented in the schema:{''.join(all_media_types)}",
94
+ media_types=documented_content_types,
88
95
  )
96
+ content_type = content_types[0]
89
97
  for option in documented_content_types:
90
98
  try:
91
- expected_main, expected_sub = parse_content_type(option)
92
- except ValueError as exc:
93
- _reraise_malformed_media_type(case, exc, "Schema", option, option)
99
+ expected_main, expected_sub = media_types.parse(option)
100
+ except ValueError:
101
+ _reraise_malformed_media_type(case, "Schema", option, option)
94
102
  try:
95
- received_main, received_sub = parse_content_type(content_type)
96
- except ValueError as exc:
97
- _reraise_malformed_media_type(case, exc, "Response", content_type, option)
103
+ received_main, received_sub = media_types.parse(content_type)
104
+ except ValueError:
105
+ _reraise_malformed_media_type(case, "Response", content_type, option)
98
106
  if (
99
107
  (expected_main == "*" and expected_sub == "*")
100
108
  or (expected_main == received_main and expected_sub == "*")
@@ -102,28 +110,25 @@ def content_type_conformance(ctx: CheckContext, response: GenericResponse, case:
102
110
  or (expected_main == received_main and expected_sub == received_sub)
103
111
  ):
104
112
  return None
105
- exc_class = get_response_type_error(
106
- case.operation.verbose_name, f"{expected_main}_{expected_sub}", f"{received_main}_{received_sub}"
107
- )
108
- raise exc_class(
109
- failures.UndefinedContentType.title,
110
- context=failures.UndefinedContentType(
111
- message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
112
- content_type=content_type,
113
- defined_content_types=documented_content_types,
114
- ),
113
+ raise UndefinedContentType(
114
+ operation=case.operation.label,
115
+ message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
116
+ content_type=content_type,
117
+ defined_content_types=documented_content_types,
115
118
  )
116
119
 
117
120
 
118
- def _reraise_malformed_media_type(case: Case, exc: ValueError, location: str, actual: str, defined: str) -> NoReturn:
119
- message = f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}"
120
- raise get_malformed_media_type_error(case.operation.verbose_name, message)(
121
- failures.MalformedMediaType.title,
122
- context=failures.MalformedMediaType(message=message, actual=actual, defined=defined),
123
- ) from exc
121
+ def _reraise_malformed_media_type(case: Case, location: str, actual: str, defined: str) -> NoReturn:
122
+ raise MalformedMediaType(
123
+ operation=case.operation.label,
124
+ message=f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}",
125
+ actual=actual,
126
+ defined=defined,
127
+ )
124
128
 
125
129
 
126
- def response_headers_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
130
+ @schemathesis.check
131
+ def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
127
132
  import jsonschema
128
133
 
129
134
  from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
@@ -141,23 +146,17 @@ def response_headers_conformance(ctx: CheckContext, response: GenericResponse, c
141
146
  missing_headers = [
142
147
  header
143
148
  for header, definition in defined_headers.items()
144
- if header not in response.headers and definition.get(case.operation.schema.header_required_field, False)
149
+ if header.lower() not in response.headers and definition.get(case.operation.schema.header_required_field, False)
145
150
  ]
146
- errors = []
151
+ errors: list[Failure] = []
147
152
  if missing_headers:
148
153
  formatted_headers = [f"\n- `{header}`" for header in missing_headers]
149
154
  message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
150
- exc_class = get_headers_error(case.operation.verbose_name, message)
151
- try:
152
- raise exc_class(
153
- failures.MissingHeaders.title,
154
- context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
155
- )
156
- except Exception as exc:
157
- errors.append(exc)
155
+ errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
158
156
  for name, definition in defined_headers.items():
159
- value = response.headers.get(name)
160
- if value is not None:
157
+ values = response.headers.get(name.lower())
158
+ if values is not None:
159
+ value = values[0]
161
160
  with case.operation.schema._validating_response(scopes) as resolver:
162
161
  if "$ref" in definition:
163
162
  _, definition = resolver.resolve(definition["$ref"])
@@ -178,14 +177,14 @@ def response_headers_conformance(ctx: CheckContext, response: GenericResponse, c
178
177
  format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
179
178
  )
180
179
  except jsonschema.ValidationError as exc:
181
- exc_class = get_schema_validation_error(case.operation.verbose_name, exc)
182
- error_ctx = failures.ValidationErrorContext.from_exception(
183
- exc, output_config=case.operation.schema.output_config
180
+ errors.append(
181
+ JsonSchemaError.from_exception(
182
+ title="Response header does not conform to the schema",
183
+ operation=case.operation.label,
184
+ exc=exc,
185
+ config=case.operation.schema.config.output,
186
+ )
184
187
  )
185
- try:
186
- raise exc_class("Response header does not conform to the schema", context=error_ctx) from exc
187
- except Exception as exc:
188
- errors.append(exc)
189
188
  return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
190
189
 
191
190
 
@@ -207,11 +206,12 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
207
206
  if schema_type == "null" and value.lower() == "null":
208
207
  return None
209
208
  if schema_type == "boolean":
210
- return convert_boolean_string(value)
209
+ return string_to_boolean(value)
211
210
  return value
212
211
 
213
212
 
214
- def response_schema_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
213
+ @schemathesis.check
214
+ def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
215
215
  from .schemas import BaseOpenAPISchema
216
216
 
217
217
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
@@ -219,95 +219,113 @@ def response_schema_conformance(ctx: CheckContext, response: GenericResponse, ca
219
219
  return case.operation.validate_response(response)
220
220
 
221
221
 
222
- def negative_data_rejection(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
222
+ @schemathesis.check
223
+ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
223
224
  from .schemas import BaseOpenAPISchema
224
225
 
225
- if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
226
+ if (
227
+ not isinstance(case.operation.schema, BaseOpenAPISchema)
228
+ or case.meta is None
229
+ or is_unexpected_http_status_case(case)
230
+ ):
226
231
  return True
227
232
 
228
233
  config = ctx.config.negative_data_rejection
229
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
234
+ allowed_statuses = expand_status_codes(config.expected_statuses or [])
230
235
 
231
236
  if (
232
- case.data_generation_method
233
- and case.data_generation_method.is_negative
237
+ case.meta.generation.mode.is_negative
234
238
  and response.status_code not in allowed_statuses
235
239
  and not has_only_additional_properties_in_non_body_parameters(case)
236
240
  ):
237
- message = f"Allowed statuses: {', '.join(config.allowed_statuses)}"
238
- exc_class = get_negative_rejection_error(case.operation.verbose_name, response.status_code)
239
- raise exc_class(
240
- failures.AcceptedNegativeData.title,
241
- context=failures.AcceptedNegativeData(
242
- message=message,
243
- status_code=response.status_code,
244
- allowed_statuses=config.allowed_statuses,
245
- ),
241
+ raise AcceptedNegativeData(
242
+ operation=case.operation.label,
243
+ message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
244
+ status_code=response.status_code,
245
+ expected_statuses=config.expected_statuses,
246
246
  )
247
247
  return None
248
248
 
249
249
 
250
- def positive_data_acceptance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
250
+ @schemathesis.check
251
+ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
251
252
  from .schemas import BaseOpenAPISchema
252
253
 
253
- if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
254
+ if (
255
+ not isinstance(case.operation.schema, BaseOpenAPISchema)
256
+ or case.meta is None
257
+ or is_unexpected_http_status_case(case)
258
+ ):
254
259
  return True
255
260
 
256
261
  config = ctx.config.positive_data_acceptance
257
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
258
-
259
- if (
260
- case.data_generation_method
261
- and case.data_generation_method.is_positive
262
- and response.status_code not in allowed_statuses
263
- ):
264
- message = f"Allowed statuses: {', '.join(config.allowed_statuses)}"
265
- exc_class = get_positive_acceptance_error(case.operation.verbose_name, response.status_code)
266
- raise exc_class(
267
- failures.RejectedPositiveData.title,
268
- context=failures.RejectedPositiveData(
269
- message=message,
270
- status_code=response.status_code,
271
- allowed_statuses=config.allowed_statuses,
272
- ),
262
+ allowed_statuses = expand_status_codes(config.expected_statuses or [])
263
+
264
+ if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
265
+ raise RejectedPositiveData(
266
+ operation=case.operation.label,
267
+ message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
268
+ status_code=response.status_code,
269
+ allowed_statuses=config.expected_statuses,
273
270
  )
274
271
  return None
275
272
 
276
273
 
277
- def missing_required_header(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
274
+ @schemathesis.check
275
+ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
276
+ meta = case.meta
277
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
278
+ return None
279
+ data = meta.phase.data
278
280
  if (
279
- case.meta
280
- and case.meta.parameter_location == "header"
281
- and case.meta.parameter
282
- and case.meta.description
283
- and case.meta.description.startswith("Missing ")
281
+ data.parameter
282
+ and data.parameter_location == "header"
283
+ and data.description
284
+ and data.description.startswith("Missing ")
284
285
  ):
285
- if case.meta.parameter.lower() == "authorization":
286
- allowed_statuses = {401}
286
+ if data.parameter.lower() == "authorization":
287
+ expected_statuses = {401}
287
288
  else:
288
289
  config = ctx.config.missing_required_header
289
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
290
- if response.status_code not in allowed_statuses:
291
- allowed = f"Allowed statuses: {', '.join(map(str, allowed_statuses))}"
292
- raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
290
+ expected_statuses = expand_status_codes(config.expected_statuses or [])
291
+ if response.status_code not in expected_statuses:
292
+ allowed = ", ".join(map(str, expected_statuses))
293
+ raise MissingHeaderNotRejected(
294
+ operation=f"{case.method} {case.path}",
295
+ header_name=data.parameter,
296
+ status_code=response.status_code,
297
+ expected_statuses=list(expected_statuses),
298
+ message=f"Missing header not rejected (got {response.status_code}, expected {allowed})",
299
+ )
293
300
  return None
294
301
 
295
302
 
296
- def unsupported_method(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
297
- if (
298
- case.meta
299
- and case.meta.description
300
- and case.meta.description.startswith("Unspecified HTTP method:")
301
- and response.request.method != "OPTIONS"
302
- ):
303
+ @schemathesis.check
304
+ def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
305
+ meta = case.meta
306
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
307
+ return None
308
+ data = meta.phase.data
309
+ if data.description and data.description.startswith("Unspecified HTTP method:"):
303
310
  if response.status_code != 405:
304
- raise AssertionError(
305
- f"Unexpected response status for unspecified HTTP method: {response.status_code}\nExpected: 405"
311
+ raise UnsupportedMethodResponse(
312
+ operation=case.operation.label,
313
+ method=cast(str, response.request.method),
314
+ status_code=response.status_code,
315
+ failure_reason="wrong_status",
316
+ message=f"Wrong status for unsupported method {response.request.method} (got {response.status_code}, expected 405)",
306
317
  )
307
318
 
308
- allow_header = response.headers.get("Allow")
319
+ allow_header = response.headers.get("allow")
309
320
  if not allow_header:
310
- raise AssertionError("Missing 'Allow' header in 405 Method Not Allowed response")
321
+ raise UnsupportedMethodResponse(
322
+ operation=case.operation.label,
323
+ method=cast(str, response.request.method),
324
+ status_code=response.status_code,
325
+ allow_header_present=False,
326
+ failure_reason="missing_allow_header",
327
+ message=f"Missing Allow header for unsupported method {response.request.method}",
328
+ )
311
329
  return None
312
330
 
313
331
 
@@ -321,14 +339,17 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
321
339
  if meta is None:
322
340
  # Ignore manually created cases
323
341
  return False
324
- if (meta.body and meta.body.is_negative) or (meta.path_parameters and meta.path_parameters.is_negative):
342
+ if (ComponentKind.BODY in meta.components and meta.components[ComponentKind.BODY].mode.is_negative) or (
343
+ ComponentKind.PATH_PARAMETERS in meta.components
344
+ and meta.components[ComponentKind.PATH_PARAMETERS].mode.is_negative
345
+ ):
325
346
  # Body or path negations always imply other negations
326
347
  return False
327
348
  validator_cls = case.operation.schema.validator_cls # type: ignore[attr-defined]
328
- for container in ("query", "headers", "cookies"):
329
- meta_for_location = getattr(meta, container)
330
- value = getattr(case, container)
331
- if value is not None and meta_for_location is not None and meta_for_location.is_negative:
349
+ for container in (ComponentKind.QUERY, ComponentKind.HEADERS, ComponentKind.COOKIES):
350
+ meta_for_location = meta.components.get(container)
351
+ value = getattr(case, container.value)
352
+ if value is not None and meta_for_location is not None and meta_for_location.mode.is_negative:
332
353
  parameters = getattr(case.operation, container)
333
354
  value_without_additional_properties = {k: v for k, v in value.items() if k in parameters}
334
355
  schema = get_schema_for_location(case.operation, container, parameters)
@@ -339,90 +360,134 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
339
360
  return True
340
361
 
341
362
 
342
- def use_after_free(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
343
- from ...transports.responses import get_reason
363
+ @schemathesis.check
364
+ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
344
365
  from .schemas import BaseOpenAPISchema
345
366
 
346
- if not isinstance(original.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(original):
367
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
347
368
  return True
348
- if response.status_code == 404 or not original.source or response.status_code >= 500:
369
+ if response.status_code == 404 or response.status_code >= 500:
349
370
  return None
350
- response = original.source.response
351
- case = original.source.case
352
- while True:
353
- # Find the most recent successful DELETE call that corresponds to the current operation
354
- if case.operation.method.lower() == "delete" and 200 <= response.status_code < 300:
371
+
372
+ for related_case in ctx._find_related(case_id=case.id):
373
+ parent = ctx._find_parent(case_id=related_case.id)
374
+ if not parent:
375
+ continue
376
+
377
+ parent_response = ctx._find_response(case_id=parent.id)
378
+
379
+ if (
380
+ related_case.operation.method.lower() == "delete"
381
+ and parent_response is not None
382
+ and 200 <= parent_response.status_code < 300
383
+ ):
355
384
  if _is_prefix_operation(
385
+ ResourcePath(related_case.path, related_case.path_parameters or {}),
356
386
  ResourcePath(case.path, case.path_parameters or {}),
357
- ResourcePath(original.path, original.path_parameters or {}),
358
387
  ):
359
- free = f"{case.operation.method.upper()} {case.formatted_path}"
360
- usage = f"{original.operation.method} {original.formatted_path}"
361
- exc_class = get_use_after_free_error(case.operation.verbose_name)
362
- reason = get_reason(response.status_code)
363
- message = (
364
- "The API did not return a `HTTP 404 Not Found` response "
365
- f"(got `HTTP {response.status_code} {reason}`) for a resource that was previously deleted.\n\nThe resource was deleted with `{free}`"
366
- )
367
- raise exc_class(
368
- failures.UseAfterFree.title,
369
- context=failures.UseAfterFree(
370
- message=message,
371
- free=free,
372
- usage=usage,
388
+ free = f"{related_case.operation.method.upper()} {prepare_path(related_case.path, related_case.path_parameters)}"
389
+ usage = f"{case.operation.method.upper()} {prepare_path(case.path, case.path_parameters)}"
390
+ reason = http.client.responses.get(response.status_code, "Unknown")
391
+ raise UseAfterFree(
392
+ operation=related_case.operation.label,
393
+ message=(
394
+ "The API did not return a `HTTP 404 Not Found` response "
395
+ f"(got `HTTP {response.status_code} {reason}`) for a resource that was previously deleted.\n\nThe resource was deleted with `{free}`"
373
396
  ),
397
+ free=free,
398
+ usage=usage,
374
399
  )
375
- if case.source is None:
376
- break
377
- response = case.source.response
378
- case = case.source.case
400
+
379
401
  return None
380
402
 
381
403
 
382
- def ensure_resource_availability(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
383
- from ...transports.responses import get_reason
404
+ @schemathesis.check
405
+ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
384
406
  from .schemas import BaseOpenAPISchema
385
407
 
386
- if not isinstance(original.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(original):
408
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
387
409
  return True
388
- if (
389
- # Response indicates a client error, even though all available parameters were taken from links
390
- # and comes from a POST request. This case likely means that the POST request actually did not
391
- # save the resource and it is not available for subsequent operations
392
- 400 <= response.status_code < 500
393
- and original.source
394
- and original.source.case.operation.method.upper() == "POST"
395
- and 200 <= original.source.response.status_code < 400
396
- and original.source.overrides_all_parameters
410
+
411
+ # First, check if this is a 4XX response
412
+ if not (400 <= response.status_code < 500):
413
+ return None
414
+
415
+ parent = ctx._find_parent(case_id=case.id)
416
+ if parent is None:
417
+ return None
418
+ parent_response = ctx._find_response(case_id=parent.id)
419
+ if parent_response is None:
420
+ return None
421
+
422
+ if not (
423
+ parent.operation.method.upper() == "POST"
424
+ and 200 <= parent_response.status_code < 400
397
425
  and _is_prefix_operation(
398
- ResourcePath(original.source.case.path, original.source.case.path_parameters or {}),
399
- ResourcePath(original.path, original.path_parameters or {}),
426
+ ResourcePath(parent.path, parent.path_parameters or {}),
427
+ ResourcePath(case.path, case.path_parameters or {}),
400
428
  )
401
429
  ):
402
- created_with = original.source.case.operation.verbose_name
403
- not_available_with = original.operation.verbose_name
404
- exc_class = get_ensure_resource_availability_error(created_with)
405
- reason = get_reason(response.status_code)
406
- message = (
430
+ return None
431
+
432
+ # Check if all parameters come from links
433
+ overrides = case._override
434
+ overrides_all_parameters = True
435
+ for parameter in case.operation.iter_parameters():
436
+ container = LOCATION_TO_CONTAINER[parameter.location]
437
+ if parameter.name not in getattr(overrides, container, {}):
438
+ overrides_all_parameters = False
439
+ break
440
+ if not overrides_all_parameters:
441
+ return None
442
+
443
+ # Look for any successful DELETE operations on this resource
444
+ for related_case in ctx._find_related(case_id=case.id):
445
+ related_response = ctx._find_response(case_id=related_case.id)
446
+ if (
447
+ related_case.operation.method.upper() == "DELETE"
448
+ and related_response is not None
449
+ and 200 <= related_response.status_code < 300
450
+ and _is_prefix_operation(
451
+ ResourcePath(related_case.path, related_case.path_parameters or {}),
452
+ ResourcePath(case.path, case.path_parameters or {}),
453
+ )
454
+ ):
455
+ # Resource was properly deleted, 404 is expected
456
+ return None
457
+
458
+ # If we got here:
459
+ # 1. Resource was created successfully
460
+ # 2. Current operation returned 4XX
461
+ # 3. All parameters come from links
462
+ # 4. No successful DELETE operations found
463
+ created_with = parent.operation.label
464
+ not_available_with = case.operation.label
465
+ reason = http.client.responses.get(response.status_code, "Unknown")
466
+ raise EnsureResourceAvailability(
467
+ operation=created_with,
468
+ message=(
407
469
  f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
408
470
  f"Created with : `{created_with}`\n"
409
471
  f"Not available with: `{not_available_with}`"
410
- )
411
- raise exc_class(
412
- failures.EnsureResourceAvailability.title,
413
- context=failures.EnsureResourceAvailability(
414
- message=message, created_with=created_with, not_available_with=not_available_with
415
- ),
416
- )
417
- return None
472
+ ),
473
+ created_with=created_with,
474
+ not_available_with=not_available_with,
475
+ )
476
+
477
+
478
+ class AuthScenario(str, enum.Enum):
479
+ NO_AUTH = "no_auth"
480
+ INVALID_AUTH = "invalid_auth"
481
+ GENERATED_AUTH = "generated_auth"
418
482
 
419
483
 
420
- class AuthKind(enum.Enum):
484
+ class AuthKind(str, enum.Enum):
421
485
  EXPLICIT = "explicit"
422
486
  GENERATED = "generated"
423
487
 
424
488
 
425
- def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
489
+ @schemathesis.check
490
+ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
426
491
  """Check if an operation declares authentication as a requirement but does not actually enforce it."""
427
492
  from .schemas import BaseOpenAPISchema
428
493
 
@@ -431,58 +496,69 @@ def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bo
431
496
  security_parameters = _get_security_parameters(case.operation)
432
497
  # Authentication is required for this API operation and response is successful
433
498
  if security_parameters and 200 <= response.status_code < 300:
434
- auth = _contains_auth(ctx, case, response.request, security_parameters)
499
+ auth = _contains_auth(ctx, case, response, security_parameters)
435
500
  if auth == AuthKind.EXPLICIT:
436
501
  # Auth is explicitly set, it is expected to be valid
437
502
  # Check if invalid auth will give an error
438
- _remove_auth_from_case(case, security_parameters)
439
- kwargs = ctx.transport_kwargs or {}
503
+ no_auth_case = remove_auth(case, security_parameters)
504
+ kwargs = ctx._transport_kwargs or {}
440
505
  kwargs.copy()
441
- if "headers" in kwargs:
442
- headers = kwargs["headers"].copy()
443
- _remove_auth_from_explicit_headers(headers, security_parameters)
444
- kwargs["headers"] = headers
506
+ for location, container_name in (
507
+ ("header", "headers"),
508
+ ("cookie", "cookies"),
509
+ ("query", "query"),
510
+ ):
511
+ if container_name in kwargs:
512
+ container = kwargs[container_name].copy()
513
+ _remove_auth_from_container(container, security_parameters, location=location)
514
+ kwargs[container_name] = container
445
515
  kwargs.pop("session", None)
446
- new_response = case.operation.schema.transport.send(case, **kwargs)
447
- if new_response.status_code != 401:
448
- _update_response(response, new_response)
449
- _raise_no_auth_error(new_response, case.operation.verbose_name, "that requires authentication")
516
+ ctx._record_case(parent_id=case.id, case=no_auth_case)
517
+ no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
518
+ ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
519
+ if no_auth_response.status_code != 401:
520
+ _raise_no_auth_error(no_auth_response, no_auth_case, AuthScenario.NO_AUTH)
450
521
  # Try to set invalid auth and check if it succeeds
451
522
  for parameter in security_parameters:
452
- _set_auth_for_case(case, parameter)
453
- new_response = case.operation.schema.transport.send(case, **kwargs)
454
- if new_response.status_code != 401:
455
- _update_response(response, new_response)
456
- _raise_no_auth_error(new_response, case.operation.verbose_name, "with any auth")
457
- _remove_auth_from_case(case, security_parameters)
523
+ invalid_auth_case = remove_auth(case, security_parameters)
524
+ _set_auth_for_case(invalid_auth_case, parameter)
525
+ ctx._record_case(parent_id=case.id, case=invalid_auth_case)
526
+ invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
527
+ ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
528
+ if invalid_auth_response.status_code != 401:
529
+ _raise_no_auth_error(invalid_auth_response, invalid_auth_case, AuthScenario.INVALID_AUTH)
458
530
  elif auth == AuthKind.GENERATED:
459
531
  # If this auth is generated which means it is likely invalid, then
460
532
  # this request should have been an error
461
- _raise_no_auth_error(response, case.operation.verbose_name, "with invalid auth")
533
+ _raise_no_auth_error(response, case, AuthScenario.GENERATED_AUTH)
462
534
  else:
463
535
  # Successful response when there is no auth
464
- _raise_no_auth_error(response, case.operation.verbose_name, "that requires authentication")
536
+ _raise_no_auth_error(response, case, AuthScenario.NO_AUTH)
465
537
  return None
466
538
 
467
539
 
468
- def _update_response(old: GenericResponse, new: GenericResponse) -> None:
469
- # Mutate the response object in place on the best effort basis
470
- if hasattr(old, "__attrs__"):
471
- for attribute in new.__attrs__:
472
- setattr(old, attribute, getattr(new, attribute))
473
- else:
474
- old.__dict__.update(new.__dict__)
540
+ def _raise_no_auth_error(response: Response, case: Case, auth: AuthScenario) -> NoReturn:
541
+ reason = http.client.responses.get(response.status_code, "Unknown")
475
542
 
476
-
477
- def _raise_no_auth_error(response: GenericResponse, operation: str, suffix: str) -> NoReturn:
478
- from ...transports.responses import get_reason
479
-
480
- exc_class = get_ignored_auth_error(operation)
481
- reason = get_reason(response.status_code)
482
- message = f"The API returned `{response.status_code} {reason}` for `{operation}` {suffix}."
483
- raise exc_class(
484
- failures.IgnoredAuth.title,
485
- context=failures.IgnoredAuth(message=message),
543
+ if auth == AuthScenario.NO_AUTH:
544
+ title = "API accepts requests without authentication"
545
+ detail = None
546
+ elif auth == AuthScenario.INVALID_AUTH:
547
+ title = "API accepts invalid authentication"
548
+ detail = "invalid credentials provided"
549
+ else:
550
+ title = "API accepts invalid authentication"
551
+ detail = "generated auth likely invalid"
552
+
553
+ message = f"Expected 401, got `{response.status_code} {reason}` for `{case.operation.label}`"
554
+ if detail is not None:
555
+ message = f"{message} ({detail})"
556
+
557
+ raise IgnoredAuth(
558
+ operation=case.operation.label,
559
+ message=message,
560
+ title=title,
561
+ case_id=case.id,
486
562
  )
487
563
 
488
564
 
@@ -502,14 +578,15 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
502
578
 
503
579
 
504
580
  def _contains_auth(
505
- ctx: CheckContext, case: Case, request: PreparedRequest, security_parameters: list[SecurityParameter]
581
+ ctx: CheckContext, case: Case, response: Response, security_parameters: list[SecurityParameter]
506
582
  ) -> AuthKind | None:
507
583
  """Whether a request has authentication declared in the schema."""
508
584
  from requests.cookies import RequestsCookieJar
509
585
 
510
586
  # If auth comes from explicit `auth` option or a custom auth, it is always explicit
511
- if ctx.auth is not None or case._has_explicit_auth:
587
+ if ctx._auth is not None or case._has_explicit_auth:
512
588
  return AuthKind.EXPLICIT
589
+ request = response.request
513
590
  parsed = urlparse(request.url)
514
591
  query = parse_qs(parsed.query) # type: ignore
515
592
  # Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
@@ -531,45 +608,76 @@ def _contains_auth(
531
608
  for parameter in security_parameters:
532
609
  name = parameter["name"]
533
610
  if has_header(parameter):
534
- if (ctx.headers is not None and name in ctx.headers) or (ctx.override and name in ctx.override.headers):
611
+ if (
612
+ # Explicit CLI headers
613
+ (ctx._headers is not None and name in ctx._headers)
614
+ # Other kinds of overrides
615
+ or (ctx._override and name in ctx._override.headers)
616
+ or (response._override and name in response._override.headers)
617
+ ):
535
618
  return AuthKind.EXPLICIT
536
619
  return AuthKind.GENERATED
537
620
  if has_cookie(parameter):
538
- if ctx.headers is not None and "Cookie" in ctx.headers:
539
- cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
540
- if name in cookies:
541
- return AuthKind.EXPLICIT
542
- if ctx.override and name in ctx.override.cookies:
621
+ for headers in [
622
+ ctx._headers,
623
+ (ctx._override.headers if ctx._override else None),
624
+ (response._override.headers if response._override else None),
625
+ ]:
626
+ if headers is not None and "Cookie" in headers:
627
+ jar = cast(RequestsCookieJar, headers["Cookie"])
628
+ if name in jar:
629
+ return AuthKind.EXPLICIT
630
+
631
+ if (ctx._override and name in ctx._override.cookies) or (
632
+ response._override and name in response._override.cookies
633
+ ):
543
634
  return AuthKind.EXPLICIT
544
635
  return AuthKind.GENERATED
545
636
  if has_query(parameter):
546
- if ctx.override and name in ctx.override.query:
637
+ if (ctx._override and name in ctx._override.query) or (
638
+ response._override and name in response._override.query
639
+ ):
547
640
  return AuthKind.EXPLICIT
548
641
  return AuthKind.GENERATED
549
642
 
550
643
  return None
551
644
 
552
645
 
553
- def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParameter]) -> None:
646
+ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Case:
554
647
  """Remove security parameters from a generated case.
555
648
 
556
649
  It mutates `case` in place.
557
650
  """
651
+ headers = case.headers.copy()
652
+ query = case.query.copy()
653
+ cookies = case.cookies.copy()
558
654
  for parameter in security_parameters:
559
655
  name = parameter["name"]
560
- if parameter["in"] == "header" and case.headers:
561
- case.headers.pop(name, None)
562
- if parameter["in"] == "query" and case.query:
563
- case.query.pop(name, None)
564
- if parameter["in"] == "cookie" and case.cookies:
565
- case.cookies.pop(name, None)
656
+ if parameter["in"] == "header" and headers:
657
+ headers.pop(name, None)
658
+ if parameter["in"] == "query" and query:
659
+ query.pop(name, None)
660
+ if parameter["in"] == "cookie" and cookies:
661
+ cookies.pop(name, None)
662
+ return Case(
663
+ operation=case.operation,
664
+ method=case.method,
665
+ path=case.path,
666
+ path_parameters=case.path_parameters.copy(),
667
+ headers=headers,
668
+ cookies=cookies,
669
+ query=query,
670
+ body=case.body.copy() if isinstance(case.body, (list, dict)) else case.body,
671
+ media_type=case.media_type,
672
+ meta=case.meta,
673
+ )
566
674
 
567
675
 
568
- def _remove_auth_from_explicit_headers(headers: dict, security_parameters: list[SecurityParameter]) -> None:
676
+ def _remove_auth_from_container(container: dict, security_parameters: list[SecurityParameter], location: str) -> None:
569
677
  for parameter in security_parameters:
570
678
  name = parameter["name"]
571
- if parameter["in"] == "header":
572
- headers.pop(name, None)
679
+ if parameter["in"] == location:
680
+ container.pop(name, None)
573
681
 
574
682
 
575
683
  def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
@@ -581,6 +689,9 @@ def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
581
689
  ):
582
690
  if parameter["in"] == location:
583
691
  container = getattr(case, attr_name, {})
692
+ # Could happen in the negative testing mode
693
+ if not isinstance(container, dict):
694
+ container = {}
584
695
  container[name] = "SCHEMATHESIS-INVALID-VALUE"
585
696
  setattr(case, attr_name, container)
586
697