schemathesis 3.35.5__py3-none-any.whl → 3.36.1__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/_hypothesis.py +5 -0
- schemathesis/auths.py +1 -0
- schemathesis/checks.py +8 -5
- schemathesis/cli/__init__.py +4 -13
- schemathesis/contrib/unique_data.py +1 -2
- schemathesis/generation/coverage.py +81 -8
- schemathesis/internal/checks.py +55 -0
- schemathesis/models.py +24 -10
- schemathesis/runner/__init__.py +7 -1
- schemathesis/runner/impl/context.py +22 -4
- schemathesis/runner/impl/core.py +63 -12
- schemathesis/runner/impl/solo.py +1 -1
- schemathesis/runner/impl/threadpool.py +3 -4
- schemathesis/schemas.py +2 -2
- schemathesis/specs/graphql/loaders.py +2 -2
- schemathesis/specs/graphql/schemas.py +9 -5
- schemathesis/specs/openapi/checks.py +79 -27
- schemathesis/specs/openapi/loaders.py +2 -2
- schemathesis/specs/openapi/schemas.py +19 -3
- schemathesis/stateful/config.py +1 -0
- schemathesis/stateful/context.py +10 -0
- schemathesis/stateful/runner.py +19 -2
- schemathesis/stateful/state_machine.py +2 -1
- schemathesis/stateful/validation.py +9 -4
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.1.dist-info}/METADATA +2 -1
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.1.dist-info}/RECORD +29 -28
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/runner/impl/core.py
CHANGED
|
@@ -21,6 +21,7 @@ from hypothesis.errors import HypothesisException, InvalidArgument
|
|
|
21
21
|
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
|
22
22
|
from jsonschema.exceptions import SchemaError as JsonSchemaError
|
|
23
23
|
from jsonschema.exceptions import ValidationError
|
|
24
|
+
from requests.structures import CaseInsensitiveDict
|
|
24
25
|
from urllib3.exceptions import InsecureRequestWarning
|
|
25
26
|
|
|
26
27
|
from ... import experimental, failures, hooks
|
|
@@ -56,9 +57,10 @@ from ...exceptions import (
|
|
|
56
57
|
)
|
|
57
58
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
58
59
|
from ...hooks import HookContext, get_all_by_name
|
|
60
|
+
from ...internal.checks import CheckContext
|
|
59
61
|
from ...internal.datetime import current_datetime
|
|
60
62
|
from ...internal.result import Err, Ok, Result
|
|
61
|
-
from ...models import APIOperation, Case, Check,
|
|
63
|
+
from ...models import APIOperation, Case, Check, Status, TestResult
|
|
62
64
|
from ...runner import events
|
|
63
65
|
from ...service import extensions
|
|
64
66
|
from ...service.models import AnalysisResult, AnalysisSuccess
|
|
@@ -75,13 +77,16 @@ from ..serialization import SerializedTestResult
|
|
|
75
77
|
from .context import RunnerContext
|
|
76
78
|
|
|
77
79
|
if TYPE_CHECKING:
|
|
78
|
-
from ...types import RawAuth
|
|
79
|
-
from ...schemas import BaseSchema
|
|
80
|
-
from ..._override import CaseOverride
|
|
81
|
-
from requests.auth import HTTPDigestAuth
|
|
82
80
|
from types import TracebackType
|
|
81
|
+
|
|
82
|
+
from requests.auth import HTTPDigestAuth
|
|
83
|
+
|
|
84
|
+
from ..._override import CaseOverride
|
|
85
|
+
from ...internal.checks import CheckFunction
|
|
86
|
+
from ...schemas import BaseSchema
|
|
83
87
|
from ...service.client import ServiceClient
|
|
84
88
|
from ...transports.responses import GenericResponse, WSGIResponse
|
|
89
|
+
from ...types import RawAuth
|
|
85
90
|
|
|
86
91
|
|
|
87
92
|
def _should_count_towards_stop(event: events.ExecutionEvent) -> bool:
|
|
@@ -95,7 +100,7 @@ class BaseRunner:
|
|
|
95
100
|
max_response_time: int | None
|
|
96
101
|
targets: Iterable[Target]
|
|
97
102
|
hypothesis_settings: hypothesis.settings
|
|
98
|
-
generation_config: GenerationConfig
|
|
103
|
+
generation_config: GenerationConfig | None
|
|
99
104
|
probe_config: probes.ProbeConfig
|
|
100
105
|
request_config: RequestConfig = field(default_factory=RequestConfig)
|
|
101
106
|
override: CaseOverride | None = None
|
|
@@ -107,6 +112,7 @@ class BaseRunner:
|
|
|
107
112
|
exit_first: bool = False
|
|
108
113
|
max_failures: int | None = None
|
|
109
114
|
started_at: str = field(default_factory=current_datetime)
|
|
115
|
+
unique_data: bool = False
|
|
110
116
|
dry_run: bool = False
|
|
111
117
|
stateful: Stateful | None = None
|
|
112
118
|
stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT
|
|
@@ -125,7 +131,7 @@ class BaseRunner:
|
|
|
125
131
|
# If auth is explicitly provided, then the global provider is ignored
|
|
126
132
|
if self.auth is not None:
|
|
127
133
|
unregister_auth()
|
|
128
|
-
ctx = RunnerContext(self.seed, stop_event)
|
|
134
|
+
ctx = RunnerContext(auth=self.auth, seed=self.seed, stop_event=stop_event, unique_data=self.unique_data)
|
|
129
135
|
start_time = time.monotonic()
|
|
130
136
|
initialized = None
|
|
131
137
|
__probes = None
|
|
@@ -333,7 +339,7 @@ class BaseRunner:
|
|
|
333
339
|
maker: Callable,
|
|
334
340
|
test_func: Callable,
|
|
335
341
|
settings: hypothesis.settings,
|
|
336
|
-
generation_config: GenerationConfig,
|
|
342
|
+
generation_config: GenerationConfig | None,
|
|
337
343
|
ctx: RunnerContext,
|
|
338
344
|
recursion_level: int = 0,
|
|
339
345
|
headers: dict[str, Any] | None = None,
|
|
@@ -561,9 +567,10 @@ def run_test(
|
|
|
561
567
|
try:
|
|
562
568
|
with catch_warnings(record=True) as warnings, capture_hypothesis_output() as hypothesis_output:
|
|
563
569
|
test(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
570
|
+
ctx=ctx,
|
|
571
|
+
checks=checks,
|
|
572
|
+
targets=targets,
|
|
573
|
+
result=result,
|
|
567
574
|
errors=errors,
|
|
568
575
|
headers=headers,
|
|
569
576
|
data_generation_methods=data_generation_methods,
|
|
@@ -789,6 +796,7 @@ def deduplicate_errors(errors: list[Exception]) -> Generator[Exception, None, No
|
|
|
789
796
|
def run_checks(
|
|
790
797
|
*,
|
|
791
798
|
case: Case,
|
|
799
|
+
ctx: CheckContext,
|
|
792
800
|
checks: Iterable[CheckFunction],
|
|
793
801
|
check_results: list[Check],
|
|
794
802
|
result: TestResult,
|
|
@@ -811,7 +819,7 @@ def run_checks(
|
|
|
811
819
|
check_name = check.__name__
|
|
812
820
|
copied_case = case.partial_deepcopy()
|
|
813
821
|
try:
|
|
814
|
-
skip_check = check(response, copied_case)
|
|
822
|
+
skip_check = check(ctx, response, copied_case)
|
|
815
823
|
if not skip_check:
|
|
816
824
|
check_result = result.add_success(check_name, copied_case, response, elapsed_time)
|
|
817
825
|
check_results.append(check_result)
|
|
@@ -897,7 +905,33 @@ def _force_data_generation_method(values: list[DataGenerationMethod], case: Case
|
|
|
897
905
|
values[:] = [data_generation_method]
|
|
898
906
|
|
|
899
907
|
|
|
908
|
+
def cached_test_func(f: Callable) -> Callable:
|
|
909
|
+
def wrapped(*, ctx: RunnerContext, case: Case, **kwargs: Any) -> None:
|
|
910
|
+
if ctx.unique_data:
|
|
911
|
+
cached = ctx.get_cached_outcome(case)
|
|
912
|
+
if isinstance(cached, BaseException):
|
|
913
|
+
raise cached
|
|
914
|
+
elif cached is None:
|
|
915
|
+
return None
|
|
916
|
+
try:
|
|
917
|
+
f(ctx=ctx, case=case, **kwargs)
|
|
918
|
+
except BaseException as exc:
|
|
919
|
+
ctx.cache_outcome(case, exc)
|
|
920
|
+
raise
|
|
921
|
+
else:
|
|
922
|
+
ctx.cache_outcome(case, None)
|
|
923
|
+
else:
|
|
924
|
+
f(ctx=ctx, case=case, **kwargs)
|
|
925
|
+
|
|
926
|
+
wrapped.__name__ = f.__name__
|
|
927
|
+
|
|
928
|
+
return wrapped
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@cached_test_func
|
|
900
932
|
def network_test(
|
|
933
|
+
*,
|
|
934
|
+
ctx: RunnerContext,
|
|
901
935
|
case: Case,
|
|
902
936
|
checks: Iterable[CheckFunction],
|
|
903
937
|
targets: Iterable[Target],
|
|
@@ -921,6 +955,7 @@ def network_test(
|
|
|
921
955
|
headers["User-Agent"] = USER_AGENT
|
|
922
956
|
if not dry_run:
|
|
923
957
|
args = (
|
|
958
|
+
ctx,
|
|
924
959
|
checks,
|
|
925
960
|
targets,
|
|
926
961
|
result,
|
|
@@ -939,6 +974,7 @@ def network_test(
|
|
|
939
974
|
|
|
940
975
|
def _network_test(
|
|
941
976
|
case: Case,
|
|
977
|
+
ctx: RunnerContext,
|
|
942
978
|
checks: Iterable[CheckFunction],
|
|
943
979
|
targets: Iterable[Target],
|
|
944
980
|
result: TestResult,
|
|
@@ -980,9 +1016,12 @@ def _network_test(
|
|
|
980
1016
|
context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
|
|
981
1017
|
run_targets(targets, context)
|
|
982
1018
|
status = Status.success
|
|
1019
|
+
|
|
1020
|
+
check_ctx = CheckContext(auth=ctx.auth, headers=CaseInsensitiveDict(headers) if headers else None)
|
|
983
1021
|
try:
|
|
984
1022
|
run_checks(
|
|
985
1023
|
case=case,
|
|
1024
|
+
ctx=check_ctx,
|
|
986
1025
|
checks=checks,
|
|
987
1026
|
check_results=check_results,
|
|
988
1027
|
result=result,
|
|
@@ -1009,7 +1048,9 @@ def get_session(auth: HTTPDigestAuth | RawAuth | None = None) -> Generator[reque
|
|
|
1009
1048
|
yield session
|
|
1010
1049
|
|
|
1011
1050
|
|
|
1051
|
+
@cached_test_func
|
|
1012
1052
|
def wsgi_test(
|
|
1053
|
+
ctx: RunnerContext,
|
|
1013
1054
|
case: Case,
|
|
1014
1055
|
checks: Iterable[CheckFunction],
|
|
1015
1056
|
targets: Iterable[Target],
|
|
@@ -1030,6 +1071,7 @@ def wsgi_test(
|
|
|
1030
1071
|
headers = prepare_wsgi_headers(headers, auth, auth_type)
|
|
1031
1072
|
if not dry_run:
|
|
1032
1073
|
args = (
|
|
1074
|
+
ctx,
|
|
1033
1075
|
checks,
|
|
1034
1076
|
targets,
|
|
1035
1077
|
result,
|
|
@@ -1046,6 +1088,7 @@ def wsgi_test(
|
|
|
1046
1088
|
|
|
1047
1089
|
def _wsgi_test(
|
|
1048
1090
|
case: Case,
|
|
1091
|
+
ctx: RunnerContext,
|
|
1049
1092
|
checks: Iterable[CheckFunction],
|
|
1050
1093
|
targets: Iterable[Target],
|
|
1051
1094
|
result: TestResult,
|
|
@@ -1066,9 +1109,11 @@ def _wsgi_test(
|
|
|
1066
1109
|
result.logs.extend(recorded.records)
|
|
1067
1110
|
status = Status.success
|
|
1068
1111
|
check_results: list[Check] = []
|
|
1112
|
+
check_ctx = CheckContext(auth=ctx.auth, headers=CaseInsensitiveDict(headers) if headers else None)
|
|
1069
1113
|
try:
|
|
1070
1114
|
run_checks(
|
|
1071
1115
|
case=case,
|
|
1116
|
+
ctx=check_ctx,
|
|
1072
1117
|
checks=checks,
|
|
1073
1118
|
check_results=check_results,
|
|
1074
1119
|
result=result,
|
|
@@ -1087,7 +1132,9 @@ def _wsgi_test(
|
|
|
1087
1132
|
return response
|
|
1088
1133
|
|
|
1089
1134
|
|
|
1135
|
+
@cached_test_func
|
|
1090
1136
|
def asgi_test(
|
|
1137
|
+
ctx: RunnerContext,
|
|
1091
1138
|
case: Case,
|
|
1092
1139
|
checks: Iterable[CheckFunction],
|
|
1093
1140
|
targets: Iterable[Target],
|
|
@@ -1108,6 +1155,7 @@ def asgi_test(
|
|
|
1108
1155
|
|
|
1109
1156
|
if not dry_run:
|
|
1110
1157
|
args = (
|
|
1158
|
+
ctx,
|
|
1111
1159
|
checks,
|
|
1112
1160
|
targets,
|
|
1113
1161
|
result,
|
|
@@ -1124,6 +1172,7 @@ def asgi_test(
|
|
|
1124
1172
|
|
|
1125
1173
|
def _asgi_test(
|
|
1126
1174
|
case: Case,
|
|
1175
|
+
ctx: RunnerContext,
|
|
1127
1176
|
checks: Iterable[CheckFunction],
|
|
1128
1177
|
targets: Iterable[Target],
|
|
1129
1178
|
result: TestResult,
|
|
@@ -1140,9 +1189,11 @@ def _asgi_test(
|
|
|
1140
1189
|
run_targets(targets, context)
|
|
1141
1190
|
status = Status.success
|
|
1142
1191
|
check_results: list[Check] = []
|
|
1192
|
+
check_ctx = CheckContext(auth=ctx.auth, headers=CaseInsensitiveDict(headers) if headers else None)
|
|
1143
1193
|
try:
|
|
1144
1194
|
run_checks(
|
|
1145
1195
|
case=case,
|
|
1196
|
+
ctx=check_ctx,
|
|
1146
1197
|
checks=checks,
|
|
1147
1198
|
check_results=check_results,
|
|
1148
1199
|
result=result,
|
schemathesis/runner/impl/solo.py
CHANGED
|
@@ -13,7 +13,6 @@ from hypothesis.errors import HypothesisWarning
|
|
|
13
13
|
|
|
14
14
|
from ..._hypothesis import create_test
|
|
15
15
|
from ...internal.result import Ok
|
|
16
|
-
from ...models import CheckFunction
|
|
17
16
|
from ...stateful import Feedback, Stateful
|
|
18
17
|
from ...transports.auth import get_requests_auth
|
|
19
18
|
from ...utils import capture_hypothesis_output
|
|
@@ -21,13 +20,13 @@ from .. import events
|
|
|
21
20
|
from .core import BaseRunner, asgi_test, get_session, handle_schema_error, network_test, run_test, wsgi_test
|
|
22
21
|
|
|
23
22
|
if TYPE_CHECKING:
|
|
24
|
-
from .context import RunnerContext
|
|
25
23
|
import hypothesis
|
|
26
24
|
|
|
27
25
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
28
|
-
from ...
|
|
26
|
+
from ...internal.checks import CheckFunction
|
|
29
27
|
from ...targets import Target
|
|
30
28
|
from ...types import RawAuth
|
|
29
|
+
from .context import RunnerContext
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
def _run_task(
|
|
@@ -235,7 +234,7 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
235
234
|
# It would be better to have a separate producer thread and communicate via threading events.
|
|
236
235
|
# Though it is a bit more complex, so the current solution is suboptimal in terms of resources utilization,
|
|
237
236
|
# but good enough and easy enough to implement.
|
|
238
|
-
tasks_generator = iter(self.schema.get_all_operations())
|
|
237
|
+
tasks_generator = iter(self.schema.get_all_operations(generation_config=self.generation_config))
|
|
239
238
|
generator_done = threading.Event()
|
|
240
239
|
tasks_queue: Queue = Queue()
|
|
241
240
|
# Add at least `workers_num` tasks first, so all workers are busy
|
schemathesis/schemas.py
CHANGED
|
@@ -241,7 +241,7 @@ class BaseSchema(Mapping):
|
|
|
241
241
|
raise NotImplementedError
|
|
242
242
|
|
|
243
243
|
def get_all_operations(
|
|
244
|
-
self, hooks: HookDispatcher | None = None
|
|
244
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
245
245
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
246
246
|
raise NotImplementedError
|
|
247
247
|
|
|
@@ -276,7 +276,7 @@ class BaseSchema(Mapping):
|
|
|
276
276
|
_given_kwargs: dict[str, GivenInput] | None = None,
|
|
277
277
|
) -> Generator[Result[tuple[APIOperation, Callable], OperationSchemaError], None, None]:
|
|
278
278
|
"""Generate all operations and Hypothesis tests for them."""
|
|
279
|
-
for result in self.get_all_operations(hooks=hooks):
|
|
279
|
+
for result in self.get_all_operations(hooks=hooks, generation_config=generation_config):
|
|
280
280
|
if isinstance(result, Ok):
|
|
281
281
|
operation = result.ok()
|
|
282
282
|
_as_strategy_kwargs: dict[str, Any] | None
|
|
@@ -139,12 +139,12 @@ def from_url(
|
|
|
139
139
|
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
|
140
140
|
)
|
|
141
141
|
def _load_schema(_uri: str, **_kwargs: Any) -> requests.Response:
|
|
142
|
-
|
|
143
|
-
return requests.post(_uri, **kwargs)
|
|
142
|
+
return requests.post(_uri, **_kwargs)
|
|
144
143
|
|
|
145
144
|
else:
|
|
146
145
|
_load_schema = requests.post
|
|
147
146
|
|
|
147
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
148
148
|
response = load_schema_from_url(lambda: _load_schema(url, **kwargs))
|
|
149
149
|
raw_schema = extract_schema_from_response(response)
|
|
150
150
|
return from_dict(
|
|
@@ -27,7 +27,7 @@ from requests.structures import CaseInsensitiveDict
|
|
|
27
27
|
|
|
28
28
|
from ... import auths
|
|
29
29
|
from ...checks import not_a_server_error
|
|
30
|
-
from ...constants import NOT_SET
|
|
30
|
+
from ...constants import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER
|
|
31
31
|
from ...exceptions import OperationNotFound, OperationSchemaError
|
|
32
32
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
33
33
|
from ...hooks import (
|
|
@@ -38,7 +38,7 @@ from ...hooks import (
|
|
|
38
38
|
should_skip_operation,
|
|
39
39
|
)
|
|
40
40
|
from ...internal.result import Ok, Result
|
|
41
|
-
from ...models import APIOperation, Case,
|
|
41
|
+
from ...models import APIOperation, Case, OperationDefinition
|
|
42
42
|
from ...schemas import APIOperationMap, BaseSchema
|
|
43
43
|
from ...types import Body, Cookies, Headers, NotSet, PathParameters, Query
|
|
44
44
|
from ..openapi.constants import LOCATION_TO_CONTAINER
|
|
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
|
|
|
49
49
|
from hypothesis.strategies import SearchStrategy
|
|
50
50
|
|
|
51
51
|
from ...auths import AuthStorage
|
|
52
|
+
from ...internal.checks import CheckFunction
|
|
52
53
|
from ...stateful import Stateful, StatefulTest
|
|
53
54
|
from ...transports.responses import GenericResponse
|
|
54
55
|
|
|
@@ -61,6 +62,9 @@ class RootType(enum.Enum):
|
|
|
61
62
|
|
|
62
63
|
@dataclass(repr=False)
|
|
63
64
|
class GraphQLCase(Case):
|
|
65
|
+
def __hash__(self) -> int:
|
|
66
|
+
return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
|
|
67
|
+
|
|
64
68
|
def _get_url(self, base_url: str | None) -> str:
|
|
65
69
|
base_url = self._get_base_url(base_url)
|
|
66
70
|
# Replace the path, in case if the user provided any path parameters via hooks
|
|
@@ -78,11 +82,12 @@ class GraphQLCase(Case):
|
|
|
78
82
|
additional_checks: tuple[CheckFunction, ...] = (),
|
|
79
83
|
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
80
84
|
code_sample_style: str | None = None,
|
|
85
|
+
headers: dict[str, Any] | None = None,
|
|
81
86
|
) -> None:
|
|
82
87
|
checks = checks or (not_a_server_error,)
|
|
83
88
|
checks += additional_checks
|
|
84
89
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
85
|
-
return super().validate_response(response, checks, code_sample_style=code_sample_style)
|
|
90
|
+
return super().validate_response(response, checks, code_sample_style=code_sample_style, headers=headers)
|
|
86
91
|
|
|
87
92
|
|
|
88
93
|
C = TypeVar("C", bound=Case)
|
|
@@ -186,8 +191,7 @@ class GraphQLSchema(BaseSchema):
|
|
|
186
191
|
return 0
|
|
187
192
|
|
|
188
193
|
def get_all_operations(
|
|
189
|
-
self,
|
|
190
|
-
hooks: HookDispatcher | None = None,
|
|
194
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
191
195
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
192
196
|
schema = self.client_schema
|
|
193
197
|
for root_type, operation_type in (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
import enum
|
|
4
5
|
from http.cookies import SimpleCookie
|
|
5
6
|
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
|
6
7
|
from urllib.parse import parse_qs, urlparse
|
|
@@ -25,11 +26,12 @@ from .utils import expand_status_code
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from requests import PreparedRequest
|
|
27
28
|
|
|
29
|
+
from ...internal.checks import CheckContext
|
|
28
30
|
from ...models import APIOperation, Case
|
|
29
31
|
from ...transports.responses import GenericResponse
|
|
30
32
|
|
|
31
33
|
|
|
32
|
-
def status_code_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
34
|
+
def status_code_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
33
35
|
from .schemas import BaseOpenAPISchema
|
|
34
36
|
|
|
35
37
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -60,7 +62,7 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
|
60
62
|
yield from expand_status_code(code)
|
|
61
63
|
|
|
62
64
|
|
|
63
|
-
def content_type_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
65
|
+
def content_type_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
64
66
|
from .schemas import BaseOpenAPISchema
|
|
65
67
|
|
|
66
68
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -115,7 +117,7 @@ def _reraise_malformed_media_type(case: Case, exc: ValueError, location: str, ac
|
|
|
115
117
|
) from exc
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
def response_headers_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
120
|
+
def response_headers_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
119
121
|
import jsonschema
|
|
120
122
|
|
|
121
123
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
|
@@ -171,11 +173,11 @@ def response_headers_conformance(response: GenericResponse, case: Case) -> bool
|
|
|
171
173
|
)
|
|
172
174
|
except jsonschema.ValidationError as exc:
|
|
173
175
|
exc_class = get_schema_validation_error(case.operation.verbose_name, exc)
|
|
174
|
-
|
|
176
|
+
error_ctx = failures.ValidationErrorContext.from_exception(
|
|
175
177
|
exc, output_config=case.operation.schema.output_config
|
|
176
178
|
)
|
|
177
179
|
try:
|
|
178
|
-
raise exc_class("Response header does not conform to the schema", context=
|
|
180
|
+
raise exc_class("Response header does not conform to the schema", context=error_ctx) from exc
|
|
179
181
|
except Exception as exc:
|
|
180
182
|
errors.append(exc)
|
|
181
183
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
|
@@ -203,7 +205,7 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
|
|
|
203
205
|
return value
|
|
204
206
|
|
|
205
207
|
|
|
206
|
-
def response_schema_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
208
|
+
def response_schema_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
207
209
|
from .schemas import BaseOpenAPISchema
|
|
208
210
|
|
|
209
211
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -211,7 +213,7 @@ def response_schema_conformance(response: GenericResponse, case: Case) -> bool |
|
|
|
211
213
|
return case.operation.validate_response(response)
|
|
212
214
|
|
|
213
215
|
|
|
214
|
-
def negative_data_rejection(response: GenericResponse, case: Case) -> bool | None:
|
|
216
|
+
def negative_data_rejection(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
215
217
|
from .schemas import BaseOpenAPISchema
|
|
216
218
|
|
|
217
219
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -258,7 +260,7 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
|
258
260
|
return True
|
|
259
261
|
|
|
260
262
|
|
|
261
|
-
def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
263
|
+
def use_after_free(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
262
264
|
from ...transports.responses import get_reason
|
|
263
265
|
from .schemas import BaseOpenAPISchema
|
|
264
266
|
|
|
@@ -298,7 +300,7 @@ def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
|
298
300
|
return None
|
|
299
301
|
|
|
300
302
|
|
|
301
|
-
def ensure_resource_availability(response: GenericResponse, original: Case) -> bool | None:
|
|
303
|
+
def ensure_resource_availability(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
302
304
|
from ...transports.responses import get_reason
|
|
303
305
|
from .schemas import BaseOpenAPISchema
|
|
304
306
|
|
|
@@ -332,7 +334,12 @@ def ensure_resource_availability(response: GenericResponse, original: Case) -> b
|
|
|
332
334
|
return None
|
|
333
335
|
|
|
334
336
|
|
|
335
|
-
|
|
337
|
+
class AuthKind(enum.Enum):
|
|
338
|
+
EXPLICIT = "explicit"
|
|
339
|
+
GENERATED = "generated"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
336
343
|
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
|
337
344
|
from .schemas import BaseOpenAPISchema
|
|
338
345
|
|
|
@@ -340,32 +347,49 @@ def ignored_auth(response: GenericResponse, case: Case) -> bool | None:
|
|
|
340
347
|
return True
|
|
341
348
|
security_parameters = _get_security_parameters(case.operation)
|
|
342
349
|
# Authentication is required for this API operation and response is successful
|
|
343
|
-
# Will it still be successful if there is no auth?
|
|
344
350
|
if security_parameters and 200 <= response.status_code < 300:
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
auth = _contains_auth(ctx, case, response.request, security_parameters)
|
|
352
|
+
if auth == AuthKind.EXPLICIT:
|
|
353
|
+
# Auth is explicitly set, it is expected to be valid
|
|
354
|
+
# Check if invalid auth will give an error
|
|
347
355
|
_remove_auth_from_case(case, security_parameters)
|
|
348
356
|
new_response = case.operation.schema.transport.send(case)
|
|
349
357
|
if 200 <= new_response.status_code < 300:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
_update_response(response, new_response)
|
|
359
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "that requires authentication")
|
|
360
|
+
# Try to set invalid auth and check if it succeeds
|
|
361
|
+
for parameter in security_parameters:
|
|
362
|
+
_set_auth_for_case(case, parameter)
|
|
363
|
+
new_response = case.operation.schema.transport.send(case)
|
|
364
|
+
if 200 <= new_response.status_code < 300:
|
|
365
|
+
_update_response(response, new_response)
|
|
366
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "with any auth")
|
|
367
|
+
_remove_auth_from_case(case, security_parameters)
|
|
368
|
+
elif auth == AuthKind.GENERATED:
|
|
369
|
+
# If this auth is generated which means it is likely invalid, then
|
|
370
|
+
# this request should have been an error
|
|
371
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "with invalid auth")
|
|
357
372
|
else:
|
|
358
373
|
# Successful response when there is no auth
|
|
359
|
-
|
|
374
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "that requires authentication")
|
|
360
375
|
return None
|
|
361
376
|
|
|
362
377
|
|
|
363
|
-
def
|
|
378
|
+
def _update_response(old: GenericResponse, new: GenericResponse) -> None:
|
|
379
|
+
# Mutate the response object in place on the best effort basis
|
|
380
|
+
if hasattr(old, "__attrs__"):
|
|
381
|
+
for attribute in new.__attrs__:
|
|
382
|
+
setattr(old, attribute, getattr(new, attribute))
|
|
383
|
+
else:
|
|
384
|
+
old.__dict__.update(new.__dict__)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _raise_no_auth_error(response: GenericResponse, operation: str, suffix: str) -> NoReturn:
|
|
364
388
|
from ...transports.responses import get_reason
|
|
365
389
|
|
|
366
390
|
exc_class = get_ignored_auth_error(operation)
|
|
367
391
|
reason = get_reason(response.status_code)
|
|
368
|
-
message = f"The API returned `{response.status_code} {reason}` for `{operation}`
|
|
392
|
+
message = f"The API returned `{response.status_code} {reason}` for `{operation}` {suffix}."
|
|
369
393
|
raise exc_class(
|
|
370
394
|
failures.IgnoredAuth.title,
|
|
371
395
|
context=failures.IgnoredAuth(message=message),
|
|
@@ -387,10 +411,15 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
|
|
|
387
411
|
]
|
|
388
412
|
|
|
389
413
|
|
|
390
|
-
def _contains_auth(
|
|
414
|
+
def _contains_auth(
|
|
415
|
+
ctx: CheckContext, case: Case, request: PreparedRequest, security_parameters: list[SecurityParameter]
|
|
416
|
+
) -> AuthKind | None:
|
|
391
417
|
"""Whether a request has authentication declared in the schema."""
|
|
392
418
|
from requests.cookies import RequestsCookieJar
|
|
393
419
|
|
|
420
|
+
# If auth comes from explicit `auth` option or a custom auth, it is always explicit
|
|
421
|
+
if ctx.auth is not None or case._has_explicit_auth:
|
|
422
|
+
return AuthKind.EXPLICIT
|
|
394
423
|
parsed = urlparse(request.url)
|
|
395
424
|
query = parse_qs(parsed.query) # type: ignore
|
|
396
425
|
# Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
|
|
@@ -410,10 +439,20 @@ def _contains_auth(request: PreparedRequest, security_parameters: list[SecurityP
|
|
|
410
439
|
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
411
440
|
|
|
412
441
|
for parameter in security_parameters:
|
|
413
|
-
if has_header(parameter)
|
|
414
|
-
|
|
442
|
+
if has_header(parameter):
|
|
443
|
+
if ctx.headers is not None and parameter["name"] in ctx.headers:
|
|
444
|
+
return AuthKind.EXPLICIT
|
|
445
|
+
return AuthKind.GENERATED
|
|
446
|
+
if has_cookie(parameter):
|
|
447
|
+
if ctx.headers is not None and "Cookie" in ctx.headers:
|
|
448
|
+
cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
|
|
449
|
+
if parameter["name"] in cookies:
|
|
450
|
+
return AuthKind.EXPLICIT
|
|
451
|
+
return AuthKind.GENERATED
|
|
452
|
+
if has_query(parameter):
|
|
453
|
+
return AuthKind.GENERATED
|
|
415
454
|
|
|
416
|
-
return
|
|
455
|
+
return None
|
|
417
456
|
|
|
418
457
|
|
|
419
458
|
def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParameter]) -> None:
|
|
@@ -431,6 +470,19 @@ def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParamet
|
|
|
431
470
|
case.cookies.pop(name, None)
|
|
432
471
|
|
|
433
472
|
|
|
473
|
+
def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
|
474
|
+
name = parameter["name"]
|
|
475
|
+
for location, attr_name in (
|
|
476
|
+
("header", "headers"),
|
|
477
|
+
("query", "query"),
|
|
478
|
+
("cookie", "cookies"),
|
|
479
|
+
):
|
|
480
|
+
if parameter["in"] == location:
|
|
481
|
+
container = getattr(case, attr_name, {})
|
|
482
|
+
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
|
483
|
+
setattr(case, attr_name, container)
|
|
484
|
+
|
|
485
|
+
|
|
434
486
|
@dataclass
|
|
435
487
|
class ResourcePath:
|
|
436
488
|
"""A path to a resource with variables."""
|
|
@@ -163,12 +163,12 @@ def from_uri(
|
|
|
163
163
|
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
|
164
164
|
)
|
|
165
165
|
def _load_schema(_uri: str, **_kwargs: Any) -> requests.Response:
|
|
166
|
-
|
|
167
|
-
return requests.get(_uri, **kwargs)
|
|
166
|
+
return requests.get(_uri, **_kwargs)
|
|
168
167
|
|
|
169
168
|
else:
|
|
170
169
|
_load_schema = requests.get
|
|
171
170
|
|
|
171
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
172
172
|
response = load_schema_from_url(lambda: _load_schema(uri, **kwargs))
|
|
173
173
|
return from_file(
|
|
174
174
|
response.text,
|
|
@@ -253,7 +253,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
253
253
|
return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
|
|
254
254
|
|
|
255
255
|
def get_all_operations(
|
|
256
|
-
self, hooks: HookDispatcher | None = None
|
|
256
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
257
257
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
258
258
|
"""Iterate over all operations defined in the API.
|
|
259
259
|
|
|
@@ -308,7 +308,17 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
308
308
|
continue
|
|
309
309
|
parameters = resolved.get("parameters", ())
|
|
310
310
|
parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
|
|
311
|
-
operation = make_operation(
|
|
311
|
+
operation = make_operation(
|
|
312
|
+
path,
|
|
313
|
+
method,
|
|
314
|
+
parameters,
|
|
315
|
+
entry,
|
|
316
|
+
resolved,
|
|
317
|
+
scope,
|
|
318
|
+
with_security_parameters=generation_config.with_security_parameters
|
|
319
|
+
if generation_config
|
|
320
|
+
else None,
|
|
321
|
+
)
|
|
312
322
|
context = HookContext(operation=operation)
|
|
313
323
|
if (
|
|
314
324
|
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
@@ -383,6 +393,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
383
393
|
raw: dict[str, Any],
|
|
384
394
|
resolved: dict[str, Any],
|
|
385
395
|
scope: str,
|
|
396
|
+
with_security_parameters: bool | None = None,
|
|
386
397
|
) -> APIOperation:
|
|
387
398
|
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
|
388
399
|
__tracebackhide__ = True
|
|
@@ -397,7 +408,12 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
397
408
|
)
|
|
398
409
|
for parameter in parameters:
|
|
399
410
|
operation.add_parameter(parameter)
|
|
400
|
-
|
|
411
|
+
with_security_parameters = (
|
|
412
|
+
with_security_parameters
|
|
413
|
+
if with_security_parameters is not None
|
|
414
|
+
else self.generation_config.with_security_parameters
|
|
415
|
+
)
|
|
416
|
+
if with_security_parameters:
|
|
401
417
|
self.security.process_definitions(self.raw_schema, operation, self.resolver)
|
|
402
418
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
403
419
|
return operation
|
schemathesis/stateful/config.py
CHANGED