schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 (73) hide show
  1. schemathesis/__init__.py +35 -27
  2. schemathesis/auths.py +85 -54
  3. schemathesis/checks.py +65 -36
  4. schemathesis/cli/commands/run/__init__.py +32 -27
  5. schemathesis/cli/commands/run/context.py +6 -1
  6. schemathesis/cli/commands/run/events.py +7 -1
  7. schemathesis/cli/commands/run/executor.py +12 -7
  8. schemathesis/cli/commands/run/handlers/output.py +188 -80
  9. schemathesis/cli/commands/run/validation.py +21 -6
  10. schemathesis/cli/constants.py +1 -1
  11. schemathesis/config/__init__.py +2 -1
  12. schemathesis/config/_generation.py +12 -13
  13. schemathesis/config/_operations.py +14 -0
  14. schemathesis/config/_phases.py +41 -5
  15. schemathesis/config/_projects.py +33 -1
  16. schemathesis/config/_report.py +6 -2
  17. schemathesis/config/_warnings.py +25 -0
  18. schemathesis/config/schema.json +49 -1
  19. schemathesis/core/errors.py +15 -19
  20. schemathesis/core/transport.py +117 -2
  21. schemathesis/engine/context.py +1 -0
  22. schemathesis/engine/errors.py +61 -2
  23. schemathesis/engine/events.py +10 -2
  24. schemathesis/engine/phases/probes.py +3 -0
  25. schemathesis/engine/phases/stateful/__init__.py +2 -1
  26. schemathesis/engine/phases/stateful/_executor.py +38 -5
  27. schemathesis/engine/phases/stateful/context.py +2 -2
  28. schemathesis/engine/phases/unit/_executor.py +36 -7
  29. schemathesis/generation/__init__.py +0 -3
  30. schemathesis/generation/case.py +153 -28
  31. schemathesis/generation/coverage.py +1 -1
  32. schemathesis/generation/hypothesis/builder.py +43 -19
  33. schemathesis/generation/metrics.py +93 -0
  34. schemathesis/generation/modes.py +0 -8
  35. schemathesis/generation/overrides.py +11 -27
  36. schemathesis/generation/stateful/__init__.py +17 -0
  37. schemathesis/generation/stateful/state_machine.py +32 -108
  38. schemathesis/graphql/loaders.py +152 -8
  39. schemathesis/hooks.py +63 -39
  40. schemathesis/openapi/checks.py +82 -20
  41. schemathesis/openapi/generation/filters.py +9 -2
  42. schemathesis/openapi/loaders.py +134 -8
  43. schemathesis/pytest/lazy.py +4 -31
  44. schemathesis/pytest/loaders.py +24 -0
  45. schemathesis/pytest/plugin.py +38 -6
  46. schemathesis/schemas.py +161 -94
  47. schemathesis/specs/graphql/scalars.py +37 -3
  48. schemathesis/specs/graphql/schemas.py +18 -9
  49. schemathesis/specs/openapi/_hypothesis.py +53 -34
  50. schemathesis/specs/openapi/checks.py +111 -47
  51. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  52. schemathesis/specs/openapi/formats.py +30 -3
  53. schemathesis/specs/openapi/media_types.py +44 -1
  54. schemathesis/specs/openapi/negative/__init__.py +5 -3
  55. schemathesis/specs/openapi/negative/mutations.py +2 -2
  56. schemathesis/specs/openapi/parameters.py +0 -3
  57. schemathesis/specs/openapi/schemas.py +14 -93
  58. schemathesis/specs/openapi/stateful/__init__.py +2 -1
  59. schemathesis/specs/openapi/stateful/links.py +1 -63
  60. schemathesis/transport/__init__.py +54 -16
  61. schemathesis/transport/prepare.py +31 -7
  62. schemathesis/transport/requests.py +21 -9
  63. schemathesis/transport/serialization.py +0 -4
  64. schemathesis/transport/wsgi.py +15 -8
  65. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
  66. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
  67. schemathesis/contrib/__init__.py +0 -9
  68. schemathesis/contrib/openapi/__init__.py +0 -9
  69. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  70. schemathesis/generation/targets.py +0 -69
  71. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
  72. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
  73. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -7,9 +7,11 @@ from typing import Any, Callable, Dict, Iterable, Optional, Union, cast
