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.
- schemathesis/__init__.py +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {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[
|
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.
|
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
|
-
|
87
|
+
ctx = HookContext(operation=operation)
|
84
88
|
|
85
89
|
path_parameters_ = generate_parameter(
|
86
|
-
"path", path_parameters, operation, draw,
|
90
|
+
"path", path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
|
87
91
|
)
|
88
|
-
headers_ = generate_parameter(
|
89
|
-
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
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,
|
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,
|
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
|
-
|
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
|
-
|
354
|
-
#
|
355
|
-
|
356
|
-
schema["properties"]
|
357
|
-
|
358
|
-
|
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
|
-
|
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
|
-
|
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,
|
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
|
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 =
|
293
|
-
raise
|
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
|
306
|
-
|
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
|
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.
|
356
|
-
parent = ctx.
|
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.
|
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.
|
415
|
+
parent = ctx._find_parent(case_id=case.id)
|
399
416
|
if parent is None:
|
400
417
|
return None
|
401
|
-
parent_response = ctx.
|
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.
|
428
|
-
related_response = ctx.
|
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
|
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.
|
504
|
+
kwargs = ctx._transport_kwargs or {}
|
482
505
|
kwargs.copy()
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
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.
|
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.
|
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,
|
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.
|
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.
|
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,
|
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,
|
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,
|
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,
|
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=
|
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,
|
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.
|
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 (
|
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
|
-
|
571
|
-
|
572
|
-
if
|
573
|
-
|
574
|
-
|
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.
|
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()
|
591
|
-
query = case.query.copy()
|
592
|
-
cookies = case.cookies.copy()
|
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()
|
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
|
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"] ==
|
619
|
-
|
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
|
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
|
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
|
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)
|