schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -1,116 +1,782 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import http.client
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from http.cookies import SimpleCookie
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, NoReturn, cast
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
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.parameters import ParameterLocation
|
|
15
|
+
from schemathesis.core.transport import Response
|
|
16
|
+
from schemathesis.generation.case import Case
|
|
17
|
+
from schemathesis.generation.meta import CoveragePhaseData, CoverageScenario
|
|
18
|
+
from schemathesis.openapi.checks import (
|
|
19
|
+
AcceptedNegativeData,
|
|
20
|
+
EnsureResourceAvailability,
|
|
21
|
+
IgnoredAuth,
|
|
22
|
+
JsonSchemaError,
|
|
23
|
+
MalformedMediaType,
|
|
24
|
+
MissingContentType,
|
|
25
|
+
MissingHeaderNotRejected,
|
|
26
|
+
MissingHeaders,
|
|
27
|
+
RejectedPositiveData,
|
|
28
|
+
UndefinedContentType,
|
|
29
|
+
UndefinedStatusCode,
|
|
30
|
+
UnsupportedMethodResponse,
|
|
31
|
+
UseAfterFree,
|
|
10
32
|
)
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
from .utils import expand_status_code
|
|
33
|
+
from schemathesis.transport.prepare import prepare_path
|
|
34
|
+
|
|
35
|
+
from .utils import expand_status_code, expand_status_codes
|
|
14
36
|
|
|
15
37
|
if TYPE_CHECKING:
|
|
16
|
-
from
|
|
38
|
+
from schemathesis.schemas import APIOperation
|
|
39
|
+
from schemathesis.specs.openapi.adapter.parameters import OpenApiParameterSet
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_unexpected_http_status_case(case: Case) -> bool:
|
|
43
|
+
# Skip checks for requests using HTTP methods not defined in the API spec
|
|
44
|
+
return bool(
|
|
45
|
+
case.meta
|
|
46
|
+
and isinstance(case.meta.phase.data, CoveragePhaseData)
|
|
47
|
+
and case.meta.phase.data.scenario == CoverageScenario.UNSPECIFIED_HTTP_METHOD
|
|
48
|
+
)
|
|
17
49
|
|
|
18
50
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
51
|
+
@schemathesis.check
|
|
52
|
+
def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
53
|
+
from .schemas import BaseOpenAPISchema
|
|
54
|
+
|
|
55
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
56
|
+
return True
|
|
57
|
+
status_codes = case.operation.responses.status_codes
|
|
23
58
|
# "default" can be used as the default response object for all HTTP codes that are not covered individually
|
|
24
|
-
if "default" in
|
|
59
|
+
if "default" in status_codes:
|
|
25
60
|
return None
|
|
26
|
-
allowed_status_codes = list(
|
|
61
|
+
allowed_status_codes = list(_expand_status_codes(status_codes))
|
|
27
62
|
if response.status_code not in allowed_status_codes:
|
|
28
|
-
defined_status_codes = list(map(str,
|
|
63
|
+
defined_status_codes = list(map(str, status_codes))
|
|
29
64
|
responses_list = ", ".join(defined_status_codes)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
message,
|
|
37
|
-
context=failures.UndefinedStatusCode(
|
|
38
|
-
status_code=response.status_code,
|
|
39
|
-
defined_status_codes=defined_status_codes,
|
|
40
|
-
allowed_status_codes=allowed_status_codes,
|
|
41
|
-
),
|
|
65
|
+
raise UndefinedStatusCode(
|
|
66
|
+
operation=case.operation.label,
|
|
67
|
+
status_code=response.status_code,
|
|
68
|
+
defined_status_codes=defined_status_codes,
|
|
69
|
+
allowed_status_codes=allowed_status_codes,
|
|
70
|
+
message=f"Received: {response.status_code}\nDocumented: {responses_list}",
|
|
42
71
|
)
|
|
43
72
|
return None # explicitly return None for mypy
|
|
44
73
|
|
|
45
74
|
|
|
46
|
-
def
|
|
75
|
+
def _expand_status_codes(responses: tuple[str, ...]) -> Iterator[int]:
|
|
47
76
|
for code in responses:
|
|
48
77
|
yield from expand_status_code(code)
|
|
49
78
|
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if not
|
|
80
|
+
@schemathesis.check
|
|
81
|
+
def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
82
|
+
from .schemas import BaseOpenAPISchema
|
|
83
|
+
|
|
84
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
85
|
+
return True
|
|
86
|
+
documented_content_types = case.operation.schema.get_content_types(case.operation, response)
|
|
87
|
+
if not documented_content_types:
|
|
56
88
|
return None
|
|
57
|
-
|
|
58
|
-
if not
|
|
59
|
-
|
|
60
|
-
raise
|
|
61
|
-
|
|
62
|
-
f"
|
|
63
|
-
|
|
89
|
+
content_types = response.headers.get("content-type")
|
|
90
|
+
if not content_types:
|
|
91
|
+
all_media_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
|
|
92
|
+
raise MissingContentType(
|
|
93
|
+
operation=case.operation.label,
|
|
94
|
+
message=f"The following media types are documented in the schema:{''.join(all_media_types)}",
|
|
95
|
+
media_types=documented_content_types,
|
|
64
96
|
)
|
|
65
|
-
|
|
97
|
+
content_type = content_types[0]
|
|
98
|
+
for option in documented_content_types:
|
|
66
99
|
try:
|
|
67
|
-
expected_main, expected_sub =
|
|
68
|
-
except ValueError
|
|
69
|
-
_reraise_malformed_media_type(
|
|
100
|
+
expected_main, expected_sub = media_types.parse(option)
|
|
101
|
+
except ValueError:
|
|
102
|
+
_reraise_malformed_media_type(case, "Schema", option, option)
|
|
70
103
|
try:
|
|
71
|
-
received_main, received_sub =
|
|
72
|
-
except ValueError
|
|
73
|
-
_reraise_malformed_media_type(
|
|
74
|
-
if (
|
|
104
|
+
received_main, received_sub = media_types.parse(content_type)
|
|
105
|
+
except ValueError:
|
|
106
|
+
_reraise_malformed_media_type(case, "Response", content_type, option)
|
|
107
|
+
if (
|
|
108
|
+
(expected_main == "*" and expected_sub == "*")
|
|
109
|
+
or (expected_main == received_main and expected_sub == "*")
|
|
110
|
+
or (expected_main == "*" and expected_sub == received_sub)
|
|
111
|
+
or (expected_main == received_main and expected_sub == received_sub)
|
|
112
|
+
):
|
|
75
113
|
return None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
f"Received
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
context=failures.UndefinedContentType(content_type=content_type, defined_content_types=defined_content_types),
|
|
114
|
+
raise UndefinedContentType(
|
|
115
|
+
operation=case.operation.label,
|
|
116
|
+
message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
|
|
117
|
+
content_type=content_type,
|
|
118
|
+
defined_content_types=documented_content_types,
|
|
82
119
|
)
|
|
83
120
|
|
|
84
121
|
|
|
85
|
-
def _reraise_malformed_media_type(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
f"
|
|
122
|
+
def _reraise_malformed_media_type(case: Case, location: str, actual: str, defined: str) -> NoReturn:
|
|
123
|
+
raise MalformedMediaType(
|
|
124
|
+
operation=case.operation.label,
|
|
125
|
+
message=f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}",
|
|
126
|
+
actual=actual,
|
|
127
|
+
defined=defined,
|
|
89
128
|
)
|
|
90
|
-
raise get_malformed_media_type_error(message)(
|
|
91
|
-
message, context=failures.MalformedMediaType(actual=actual, defined=defined)
|
|
92
|
-
) from exc
|
|
93
129
|
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
131
|
+
@schemathesis.check
|
|
132
|
+
def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
133
|
+
import jsonschema
|
|
134
|
+
|
|
135
|
+
from .schemas import BaseOpenAPISchema, _maybe_raise_one_or_more
|
|
136
|
+
|
|
137
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Find the matching response definition
|
|
141
|
+
response_definition = case.operation.responses.find_by_status_code(response.status_code)
|
|
142
|
+
if response_definition is None:
|
|
143
|
+
return None
|
|
144
|
+
# Check whether the matching response definition has headers defined
|
|
145
|
+
headers = response_definition.headers
|
|
146
|
+
if not headers:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
errors: list[Failure] = []
|
|
150
|
+
|
|
151
|
+
missing_headers = []
|
|
152
|
+
|
|
153
|
+
for name, header in headers.items():
|
|
154
|
+
values = response.headers.get(name.lower())
|
|
155
|
+
if values is not None:
|
|
156
|
+
value = values[0]
|
|
157
|
+
coerced = _coerce_header_value(value, header.schema)
|
|
158
|
+
try:
|
|
159
|
+
header.validator.validate(coerced)
|
|
160
|
+
except jsonschema.ValidationError as exc:
|
|
161
|
+
errors.append(
|
|
162
|
+
JsonSchemaError.from_exception(
|
|
163
|
+
title="Response header does not conform to the schema",
|
|
164
|
+
operation=case.operation.label,
|
|
165
|
+
exc=exc,
|
|
166
|
+
config=case.operation.schema.config.output,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
elif header.is_required:
|
|
170
|
+
missing_headers.append(name)
|
|
171
|
+
|
|
172
|
+
if missing_headers:
|
|
173
|
+
formatted_headers = [f"\n- `{header}`" for header in missing_headers]
|
|
174
|
+
message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
|
|
175
|
+
errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
|
|
176
|
+
|
|
177
|
+
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | float | None | bool:
|
|
181
|
+
schema_type = schema.get("type")
|
|
182
|
+
|
|
183
|
+
if schema_type == "string":
|
|
184
|
+
return value
|
|
185
|
+
if schema_type == "integer":
|
|
186
|
+
try:
|
|
187
|
+
return int(value)
|
|
188
|
+
except ValueError:
|
|
189
|
+
return value
|
|
190
|
+
if schema_type == "number":
|
|
191
|
+
try:
|
|
192
|
+
return float(value)
|
|
193
|
+
except ValueError:
|
|
194
|
+
return value
|
|
195
|
+
if schema_type == "null" and value.lower() == "null":
|
|
196
|
+
return None
|
|
197
|
+
if schema_type == "boolean":
|
|
198
|
+
return string_to_boolean(value)
|
|
199
|
+
return value
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@schemathesis.check
|
|
203
|
+
def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
204
|
+
from .schemas import BaseOpenAPISchema
|
|
205
|
+
|
|
206
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
207
|
+
return True
|
|
208
|
+
return case.operation.validate_response(response, case=case)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@schemathesis.check
|
|
212
|
+
def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
213
|
+
from .schemas import BaseOpenAPISchema
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
not isinstance(case.operation.schema, BaseOpenAPISchema)
|
|
217
|
+
or case.meta is None
|
|
218
|
+
or is_unexpected_http_status_case(case)
|
|
219
|
+
):
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
config = ctx.config.negative_data_rejection
|
|
223
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
case.meta.generation.mode.is_negative
|
|
227
|
+
and response.status_code not in allowed_statuses
|
|
228
|
+
and not has_only_additional_properties_in_non_body_parameters(case)
|
|
229
|
+
):
|
|
230
|
+
extra_info = ""
|
|
231
|
+
phase = case.meta.phase
|
|
232
|
+
if phase.data.description:
|
|
233
|
+
parts: list[str] = []
|
|
234
|
+
# Special case: CoveragePhaseData descriptions for "Missing" scenarios are already complete
|
|
235
|
+
if isinstance(phase.data, CoveragePhaseData) and phase.data.scenario in (
|
|
236
|
+
CoverageScenario.MISSING_PARAMETER,
|
|
237
|
+
CoverageScenario.OBJECT_MISSING_REQUIRED_PROPERTY,
|
|
238
|
+
):
|
|
239
|
+
extra_info = f"\nInvalid component: {phase.data.description}"
|
|
240
|
+
else:
|
|
241
|
+
# Build structured message: parameter `name` in location - description
|
|
242
|
+
# For body, don't show parameter name (it's the media type, not useful)
|
|
243
|
+
location = phase.data.parameter_location
|
|
244
|
+
if phase.data.parameter and location != ParameterLocation.BODY:
|
|
245
|
+
parts.append(f"parameter `{phase.data.parameter}`")
|
|
246
|
+
if location:
|
|
247
|
+
parts.append(f"in {location.name.lower()}")
|
|
248
|
+
# Lowercase first letter of description for consistency
|
|
249
|
+
description = phase.data.description
|
|
250
|
+
if description:
|
|
251
|
+
description = description[0].lower() + description[1:] if len(description) > 0 else description
|
|
252
|
+
if parts:
|
|
253
|
+
parts.append(f"- {description}")
|
|
254
|
+
else:
|
|
255
|
+
parts.append(description)
|
|
256
|
+
extra_info = "\nInvalid component: " + " ".join(parts)
|
|
257
|
+
raise AcceptedNegativeData(
|
|
258
|
+
operation=case.operation.label,
|
|
259
|
+
message=f"Invalid data should have been rejected\nExpected: {', '.join(config.expected_statuses)}{extra_info}",
|
|
260
|
+
status_code=response.status_code,
|
|
261
|
+
expected_statuses=config.expected_statuses,
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@schemathesis.check
|
|
267
|
+
def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
268
|
+
from .schemas import BaseOpenAPISchema
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
not isinstance(case.operation.schema, BaseOpenAPISchema)
|
|
272
|
+
or case.meta is None
|
|
273
|
+
or is_unexpected_http_status_case(case)
|
|
274
|
+
):
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
config = ctx.config.positive_data_acceptance
|
|
278
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
|
279
|
+
|
|
280
|
+
if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
|
|
281
|
+
raise RejectedPositiveData(
|
|
282
|
+
operation=case.operation.label,
|
|
283
|
+
message=f"Valid data should have been accepted\nExpected: {', '.join(config.expected_statuses)}",
|
|
284
|
+
status_code=response.status_code,
|
|
285
|
+
allowed_statuses=config.expected_statuses,
|
|
286
|
+
)
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@schemathesis.check
|
|
291
|
+
def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
292
|
+
meta = case.meta
|
|
293
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
|
|
294
|
+
return None
|
|
295
|
+
data = meta.phase.data
|
|
296
|
+
if (
|
|
297
|
+
data.parameter
|
|
298
|
+
and data.parameter_location == ParameterLocation.HEADER
|
|
299
|
+
and data.scenario == CoverageScenario.MISSING_PARAMETER
|
|
300
|
+
):
|
|
301
|
+
if data.parameter.lower() == "authorization":
|
|
302
|
+
expected_statuses = {401}
|
|
303
|
+
else:
|
|
304
|
+
config = ctx.config.missing_required_header
|
|
305
|
+
expected_statuses = expand_status_codes(config.expected_statuses or [])
|
|
306
|
+
if response.status_code not in expected_statuses:
|
|
307
|
+
allowed = ", ".join(map(str, expected_statuses))
|
|
308
|
+
raise MissingHeaderNotRejected(
|
|
309
|
+
operation=f"{case.method} {case.path}",
|
|
310
|
+
header_name=data.parameter,
|
|
311
|
+
status_code=response.status_code,
|
|
312
|
+
expected_statuses=list(expected_statuses),
|
|
313
|
+
message=f"Missing header not rejected (got {response.status_code}, expected {allowed})",
|
|
314
|
+
)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@schemathesis.check
|
|
319
|
+
def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
320
|
+
meta = case.meta
|
|
321
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
|
|
322
|
+
return None
|
|
323
|
+
data = meta.phase.data
|
|
324
|
+
if data.scenario == CoverageScenario.UNSPECIFIED_HTTP_METHOD:
|
|
325
|
+
if response.status_code != 405:
|
|
326
|
+
raise UnsupportedMethodResponse(
|
|
327
|
+
operation=case.operation.label,
|
|
328
|
+
method=cast(str, response.request.method),
|
|
329
|
+
status_code=response.status_code,
|
|
330
|
+
failure_reason="wrong_status",
|
|
331
|
+
message=f"Unsupported method {response.request.method} returned {response.status_code}, expected 405 Method Not Allowed\n\nReturn 405 for methods not listed in the OpenAPI spec",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
allow_header = response.headers.get("allow")
|
|
335
|
+
if not allow_header:
|
|
336
|
+
raise UnsupportedMethodResponse(
|
|
337
|
+
operation=case.operation.label,
|
|
338
|
+
method=cast(str, response.request.method),
|
|
339
|
+
status_code=response.status_code,
|
|
340
|
+
allow_header_present=False,
|
|
341
|
+
failure_reason="missing_allow_header",
|
|
342
|
+
message=f"{response.request.method} returned 405 without required `Allow` header\n\nAdd `Allow` header listing supported methods (required by RFC 9110)",
|
|
343
|
+
)
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
348
|
+
# Check if the case contains only additional properties in query, headers, or cookies.
|
|
349
|
+
# This function is used to determine if negation is solely in the form of extra properties,
|
|
350
|
+
# which are often ignored for backward-compatibility by the tested apps
|
|
351
|
+
from .schemas import BaseOpenAPISchema
|
|
352
|
+
|
|
353
|
+
meta = case.meta
|
|
354
|
+
if meta is None or not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
355
|
+
# Ignore manually created cases
|
|
356
|
+
return False
|
|
357
|
+
if (ParameterLocation.BODY in meta.components and meta.components[ParameterLocation.BODY].mode.is_negative) or (
|
|
358
|
+
ParameterLocation.PATH in meta.components and meta.components[ParameterLocation.PATH].mode.is_negative
|
|
359
|
+
):
|
|
360
|
+
# Body or path negations always imply other negations
|
|
361
|
+
return False
|
|
362
|
+
validator_cls = case.operation.schema.adapter.jsonschema_validator_cls
|
|
363
|
+
for location in (ParameterLocation.QUERY, ParameterLocation.HEADER, ParameterLocation.COOKIE):
|
|
364
|
+
meta_for_location = meta.components.get(location)
|
|
365
|
+
value = getattr(case, location.container_name)
|
|
366
|
+
if value is not None and meta_for_location is not None and meta_for_location.mode.is_negative:
|
|
367
|
+
container = getattr(case.operation, location.container_name)
|
|
368
|
+
schema = container.schema
|
|
369
|
+
|
|
370
|
+
if _has_serialization_sensitive_types(schema, container):
|
|
371
|
+
# Can't reliably determine if only additional properties were added
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
value_without_additional_properties = {k: v for k, v in value.items() if k in container}
|
|
375
|
+
if not validator_cls(schema).is_valid(value_without_additional_properties):
|
|
376
|
+
# Other types of negation found
|
|
377
|
+
return False
|
|
378
|
+
# Only additional properties are added
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _has_serialization_sensitive_types(schema: dict, container: OpenApiParameterSet) -> bool:
|
|
383
|
+
"""Check if schema contains array or object types in defined parameters.
|
|
384
|
+
|
|
385
|
+
In query/header/cookie parameters, arrays and objects are serialized to strings.
|
|
386
|
+
This makes post-serialization validation against the original schema unreliable:
|
|
387
|
+
|
|
388
|
+
- Generated: ["foo", "bar"] (array)
|
|
389
|
+
- Serialized: "foo,bar" (string)
|
|
390
|
+
|
|
391
|
+
Validation of string against array schema fails incorrectly.
|
|
392
|
+
A better approach would be to apply serialization later on in the process.
|
|
393
|
+
|
|
394
|
+
"""
|
|
395
|
+
from schemathesis.core.jsonschema import get_type
|
|
396
|
+
|
|
397
|
+
properties = schema.get("properties", {})
|
|
398
|
+
for prop_name, prop_schema in properties.items():
|
|
399
|
+
if prop_name in container:
|
|
400
|
+
types = get_type(prop_schema)
|
|
401
|
+
if "array" in types or "object" in types:
|
|
402
|
+
return True
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@schemathesis.check
|
|
407
|
+
def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
408
|
+
from .schemas import BaseOpenAPISchema
|
|
409
|
+
|
|
410
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
# Only check for use-after-free on successful responses (2xx) or redirects (3xx)
|
|
414
|
+
# Other status codes indicate request-level issues / server errors, not successful resource access
|
|
415
|
+
if not (200 <= response.status_code < 400):
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
for related_case in ctx._find_related(case_id=case.id):
|
|
419
|
+
parent = ctx._find_parent(case_id=related_case.id)
|
|
420
|
+
if not parent:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
parent_response = ctx._find_response(case_id=parent.id)
|
|
424
|
+
|
|
425
|
+
if (
|
|
426
|
+
related_case.operation.method.lower() == "delete"
|
|
427
|
+
and parent_response is not None
|
|
428
|
+
and 200 <= parent_response.status_code < 300
|
|
429
|
+
):
|
|
430
|
+
if _is_prefix_operation(
|
|
431
|
+
ResourcePath(related_case.path, related_case.path_parameters or {}),
|
|
432
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
|
433
|
+
):
|
|
434
|
+
free = f"{related_case.operation.method.upper()} {prepare_path(related_case.path, related_case.path_parameters)}"
|
|
435
|
+
usage = f"{case.operation.method.upper()} {prepare_path(case.path, case.path_parameters)}"
|
|
436
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
|
437
|
+
raise UseAfterFree(
|
|
438
|
+
operation=related_case.operation.label,
|
|
439
|
+
message=(
|
|
440
|
+
"The API did not return a `HTTP 404 Not Found` response "
|
|
441
|
+
f"(got `HTTP {response.status_code} {reason}`) for a resource that was previously deleted.\n\nThe resource was deleted with `{free}`"
|
|
442
|
+
),
|
|
443
|
+
free=free,
|
|
444
|
+
usage=usage,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@schemathesis.check
|
|
451
|
+
def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
452
|
+
from .schemas import BaseOpenAPISchema
|
|
453
|
+
|
|
454
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
# Only check for 404 (Not Found) responses - other 4XX are not resource availability issues
|
|
458
|
+
# 422 / 400: Validation errors (bad request data)
|
|
459
|
+
# 401 / 403: Auth issues (expired tokens, permissions)
|
|
460
|
+
# 409: Conflict errors
|
|
461
|
+
if response.status_code != 404:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
parent = ctx._find_parent(case_id=case.id)
|
|
465
|
+
if parent is None:
|
|
466
|
+
return None
|
|
467
|
+
parent_response = ctx._find_response(case_id=parent.id)
|
|
468
|
+
if parent_response is None:
|
|
100
469
|
return None
|
|
101
470
|
|
|
102
|
-
|
|
103
|
-
|
|
471
|
+
if not (
|
|
472
|
+
parent.operation.method.upper() == "POST"
|
|
473
|
+
and 200 <= parent_response.status_code < 400
|
|
474
|
+
and _is_prefix_operation(
|
|
475
|
+
ResourcePath(parent.path, parent.path_parameters or {}),
|
|
476
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
|
477
|
+
)
|
|
478
|
+
):
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Check if all parameters come from links
|
|
482
|
+
overrides = case._override
|
|
483
|
+
overrides_all_parameters = True
|
|
484
|
+
for parameter in case.operation.iter_parameters():
|
|
485
|
+
container = parameter.location.container_name
|
|
486
|
+
if parameter.name not in getattr(overrides, container, {}):
|
|
487
|
+
overrides_all_parameters = False
|
|
488
|
+
break
|
|
489
|
+
if not overrides_all_parameters:
|
|
104
490
|
return None
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
491
|
+
|
|
492
|
+
# Look for any successful DELETE operations on this resource
|
|
493
|
+
for related_case in ctx._find_related(case_id=case.id):
|
|
494
|
+
related_response = ctx._find_response(case_id=related_case.id)
|
|
495
|
+
if (
|
|
496
|
+
related_case.operation.method.upper() == "DELETE"
|
|
497
|
+
and related_response is not None
|
|
498
|
+
and 200 <= related_response.status_code < 300
|
|
499
|
+
and _is_prefix_operation(
|
|
500
|
+
ResourcePath(related_case.path, related_case.path_parameters or {}),
|
|
501
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
|
502
|
+
)
|
|
503
|
+
):
|
|
504
|
+
# Resource was properly deleted, 404 is expected
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
# If we got here:
|
|
508
|
+
# 1. Resource was created successfully
|
|
509
|
+
# 2. Current operation returned 4XX
|
|
510
|
+
# 3. All parameters come from links
|
|
511
|
+
# 4. No successful DELETE operations found
|
|
512
|
+
created_with = parent.operation.label
|
|
513
|
+
not_available_with = case.operation.label
|
|
514
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
|
515
|
+
raise EnsureResourceAvailability(
|
|
516
|
+
operation=created_with,
|
|
517
|
+
message=(
|
|
518
|
+
f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
|
|
519
|
+
f"Created with : `{created_with}`\n"
|
|
520
|
+
f"Not available with: `{not_available_with}`"
|
|
521
|
+
),
|
|
522
|
+
created_with=created_with,
|
|
523
|
+
not_available_with=not_available_with,
|
|
110
524
|
)
|
|
111
525
|
|
|
112
526
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
527
|
+
class AuthScenario(str, enum.Enum):
|
|
528
|
+
NO_AUTH = "no_auth"
|
|
529
|
+
INVALID_AUTH = "invalid_auth"
|
|
530
|
+
GENERATED_AUTH = "generated_auth"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class AuthKind(str, enum.Enum):
|
|
534
|
+
EXPLICIT = "explicit"
|
|
535
|
+
GENERATED = "generated"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@schemathesis.check
|
|
539
|
+
def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
|
540
|
+
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
|
541
|
+
from schemathesis.specs.openapi.adapter.security import has_optional_auth
|
|
542
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
543
|
+
|
|
544
|
+
operation = case.operation
|
|
545
|
+
if (
|
|
546
|
+
not isinstance(operation.schema, BaseOpenAPISchema)
|
|
547
|
+
or is_unexpected_http_status_case(case)
|
|
548
|
+
or has_optional_auth(operation.schema.raw_schema, operation.definition.raw)
|
|
549
|
+
):
|
|
550
|
+
return True
|
|
551
|
+
security_parameters = _get_security_parameters(case.operation)
|
|
552
|
+
# Authentication is required for this API operation and response is successful
|
|
553
|
+
if security_parameters and 200 <= response.status_code < 300:
|
|
554
|
+
auth = _contains_auth(ctx, case, response, security_parameters)
|
|
555
|
+
if auth == AuthKind.EXPLICIT:
|
|
556
|
+
# Auth is explicitly set, it is expected to be valid
|
|
557
|
+
# Check if invalid auth will give an error
|
|
558
|
+
no_auth_case = remove_auth(case, security_parameters)
|
|
559
|
+
kwargs = ctx._transport_kwargs or {}
|
|
560
|
+
kwargs.copy()
|
|
561
|
+
for location, container_name in (
|
|
562
|
+
("header", "headers"),
|
|
563
|
+
("cookie", "cookies"),
|
|
564
|
+
("query", "query"),
|
|
565
|
+
):
|
|
566
|
+
if container_name in kwargs:
|
|
567
|
+
container = kwargs[container_name].copy()
|
|
568
|
+
_remove_auth_from_container(container, security_parameters, location=location)
|
|
569
|
+
kwargs[container_name] = container
|
|
570
|
+
kwargs.pop("session", None)
|
|
571
|
+
if case.operation.app is not None:
|
|
572
|
+
kwargs.setdefault("app", case.operation.app)
|
|
573
|
+
ctx._record_case(parent_id=case.id, case=no_auth_case)
|
|
574
|
+
no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
|
|
575
|
+
ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
|
|
576
|
+
if no_auth_response.status_code != 401:
|
|
577
|
+
_raise_no_auth_error(no_auth_response, no_auth_case, AuthScenario.NO_AUTH)
|
|
578
|
+
# Try to set invalid auth and check if it succeeds
|
|
579
|
+
for parameter in security_parameters:
|
|
580
|
+
invalid_auth_case = remove_auth(case, security_parameters)
|
|
581
|
+
_set_auth_for_case(invalid_auth_case, parameter)
|
|
582
|
+
ctx._record_case(parent_id=case.id, case=invalid_auth_case)
|
|
583
|
+
invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
|
|
584
|
+
ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
|
|
585
|
+
if invalid_auth_response.status_code != 401:
|
|
586
|
+
_raise_no_auth_error(invalid_auth_response, invalid_auth_case, AuthScenario.INVALID_AUTH)
|
|
587
|
+
elif auth == AuthKind.GENERATED:
|
|
588
|
+
# If this auth is generated which means it is likely invalid, then
|
|
589
|
+
# this request should have been an error
|
|
590
|
+
_raise_no_auth_error(response, case, AuthScenario.GENERATED_AUTH)
|
|
591
|
+
else:
|
|
592
|
+
# Successful response when there is no auth
|
|
593
|
+
_raise_no_auth_error(response, case, AuthScenario.NO_AUTH)
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _raise_no_auth_error(response: Response, case: Case, auth: AuthScenario) -> NoReturn:
|
|
598
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
|
599
|
+
|
|
600
|
+
if auth == AuthScenario.NO_AUTH:
|
|
601
|
+
title = "API accepts requests without authentication"
|
|
602
|
+
detail = None
|
|
603
|
+
elif auth == AuthScenario.INVALID_AUTH:
|
|
604
|
+
title = "API accepts invalid authentication"
|
|
605
|
+
detail = "invalid credentials provided"
|
|
606
|
+
else:
|
|
607
|
+
title = "API accepts invalid authentication"
|
|
608
|
+
detail = "generated auth likely invalid"
|
|
609
|
+
|
|
610
|
+
message = f"Expected 401, got `{response.status_code} {reason}` for `{case.operation.label}`"
|
|
611
|
+
if detail is not None:
|
|
612
|
+
message = f"{message} ({detail})"
|
|
613
|
+
|
|
614
|
+
raise IgnoredAuth(
|
|
615
|
+
operation=case.operation.label,
|
|
616
|
+
message=message,
|
|
617
|
+
title=title,
|
|
618
|
+
case_id=case.id,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _get_security_parameters(operation: APIOperation) -> list[Mapping[str, Any]]:
|
|
623
|
+
"""Extract security definitions that are active for the given operation and convert them into parameters."""
|
|
624
|
+
from schemathesis.specs.openapi.adapter.security import ORIGINAL_SECURITY_TYPE_KEY
|
|
625
|
+
|
|
626
|
+
return [
|
|
627
|
+
param
|
|
628
|
+
for param in operation.security.iter_parameters()
|
|
629
|
+
if param[ORIGINAL_SECURITY_TYPE_KEY] in ["apiKey", "basic", "http"]
|
|
630
|
+
]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _contains_auth(
|
|
634
|
+
ctx: CheckContext, case: Case, response: Response, security_parameters: list[Mapping[str, Any]]
|
|
635
|
+
) -> AuthKind | None:
|
|
636
|
+
"""Whether a request has authentication declared in the schema."""
|
|
637
|
+
from requests.cookies import RequestsCookieJar
|
|
638
|
+
|
|
639
|
+
# If auth comes from explicit `auth` option or a custom auth, it is always explicit
|
|
640
|
+
if ctx._auth is not None or case._has_explicit_auth:
|
|
641
|
+
return AuthKind.EXPLICIT
|
|
642
|
+
request = response.request
|
|
643
|
+
parsed = urlparse(request.url)
|
|
644
|
+
query = parse_qs(parsed.query) # type: ignore[type-var]
|
|
645
|
+
# Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
|
|
646
|
+
header_cookies: SimpleCookie = SimpleCookie()
|
|
647
|
+
raw_cookie = request.headers.get("Cookie")
|
|
648
|
+
if raw_cookie is not None:
|
|
649
|
+
header_cookies.load(raw_cookie)
|
|
650
|
+
|
|
651
|
+
def has_header(p: Mapping[str, Any]) -> bool:
|
|
652
|
+
return p["in"] == "header" and p["name"] in request.headers
|
|
653
|
+
|
|
654
|
+
def has_query(p: Mapping[str, Any]) -> bool:
|
|
655
|
+
return p["in"] == "query" and p["name"] in query
|
|
656
|
+
|
|
657
|
+
def has_cookie(p: Mapping[str, Any]) -> bool:
|
|
658
|
+
cookies = cast(RequestsCookieJar, request._cookies) # type: ignore[attr-defined]
|
|
659
|
+
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
660
|
+
|
|
661
|
+
for parameter in security_parameters:
|
|
662
|
+
name = parameter["name"]
|
|
663
|
+
if has_header(parameter):
|
|
664
|
+
if (
|
|
665
|
+
# Explicit CLI headers
|
|
666
|
+
(ctx._headers is not None and name in ctx._headers)
|
|
667
|
+
# Other kinds of overrides
|
|
668
|
+
or (ctx._override and name in ctx._override.headers)
|
|
669
|
+
or (response._override and name in response._override.headers)
|
|
670
|
+
):
|
|
671
|
+
return AuthKind.EXPLICIT
|
|
672
|
+
return AuthKind.GENERATED
|
|
673
|
+
if has_cookie(parameter):
|
|
674
|
+
for headers in [
|
|
675
|
+
ctx._headers,
|
|
676
|
+
(ctx._override.headers if ctx._override else None),
|
|
677
|
+
(response._override.headers if response._override else None),
|
|
678
|
+
]:
|
|
679
|
+
if headers is not None and "Cookie" in headers:
|
|
680
|
+
jar = cast(RequestsCookieJar, headers["Cookie"])
|
|
681
|
+
if name in jar:
|
|
682
|
+
return AuthKind.EXPLICIT
|
|
683
|
+
|
|
684
|
+
if (ctx._override and name in ctx._override.cookies) or (
|
|
685
|
+
response._override and name in response._override.cookies
|
|
686
|
+
):
|
|
687
|
+
return AuthKind.EXPLICIT
|
|
688
|
+
return AuthKind.GENERATED
|
|
689
|
+
if has_query(parameter):
|
|
690
|
+
if (ctx._override and name in ctx._override.query) or (
|
|
691
|
+
response._override and name in response._override.query
|
|
692
|
+
):
|
|
693
|
+
return AuthKind.EXPLICIT
|
|
694
|
+
return AuthKind.GENERATED
|
|
695
|
+
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def remove_auth(case: Case, security_parameters: list[Mapping[str, Any]]) -> Case:
|
|
700
|
+
"""Remove security parameters from a generated case.
|
|
701
|
+
|
|
702
|
+
It mutates `case` in place.
|
|
703
|
+
"""
|
|
704
|
+
headers = case.headers.copy()
|
|
705
|
+
query = case.query.copy()
|
|
706
|
+
cookies = case.cookies.copy()
|
|
707
|
+
for parameter in security_parameters:
|
|
708
|
+
name = parameter["name"]
|
|
709
|
+
if parameter["in"] == "header" and headers:
|
|
710
|
+
headers.pop(name, None)
|
|
711
|
+
if parameter["in"] == "query" and query:
|
|
712
|
+
query.pop(name, None)
|
|
713
|
+
if parameter["in"] == "cookie" and cookies:
|
|
714
|
+
cookies.pop(name, None)
|
|
715
|
+
return Case(
|
|
716
|
+
operation=case.operation,
|
|
717
|
+
method=case.method,
|
|
718
|
+
path=case.path,
|
|
719
|
+
path_parameters=case.path_parameters.copy(),
|
|
720
|
+
headers=headers,
|
|
721
|
+
cookies=cookies,
|
|
722
|
+
query=query,
|
|
723
|
+
body=case.body.copy() if isinstance(case.body, (list, dict)) else case.body,
|
|
724
|
+
media_type=case.media_type,
|
|
725
|
+
meta=case.meta,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _remove_auth_from_container(container: dict, security_parameters: list[Mapping[str, Any]], location: str) -> None:
|
|
730
|
+
for parameter in security_parameters:
|
|
731
|
+
name = parameter["name"]
|
|
732
|
+
if parameter["in"] == location:
|
|
733
|
+
container.pop(name, None)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _set_auth_for_case(case: Case, parameter: Mapping[str, Any]) -> None:
|
|
737
|
+
name = parameter["name"]
|
|
738
|
+
for location, attr_name in (
|
|
739
|
+
("header", "headers"),
|
|
740
|
+
("query", "query"),
|
|
741
|
+
("cookie", "cookies"),
|
|
742
|
+
):
|
|
743
|
+
if parameter["in"] == location:
|
|
744
|
+
container = getattr(case, attr_name, {})
|
|
745
|
+
# Could happen in the negative testing mode
|
|
746
|
+
if not isinstance(container, dict):
|
|
747
|
+
container = {}
|
|
748
|
+
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
|
749
|
+
setattr(case, attr_name, container)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@dataclass
|
|
753
|
+
class ResourcePath:
|
|
754
|
+
"""A path to a resource with variables."""
|
|
755
|
+
|
|
756
|
+
value: str
|
|
757
|
+
variables: dict[str, str]
|
|
758
|
+
|
|
759
|
+
__slots__ = ("value", "variables")
|
|
760
|
+
|
|
761
|
+
def get(self, key: str) -> str:
|
|
762
|
+
return self.variables[key.lstrip("{").rstrip("}")]
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _is_prefix_operation(lhs: ResourcePath, rhs: ResourcePath) -> bool:
|
|
766
|
+
lhs_parts = lhs.value.rstrip("/").split("/")
|
|
767
|
+
rhs_parts = rhs.value.rstrip("/").split("/")
|
|
768
|
+
|
|
769
|
+
# Left has more parts, can't be a prefix
|
|
770
|
+
if len(lhs_parts) > len(rhs_parts):
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
for left, right in zip(lhs_parts, rhs_parts):
|
|
774
|
+
if left.startswith("{") and right.startswith("{"):
|
|
775
|
+
if str(lhs.get(left)) != str(rhs.get(right)):
|
|
776
|
+
return False
|
|
777
|
+
elif left != right and left.rstrip("s") != right.rstrip("s"):
|
|
778
|
+
# Parts don't match, not a prefix
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
# If we've reached this point, the LHS path is a prefix of the RHS path
|
|
782
|
+
return True
|