7
7
  from urllib.parse import quote_plus
8
8
  from weakref import WeakKeyDictionary
9
9
 
10
+ import jsonschema.protocols
10
11
  from hypothesis import event, note, reject
11
12
  from hypothesis import strategies as st
12
13
  from hypothesis_jsonschema import from_schema
14
+ from requests.structures import CaseInsensitiveDict
13
15
 
14
16
  from schemathesis.config import GenerationConfig
15
17
  from schemathesis.core import NOT_SET, NotSet, media_types
@@ -42,7 +44,9 @@ from .parameters import OpenAPIBody, OpenAPIParameter, parameters_to_json_schema
42
44
  from .utils import is_header_location
43
45
 
44
46
  SLASH = "/"
45
- StrategyFactory = Callable[[Dict[str, Any], str, str, Optional[str], GenerationConfig], st.SearchStrategy]
47
+ StrategyFactory = Callable[
48
+ [Dict[str, Any], str, str, Optional[str], GenerationConfig, type[jsonschema.protocols.Validator]], st.SearchStrategy
49
+ ]
46
50
 
47
51
 
48
52
  @st.composite # type: ignore
@@ -52,7 +56,7 @@ def openapi_cases(
52
56
  operation: APIOperation,
53
57
  hooks: HookDispatcher | None = None,
54
58
  auth_storage: auths.AuthStorage | None = None,
55
- generation_mode: GenerationMode = GenerationMode.default(),
59
+ generation_mode: GenerationMode = GenerationMode.POSITIVE,
56
60
  path_parameters: NotSet | dict[str, Any] = NOT_SET,
57
61
  headers: NotSet | dict[str, Any] = NOT_SET,
58
62
  cookies: NotSet | dict[str, Any] = NOT_SET,
@@ -80,18 +84,14 @@ def openapi_cases(
80
84
  phase_name = "stateful" if __is_stateful_phase else phase.value
81
85
  generation_config = operation.schema.config.generation_for(operation=operation, phase=phase_name)
82
86
 
83
- context = HookContext(operation)
87
+ ctx = HookContext(operation=operation)
84
88
 
85
89
  path_parameters_ = generate_parameter(
86
- "path", path_parameters, operation, draw, context, hooks, generation_mode, generation_config
90
+ "path", path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
87
91
  )
88
- headers_ = generate_parameter(
89
- "header", headers, operation, draw, context, hooks, generation_mode, generation_config
90
- )
91
- cookies_ = generate_parameter(
92
- "cookie", cookies, operation, draw, context, hooks, generation_mode, generation_config
93
- )
94
- query_ = generate_parameter("query", query, operation, draw, context, hooks, generation_mode, generation_config)
92
+ headers_ = generate_parameter("header", headers, operation, draw, ctx, hooks, generation_mode, generation_config)
93
+ cookies_ = generate_parameter("cookie", cookies, operation, draw, ctx, hooks, generation_mode, generation_config)
94
+ query_ = generate_parameter("query", query, operation, draw, ctx, hooks, generation_mode, generation_config)
95
95
 
96
96
  if body is NOT_SET:
97
97
  if operation.body:
@@ -108,7 +108,7 @@ def openapi_cases(
108
108
  candidates = operation.body.items
109
109
  parameter = draw(st.sampled_from(candidates))
110
110
  strategy = _get_body_strategy(parameter, strategy_factory, operation, generation_config)
111
- strategy = apply_hooks(operation, context, hooks, strategy, "body")
111
+ strategy = apply_hooks(operation, ctx, hooks, strategy, "body")
112
112
  # Parameter may have a wildcard media type. In this case, choose any supported one
113
113
  possible_media_types = sorted(
114
114
  operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
@@ -158,12 +158,12 @@ def openapi_cases(
158
158
 
159
159
  instance = operation.Case(
160
160
  media_type=media_type,
161
- path_parameters=path_parameters_.value,
162
- headers=headers_.value,
163
- cookies=cookies_.value,
164
- query=query_.value,
161
+ path_parameters=path_parameters_.value or {},
162
+ headers=headers_.value or CaseInsensitiveDict(),
163
+ cookies=cookies_.value or {},
164
+ query=query_.value or {},
165
165
  body=body_.value,
166
- meta=CaseMetadata(
166
+ _meta=CaseMetadata(
167
167
  generation=GenerationInfo(
168
168
  time=time.monotonic() - start,
169
169
  mode=generation_mode,
@@ -199,6 +199,8 @@ def _get_body_strategy(
199
199
  operation: APIOperation,
200
200
  generation_config: GenerationConfig,
201
201
  ) -> st.SearchStrategy:
202
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
203
+
202
204
  if parameter.media_type in MEDIA_TYPES:
203
205
  return MEDIA_TYPES[parameter.media_type]
204
206
  # The cache key relies on object ids, which means that the parameter should not be mutated
@@ -207,7 +209,10 @@ def _get_body_strategy(
207
209
  return _BODY_STRATEGIES_CACHE[parameter][strategy_factory]
208
210
  schema = parameter.as_json_schema(operation)
209
211
  schema = operation.schema.prepare_schema(schema)
210
- strategy = strategy_factory(schema, operation.label, "body", parameter.media_type, generation_config)
212
+ assert isinstance(operation.schema, BaseOpenAPISchema)
213
+ strategy = strategy_factory(
214
+ schema, operation.label, "body", parameter.media_type, generation_config, operation.schema.validator_cls
215
+ )
211
216
  if not parameter.is_required:
212
217
  strategy |= st.just(NOT_SET)
213
218
  _BODY_STRATEGIES_CACHE.setdefault(parameter, {})[strategy_factory] = strategy
@@ -219,7 +224,7 @@ def get_parameters_value(
219
224
  location: str,
220
225
  draw: Callable,
221
226
  operation: APIOperation,
222
- context: HookContext,
227
+ ctx: HookContext,
223
228
  hooks: HookDispatcher | None,
224
229
  strategy_factory: StrategyFactory,
225
230
  generation_config: GenerationConfig,
@@ -231,10 +236,10 @@ def get_parameters_value(
231
236
  """
232
237
  if isinstance(value, NotSet) or not value:
233
238
  strategy = get_parameters_strategy(operation, strategy_factory, location, generation_config)
234
- strategy = apply_hooks(operation, context, hooks, strategy, location)
239
+ strategy = apply_hooks(operation, ctx, hooks, strategy, location)
235
240
  return draw(strategy)
236
241
  strategy = get_parameters_strategy(operation, strategy_factory, location, generation_config, exclude=value.keys())
237
- strategy = apply_hooks(operation, context, hooks, strategy, location)
242
+ strategy = apply_hooks(operation, ctx, hooks, strategy, location)
238
243
  new = draw(strategy)
239
244
  if new is not None:
240
245
  copied = deepclone(value)
@@ -272,7 +277,7 @@ def generate_parameter(
272
277
  explicit: NotSet | dict[str, Any],
273
278
  operation: APIOperation,
274
279
  draw: Callable,
275
- context: HookContext,
280
+ ctx: HookContext,
276
281
  hooks: HookDispatcher | None,
277
282
  generator: GenerationMode,
278
283
  generation_config: GenerationConfig,
@@ -291,9 +296,7 @@ def generate_parameter(
291
296
  generator = GenerationMode.POSITIVE
292
297
  else:
293
298
  strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generator]
294
- value = get_parameters_value(
295
- explicit, location, draw, operation, context, hooks, strategy_factory, generation_config
296
- )
299
+ value = get_parameters_value(explicit, location, draw, operation, ctx, hooks, strategy_factory, generation_config)
297
300
  used_generator: GenerationMode | None = generator
298
301
  if value == explicit:
299
302
  # When we pass `explicit`, then its parts are excluded from generation of the final value
@@ -343,6 +346,8 @@ def get_parameters_strategy(
343
346
  exclude: Iterable[str] = (),
344
347
  ) -> st.SearchStrategy:
345
348
  """Create a new strategy for the case's component from the API operation parameters."""
349
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
350
+
346
351
  parameters = getattr(operation, LOCATION_TO_CONTAINER[location])
347
352
  if parameters:
348
353
  # The cache key relies on object ids, which means that the parameter should not be mutated
@@ -350,17 +355,28 @@ def get_parameters_strategy(
350
355
  if operation in _PARAMETER_STRATEGIES_CACHE and nested_cache_key in _PARAMETER_STRATEGIES_CACHE[operation]:
351
356
  return _PARAMETER_STRATEGIES_CACHE[operation][nested_cache_key]
352
357
  schema = get_schema_for_location(operation, location, parameters)
353
- for name in exclude:
354
- # Values from `exclude` are not necessarily valid for the schema - they come from user-defined examples
355
- # that may be invalid
356
- schema["properties"].pop(name, None)
357
- with suppress(ValueError):
358
- schema["required"].remove(name)
358
+ if location == "header" and exclude:
359
+ # Remove excluded headers case-insensitively
360
+ exclude_lower = {name.lower() for name in exclude}
361
+ schema["properties"] = {
362
+ key: value for key, value in schema["properties"].items() if key.lower() not in exclude_lower
363
+ }
364
+ if "required" in schema:
365
+ schema["required"] = [key for key in schema["required"] if key.lower() not in exclude_lower]
366
+ elif exclude:
367
+ # Non-header locations: remove by exact name
368
+ for name in exclude:
369
+ schema["properties"].pop(name, None)
370
+ with suppress(ValueError):
371
+ schema["required"].remove(name)
359
372
  if not schema["properties"] and strategy_factory is make_negative_strategy:
360
373
  # Nothing to negate - all properties were excluded
361
374
  strategy = st.none()
362
375
  else:
363
- strategy = strategy_factory(schema, operation.label, location, None, generation_config)
376
+ assert isinstance(operation.schema, BaseOpenAPISchema)
377
+ strategy = strategy_factory(
378
+ schema, operation.label, location, None, generation_config, operation.schema.validator_cls
379
+ )
364
380
  serialize = operation.get_parameter_serializer(location)
365
381
  if serialize is not None:
366
382
  strategy = strategy.map(serialize)
@@ -424,6 +440,7 @@ def make_positive_strategy(
424
440
  location: str,
425
441
  media_type: str | None,
426
442
  generation_config: GenerationConfig,
443
+ validator_cls: type[jsonschema.protocols.Validator],
427
444
  custom_formats: dict[str, st.SearchStrategy] | None = None,
428
445
  ) -> st.SearchStrategy:
429
446
  """Strategy for generating values that fit the schema."""
@@ -454,6 +471,7 @@ def make_negative_strategy(
454
471
  location: str,
455
472
  media_type: str | None,
456
473
  generation_config: GenerationConfig,
474
+ validator_cls: type[jsonschema.protocols.Validator],
457
475
  custom_formats: dict[str, st.SearchStrategy] | None = None,
458
476
  ) -> st.SearchStrategy:
459
477
  custom_formats = _build_custom_formats(custom_formats, generation_config)
@@ -464,6 +482,7 @@ def make_negative_strategy(
464
482
  media_type=media_type,
465
483
  custom_formats=custom_formats,
466
484
  generation_config=generation_config,
485
+ validator_cls=validator_cls,
467
486
  )
468
487
 
469
488
 
@@ -494,11 +513,11 @@ def quote_all(parameters: dict[str, Any]) -> dict[str, Any]:
494
513
 
495
514
  def apply_hooks(
496
515
  operation: APIOperation,
497
- context: HookContext,
516
+ ctx: HookContext,
498
517
  hooks: HookDispatcher | None,
499
518
  strategy: st.SearchStrategy,
500
519
  location: str,
501
520
  ) -> st.SearchStrategy:
502
521
  """Apply all hooks related to the given location."""
503
522
  container = LOCATION_TO_CONTAINER[location]
504
- return apply_to_all_dispatchers(operation, context, hooks, strategy, container)
523
+ return apply_to_all_dispatchers(operation, ctx, hooks, strategy, container)
@@ -21,10 +21,12 @@ from schemathesis.openapi.checks import (
21
21
  JsonSchemaError,
22
22
  MalformedMediaType,
23
23
  MissingContentType,
24
+ MissingHeaderNotRejected,
24
25
  MissingHeaders,
25
26
  RejectedPositiveData,
26
27
  UndefinedContentType,
27
28
  UndefinedStatusCode,
29
+ UnsupportedMethodResponse,
28
30
  UseAfterFree,
29
31
  )
30
32
  from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
@@ -33,9 +35,7 @@ from schemathesis.transport.prepare import prepare_path
33
35
  from .utils import expand_status_code, expand_status_codes
34
36
 
35
37
  if TYPE_CHECKING:
36
- from requests import PreparedRequest
37
-
38
- from ...schemas import APIOperation
38
+ from schemathesis.schemas import APIOperation
39
39
 
40
40
 
41
41
  def is_unexpected_http_status_case(case: Case) -> bool:
@@ -289,8 +289,14 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
289
289
  config = ctx.config.missing_required_header
290
290
  expected_statuses = expand_status_codes(config.expected_statuses or [])
291
291
  if response.status_code not in expected_statuses:
292
- allowed = f"Allowed statuses: {', '.join(map(str, expected_statuses))}"
293
- raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
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
+ )
294
300
  return None
295
301
 
296
302
 
@@ -302,13 +308,24 @@ def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> boo
302
308
  data = meta.phase.data
303
309
  if data.description and data.description.startswith("Unspecified HTTP method:"):
304
310
  if response.status_code != 405:
305
- raise AssertionError(
306
- 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)",
307
317
  )
308
318
 
309
319
  allow_header = response.headers.get("allow")
310
320
  if not allow_header:
311
- 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
+ )
312
329
  return None
313
330
 
314
331
 
@@ -352,12 +369,12 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
352
369
  if response.status_code == 404 or response.status_code >= 500:
353
370
  return None
354
371
 
355
- for related_case in ctx.find_related(case_id=case.id):
356
- parent = ctx.find_parent(case_id=related_case.id)
372
+ for related_case in ctx._find_related(case_id=case.id):
373
+ parent = ctx._find_parent(case_id=related_case.id)
357
374
  if not parent:
358
375
  continue
359
376
 
360
- parent_response = ctx.find_response(case_id=parent.id)
377
+ parent_response = ctx._find_response(case_id=parent.id)
361
378
 
362
379
  if (
363
380
  related_case.operation.method.lower() == "delete"
@@ -395,10 +412,10 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
395
412
  if not (400 <= response.status_code < 500):
396
413
  return None
397
414
 
398
- parent = ctx.find_parent(case_id=case.id)
415
+ parent = ctx._find_parent(case_id=case.id)
399
416
  if parent is None:
400
417
  return None
401
- parent_response = ctx.find_response(case_id=parent.id)
418
+ parent_response = ctx._find_response(case_id=parent.id)
402
419
  if parent_response is None:
403
420
  return None
404
421
 
@@ -424,8 +441,8 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
424
441
  return None
425
442
 
426
443
  # Look for any successful DELETE operations on this resource
427
- for related_case in ctx.find_related(case_id=case.id):
428
- related_response = ctx.find_response(case_id=related_case.id)
444
+ for related_case in ctx._find_related(case_id=case.id):
445
+ related_response = ctx._find_response(case_id=related_case.id)
429
446
  if (
430
447
  related_case.operation.method.upper() == "DELETE"
431
448
  and related_response is not None
@@ -458,6 +475,12 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
458
475
  )
459
476
 
460
477
 
478
+ class AuthScenario(str, enum.Enum):
479
+ NO_AUTH = "no_auth"
480
+ INVALID_AUTH = "invalid_auth"
481
+ GENERATED_AUTH = "generated_auth"
482
+
483
+
461
484
  class AuthKind(str, enum.Enum):
462
485
  EXPLICIT = "explicit"
463
486
  GENERATED = "generated"
@@ -473,47 +496,68 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
473
496
  security_parameters = _get_security_parameters(case.operation)
474
497
  # Authentication is required for this API operation and response is successful
475
498
  if security_parameters and 200 <= response.status_code < 300:
476
- auth = _contains_auth(ctx, case, response.request, security_parameters)
499
+ auth = _contains_auth(ctx, case, response, security_parameters)
477
500
  if auth == AuthKind.EXPLICIT:
478
501
  # Auth is explicitly set, it is expected to be valid
479
502
  # Check if invalid auth will give an error
480
503
  no_auth_case = remove_auth(case, security_parameters)
481
- kwargs = ctx.transport_kwargs or {}
504
+ kwargs = ctx._transport_kwargs or {}
482
505
  kwargs.copy()
483
- if "headers" in kwargs:
484
- headers = kwargs["headers"].copy()
485
- _remove_auth_from_explicit_headers(headers, security_parameters)
486
- 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
487
515
  kwargs.pop("session", None)
488
- ctx.record_case(parent_id=case.id, case=no_auth_case)
516
+ ctx._record_case(parent_id=case.id, case=no_auth_case)
489
517
  no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
490
- ctx.record_response(case_id=no_auth_case.id, response=no_auth_response)
518
+ ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
491
519
  if no_auth_response.status_code != 401:
492
- _raise_no_auth_error(no_auth_response, no_auth_case, "that requires authentication")
520
+ _raise_no_auth_error(no_auth_response, no_auth_case, AuthScenario.NO_AUTH)
493
521
  # Try to set invalid auth and check if it succeeds
494
522
  for parameter in security_parameters:
495
523
  invalid_auth_case = remove_auth(case, security_parameters)
496
524
  _set_auth_for_case(invalid_auth_case, parameter)
497
- ctx.record_case(parent_id=case.id, case=invalid_auth_case)
525
+ ctx._record_case(parent_id=case.id, case=invalid_auth_case)
498
526
  invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
499
- ctx.record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
527
+ ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
500
528
  if invalid_auth_response.status_code != 401:
501
- _raise_no_auth_error(invalid_auth_response, invalid_auth_case, "with any auth")
529
+ _raise_no_auth_error(invalid_auth_response, invalid_auth_case, AuthScenario.INVALID_AUTH)
502
530
  elif auth == AuthKind.GENERATED:
503
531
  # If this auth is generated which means it is likely invalid, then
504
532
  # this request should have been an error
505
- _raise_no_auth_error(response, case, "with invalid auth")
533
+ _raise_no_auth_error(response, case, AuthScenario.GENERATED_AUTH)
506
534
  else:
507
535
  # Successful response when there is no auth
508
- _raise_no_auth_error(response, case, "that requires authentication")
536
+ _raise_no_auth_error(response, case, AuthScenario.NO_AUTH)
509
537
  return None
510
538
 
511
539
 
512
- def _raise_no_auth_error(response: Response, case: Case, suffix: str) -> NoReturn:
540
+ def _raise_no_auth_error(response: Response, case: Case, auth: AuthScenario) -> NoReturn:
513
541
  reason = http.client.responses.get(response.status_code, "Unknown")
542
+
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
+
514
557
  raise IgnoredAuth(
515
558
  operation=case.operation.label,
516
- message=f"The API returned `{response.status_code} {reason}` for `{case.operation.label}` {suffix}.",
559
+ message=message,
560
+ title=title,
517
561
  case_id=case.id,
518
562
  )
519
563
 
@@ -534,14 +578,15 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
534
578
 
535
579
 
536
580
  def _contains_auth(
537
- ctx: CheckContext, case: Case, request: PreparedRequest, security_parameters: list[SecurityParameter]
581
+ ctx: CheckContext, case: Case, response: Response, security_parameters: list[SecurityParameter]
538
582
  ) -> AuthKind | None:
539
583
  """Whether a request has authentication declared in the schema."""
540
584
  from requests.cookies import RequestsCookieJar
541
585
 
542
586
  # If auth comes from explicit `auth` option or a custom auth, it is always explicit
543
- if ctx.auth is not None or case._has_explicit_auth:
587
+ if ctx._auth is not None or case._has_explicit_auth:
544
588
  return AuthKind.EXPLICIT
589
+ request = response.request
545
590
  parsed = urlparse(request.url)
546
591
  query = parse_qs(parsed.query) # type: ignore
547
592
  # Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
@@ -563,19 +608,35 @@ def _contains_auth(
563
608
  for parameter in security_parameters:
564
609
  name = parameter["name"]
565
610
  if has_header(parameter):
566
- 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
+ ):
567
618
  return AuthKind.EXPLICIT
568
619
  return AuthKind.GENERATED
569
620
  if has_cookie(parameter):
570
- if ctx.headers is not None and "Cookie" in ctx.headers:
571
- cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
572
- if name in cookies:
573
- return AuthKind.EXPLICIT
574
- 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
+ ):
575
634
  return AuthKind.EXPLICIT
576
635
  return AuthKind.GENERATED
577
636
  if has_query(parameter):
578
- 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
+ ):
579
640
  return AuthKind.EXPLICIT
580
641
  return AuthKind.GENERATED
581
642
 
@@ -587,9 +648,9 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
587
648
 
588
649
  It mutates `case` in place.
589
650
  """
590
- headers = case.headers.copy() if case.headers else None
591
- query = case.query.copy() if case.query else None
592
- cookies = case.cookies.copy() if case.cookies else None
651
+ headers = case.headers.copy()
652
+ query = case.query.copy()
653
+ cookies = case.cookies.copy()
593
654
  for parameter in security_parameters:
594
655
  name = parameter["name"]
595
656
  if parameter["in"] == "header" and headers:
@@ -602,7 +663,7 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
602
663
  operation=case.operation,
603
664
  method=case.method,
604
665
  path=case.path,
605
- path_parameters=case.path_parameters.copy() if case.path_parameters else None,
666
+ path_parameters=case.path_parameters.copy(),
606
667
  headers=headers,
607
668
  cookies=cookies,
608
669
  query=query,
@@ -612,11 +673,11 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
612
673
  )
613
674
 
614
675
 
615
- 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:
616
677
  for parameter in security_parameters:
617
678
  name = parameter["name"]
618
- if parameter["in"] == "header":
619
- headers.pop(name, None)
679
+ if parameter["in"] == location:
680
+ container.pop(name, None)
620
681
 
621
682
 
622
683
  def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
@@ -628,6 +689,9 @@ def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
628
689
  ):
629
690
  if parameter["in"] == location:
630
691
  container = getattr(case, attr_name, {})
692
+ # Could happen in the negative testing mode
693
+ if not isinstance(container, dict):
694
+ container = {}
631
695
  container[name] = "SCHEMATHESIS-INVALID-VALUE"
632
696
  setattr(case, attr_name, container)
633
697
 
@@ -87,7 +87,7 @@ class NonBodyRequest(Node):
87
87
  extractor: Extractor | None = None
88
88
 
89
89
  def evaluate(self, output: StepOutput) -> str | Unresolvable:
90
- container: dict | CaseInsensitiveDict = {
90
+ container = {
91
91
  "query": output.case.query,
92
92
  "path": output.case.path_parameters,
93
93
  "header": output.case.headers,
@@ -15,10 +15,37 @@ STRING_FORMATS: dict[str, st.SearchStrategy] = {}
15
15
 
16
16
 
17
17
  def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
18
- """Register a new strategy for generating data for specific string "format".
18
+ r"""Register a custom Hypothesis strategy for generating string format data.
19
+
20
+ Args:
21
+ name: String format name that matches the "format" keyword in your API schema
22
+ strategy: Hypothesis strategy to generate values for this format
23
+
24
+ Example:
25
+ ```python
26
+ import schemathesis
27
+ from hypothesis import strategies as st
28
+
29
+ # Register phone number format
30
+ phone_strategy = st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}")
31
+ schemathesis.openapi.format("phone", phone_strategy)
32
+
33
+ # Register email with specific domain
34
+ email_strategy = st.from_regex(r"[a-z]+@company\.com")
35
+ schemathesis.openapi.format("company-email", email_strategy)
36
+ ```
37
+
38
+ Schema usage:
39
+ ```yaml
40
+ properties:
41
+ phone:
42
+ type: string
43
+ format: phone # Uses your phone_strategy
44
+ contact_email:
45
+ type: string
46
+ format: company-email # Uses your email_strategy
47
+ ```
19
48
 
20
- :param str name: Format name. It should correspond the one used in the API schema as the "format" keyword value.
21
- :param strategy: Hypothesis strategy you'd like to use to generate values for this format.
22
49
  """
23
50
  from hypothesis.strategies import SearchStrategy
24
51
 
@@ -15,7 +15,50 @@ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
15
15
 
16
16
 
17
17
  def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
18
- """Register a strategy for the given media type."""
18
+ r"""Register a custom Hypothesis strategy for generating media type content.
19
+
20
+ Args:
21
+ name: Media type name that matches your OpenAPI requestBody content type
22
+ strategy: Hypothesis strategy that generates bytes for this media type
23
+ aliases: Additional media type names that use the same strategy
24
+
25
+ Example:
26
+ ```python
27
+ import schemathesis
28
+ from hypothesis import strategies as st
29
+
30
+ # Register PDF file strategy
31
+ pdf_strategy = st.sampled_from([
32
+ b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF",
33
+ b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
34
+ ])
35
+ schemathesis.openapi.media_type("application/pdf", pdf_strategy)
36
+
37
+ # Dynamic content generation
38
+ @st.composite
39
+ def xml_content(draw):
40
+ tag = draw(st.text(min_size=3, max_size=10))
41
+ content = draw(st.text(min_size=1, max_size=50))
42
+ return f"<?xml version='1.0'?><{tag}>{content}</{tag}>".encode()
43
+
44
+ schemathesis.openapi.media_type("application/xml", xml_content())
45
+ ```
46
+
47
+ Schema usage:
48
+ ```yaml
49
+ requestBody:
50
+ content:
51
+ application/pdf: # Uses your PDF strategy
52
+ schema:
53
+ type: string
54
+ format: binary
55
+ application/xml: # Uses your XML strategy
56
+ schema:
57
+ type: string
58
+ format: binary
59
+ ```
60
+
61
+ """
19
62
 
20
63
  @REQUESTS_TRANSPORT.serializer(name, *aliases)
21
64
  @ASGI_TRANSPORT.serializer(name, *aliases)