pytest-api-core 1.0.4__tar.gz → 1.0.6__tar.gz
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.
- {pytest_api_core-1.0.4/src/pytest_api_core.egg-info → pytest_api_core-1.0.6}/PKG-INFO +71 -6
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/README.md +70 -5
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/pyproject.toml +1 -1
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/__init__.py +4 -1
- pytest_api_core-1.0.6/src/pytest_api_core/assertions/assert_utils.py +149 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/assertions/response_assertions.py +57 -25
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/auth/auth_handlers.py +9 -1
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/client/api_client.py +31 -16
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/config/config_manager.py +3 -1
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/plugin.py +21 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/html_reporter.py +9 -7
- pytest_api_core-1.0.6/src/pytest_api_core/security/__init__.py +25 -0
- pytest_api_core-1.0.6/src/pytest_api_core/security/redaction.py +220 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6/src/pytest_api_core.egg-info}/PKG-INFO +71 -6
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/SOURCES.txt +3 -1
- pytest_api_core-1.0.4/src/pytest_api_core/assertions/assert_utils.py +0 -114
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/LICENSE +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/MANIFEST.in +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/setup.cfg +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/assertions/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/auth/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/client/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/client/api_response.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/config/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/config/base_settings.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/config/env_loader.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/fixtures/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/fixtures/api_fixtures.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/__init__.py +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/templates/report.html +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/dependency_links.txt +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/entry_points.txt +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/requires.txt +0 -0
- {pytest_api_core-1.0.4 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-api-core
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
4
4
|
Summary: Pytest API automation framework with custom HTML reporting, reusable fixtures, and fluent assertions
|
|
5
5
|
Author: QA Platform Team
|
|
6
6
|
License: MIT License
|
|
@@ -84,6 +84,7 @@ Dynamic: license-file
|
|
|
84
84
|
- **Configurable retry policy** — max attempts, backoff factor, and retryable methods, overridable per environment (see below)
|
|
85
85
|
- **Environment config** — Python `settings.py` classes + `.env` file + env var overrides
|
|
86
86
|
- **Custom HTML report** — self-contained file with charts, filterable table, and request/response details
|
|
87
|
+
- **Secret redaction** — tokens, passwords, API keys and cookies are masked in logs, assertion messages, and the HTML report (see [Secret redaction](#secret-redaction))
|
|
87
88
|
- **Auto-registered fixtures** — zero boilerplate in consuming projects
|
|
88
89
|
|
|
89
90
|
---
|
|
@@ -91,13 +92,13 @@ Dynamic: license-file
|
|
|
91
92
|
## Installation
|
|
92
93
|
|
|
93
94
|
```bash
|
|
94
|
-
pip install pytest-api-core==1.0
|
|
95
|
+
pip install pytest-api-core==1.1.0
|
|
95
96
|
```
|
|
96
97
|
|
|
97
98
|
With `.env` file support (recommended):
|
|
98
99
|
|
|
99
100
|
```bash
|
|
100
|
-
pip install "pytest-api-core[dotenv]==1.0
|
|
101
|
+
pip install "pytest-api-core[dotenv]==1.1.0"
|
|
101
102
|
```
|
|
102
103
|
|
|
103
104
|
---
|
|
@@ -183,6 +184,7 @@ something you'd want to toggle per environment).
|
|
|
183
184
|
| `api_html_theme` | Report theme: `light` or `dark` | `dark` |
|
|
184
185
|
| `api_html_title` | Report browser tab title | `API Test Report` |
|
|
185
186
|
| `api_html_header` | Report page header text | `API Test Report` |
|
|
187
|
+
| `api_redact_keys` | Extra header/JSON/query keys to redact (one per line) | — |
|
|
186
188
|
|
|
187
189
|
### CLI flags
|
|
188
190
|
|
|
@@ -216,6 +218,67 @@ redeclaring `api_client` — see [docs/QUICKSTART.md](docs/QUICKSTART.md#7-overr
|
|
|
216
218
|
|
|
217
219
|
---
|
|
218
220
|
|
|
221
|
+
## Secret redaction
|
|
222
|
+
|
|
223
|
+
Credentials never reach the raw logs (terminal, `log_cli`, `log_file`), assertion failure
|
|
224
|
+
messages, or the HTML report — they are replaced with `***REDACTED***`. Redaction is on by
|
|
225
|
+
default and works in three layers:
|
|
226
|
+
|
|
227
|
+
1. **By key name** — values under any header, JSON key, query/form parameter, or `json_path`
|
|
228
|
+
segment whose name contains `token`, `secret`, `password`, `passwd`, `apikey`,
|
|
229
|
+
`authorization`, `cookie`, `credential`, or `privatekey` (case-insensitive, `-`/`_` ignored)
|
|
230
|
+
are masked. This covers `Authorization`, `X-API-Key`, `Set-Cookie`, `access_token`,
|
|
231
|
+
`client_secret`, `?api_key=…`, and so on.
|
|
232
|
+
2. **By known value** — secrets the framework knows about are scrubbed wherever they appear,
|
|
233
|
+
even under an innocuous key: values passed to `BearerAuth`, `BasicAuth`, `APIKeyAuth`,
|
|
234
|
+
`OAuth2ClientCredentials` (including fetched access tokens), sensitive `default_headers`, and
|
|
235
|
+
the `API_TOKEN` / `API_PASSWORD` / `API_KEY_VALUE` env vars. `Bearer`/`Basic` credentials and
|
|
236
|
+
JWTs are always scrubbed from free text.
|
|
237
|
+
3. **Final pass** — framework and urllib3 log lines, plus captured logs, stdout/stderr, and
|
|
238
|
+
tracebacks in the HTML report, are scrubbed before being written.
|
|
239
|
+
|
|
240
|
+
Assertions on sensitive fields still work normally — only what is *displayed* is masked:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
assert_that(response).json_path("$.access_token").is_not_none() # report shows ***REDACTED***
|
|
244
|
+
assert_that(response).header_equals("X-Auth-Token", expected_token)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Extending redaction
|
|
248
|
+
|
|
249
|
+
```ini
|
|
250
|
+
# pytest.ini — extra key names to treat as sensitive
|
|
251
|
+
[pytest]
|
|
252
|
+
api_redact_keys =
|
|
253
|
+
x-tenant-signature
|
|
254
|
+
otp
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
# conftest.py — register a secret obtained at runtime, or add keys programmatically
|
|
259
|
+
from pytest_api_core import add_sensitive_keys, register_secret
|
|
260
|
+
|
|
261
|
+
register_secret(session_token) # scrubbed everywhere from now on
|
|
262
|
+
add_sensitive_keys("x-tenant-signature")
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Standalone helpers don't know key names, so pass `sensitive=True` to mask their values:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
assert_is_not_empty(token, sensitive=True)
|
|
269
|
+
assert_equal(actual_secret, expected_secret, sensitive=True)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Notes**
|
|
273
|
+
|
|
274
|
+
- Masking is deliberately conservative: a key like `token_type` is masked too.
|
|
275
|
+
- Registered secrets shorter than 4 characters are ignored, to avoid mangling unrelated output.
|
|
276
|
+
- Plain `assert` statements are reported by pytest itself (assertion introspection), which the
|
|
277
|
+
framework can't intercept — prefer `assert_that(...)` / the `assert_*` helpers for secret
|
|
278
|
+
values, or `register_secret()` them so they're scrubbed from the HTML report.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
219
282
|
## Project structure
|
|
220
283
|
|
|
221
284
|
```
|
|
@@ -236,9 +299,11 @@ pytest-api-core-framework/
|
|
|
236
299
|
│ │ └── env_loader.py # .env file loading
|
|
237
300
|
│ ├── fixtures/
|
|
238
301
|
│ │ └── api_fixtures.py # api_config, auth_provider, api_client, api_*_auth fixtures
|
|
239
|
-
│
|
|
240
|
-
│
|
|
241
|
-
│
|
|
302
|
+
│ ├── reporters/
|
|
303
|
+
│ │ ├── html_reporter.py # parses __API_CALL__/__API_ASSERT__ sentinels, renders HTML report
|
|
304
|
+
│ │ └── templates/report.html
|
|
305
|
+
│ └── security/
|
|
306
|
+
│ └── redaction.py # secret masking for logs, sentinels, assertion messages, report
|
|
242
307
|
├── tests/
|
|
243
308
|
│ ├── unit/test_unit.py # mocked HTTP tests (responses library)
|
|
244
309
|
│ └── e2e/test_e2e_jsonplaceholder.py # live tests against jsonplaceholder.typicode.com
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
- **Configurable retry policy** — max attempts, backoff factor, and retryable methods, overridable per environment (see below)
|
|
19
19
|
- **Environment config** — Python `settings.py` classes + `.env` file + env var overrides
|
|
20
20
|
- **Custom HTML report** — self-contained file with charts, filterable table, and request/response details
|
|
21
|
+
- **Secret redaction** — tokens, passwords, API keys and cookies are masked in logs, assertion messages, and the HTML report (see [Secret redaction](#secret-redaction))
|
|
21
22
|
- **Auto-registered fixtures** — zero boilerplate in consuming projects
|
|
22
23
|
|
|
23
24
|
---
|
|
@@ -25,13 +26,13 @@
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
pip install pytest-api-core==1.0
|
|
29
|
+
pip install pytest-api-core==1.1.0
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
With `.env` file support (recommended):
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
|
-
pip install "pytest-api-core[dotenv]==1.0
|
|
35
|
+
pip install "pytest-api-core[dotenv]==1.1.0"
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
---
|
|
@@ -117,6 +118,7 @@ something you'd want to toggle per environment).
|
|
|
117
118
|
| `api_html_theme` | Report theme: `light` or `dark` | `dark` |
|
|
118
119
|
| `api_html_title` | Report browser tab title | `API Test Report` |
|
|
119
120
|
| `api_html_header` | Report page header text | `API Test Report` |
|
|
121
|
+
| `api_redact_keys` | Extra header/JSON/query keys to redact (one per line) | — |
|
|
120
122
|
|
|
121
123
|
### CLI flags
|
|
122
124
|
|
|
@@ -150,6 +152,67 @@ redeclaring `api_client` — see [docs/QUICKSTART.md](docs/QUICKSTART.md#7-overr
|
|
|
150
152
|
|
|
151
153
|
---
|
|
152
154
|
|
|
155
|
+
## Secret redaction
|
|
156
|
+
|
|
157
|
+
Credentials never reach the raw logs (terminal, `log_cli`, `log_file`), assertion failure
|
|
158
|
+
messages, or the HTML report — they are replaced with `***REDACTED***`. Redaction is on by
|
|
159
|
+
default and works in three layers:
|
|
160
|
+
|
|
161
|
+
1. **By key name** — values under any header, JSON key, query/form parameter, or `json_path`
|
|
162
|
+
segment whose name contains `token`, `secret`, `password`, `passwd`, `apikey`,
|
|
163
|
+
`authorization`, `cookie`, `credential`, or `privatekey` (case-insensitive, `-`/`_` ignored)
|
|
164
|
+
are masked. This covers `Authorization`, `X-API-Key`, `Set-Cookie`, `access_token`,
|
|
165
|
+
`client_secret`, `?api_key=…`, and so on.
|
|
166
|
+
2. **By known value** — secrets the framework knows about are scrubbed wherever they appear,
|
|
167
|
+
even under an innocuous key: values passed to `BearerAuth`, `BasicAuth`, `APIKeyAuth`,
|
|
168
|
+
`OAuth2ClientCredentials` (including fetched access tokens), sensitive `default_headers`, and
|
|
169
|
+
the `API_TOKEN` / `API_PASSWORD` / `API_KEY_VALUE` env vars. `Bearer`/`Basic` credentials and
|
|
170
|
+
JWTs are always scrubbed from free text.
|
|
171
|
+
3. **Final pass** — framework and urllib3 log lines, plus captured logs, stdout/stderr, and
|
|
172
|
+
tracebacks in the HTML report, are scrubbed before being written.
|
|
173
|
+
|
|
174
|
+
Assertions on sensitive fields still work normally — only what is *displayed* is masked:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
assert_that(response).json_path("$.access_token").is_not_none() # report shows ***REDACTED***
|
|
178
|
+
assert_that(response).header_equals("X-Auth-Token", expected_token)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Extending redaction
|
|
182
|
+
|
|
183
|
+
```ini
|
|
184
|
+
# pytest.ini — extra key names to treat as sensitive
|
|
185
|
+
[pytest]
|
|
186
|
+
api_redact_keys =
|
|
187
|
+
x-tenant-signature
|
|
188
|
+
otp
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
# conftest.py — register a secret obtained at runtime, or add keys programmatically
|
|
193
|
+
from pytest_api_core import add_sensitive_keys, register_secret
|
|
194
|
+
|
|
195
|
+
register_secret(session_token) # scrubbed everywhere from now on
|
|
196
|
+
add_sensitive_keys("x-tenant-signature")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Standalone helpers don't know key names, so pass `sensitive=True` to mask their values:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
assert_is_not_empty(token, sensitive=True)
|
|
203
|
+
assert_equal(actual_secret, expected_secret, sensitive=True)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Notes**
|
|
207
|
+
|
|
208
|
+
- Masking is deliberately conservative: a key like `token_type` is masked too.
|
|
209
|
+
- Registered secrets shorter than 4 characters are ignored, to avoid mangling unrelated output.
|
|
210
|
+
- Plain `assert` statements are reported by pytest itself (assertion introspection), which the
|
|
211
|
+
framework can't intercept — prefer `assert_that(...)` / the `assert_*` helpers for secret
|
|
212
|
+
values, or `register_secret()` them so they're scrubbed from the HTML report.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
153
216
|
## Project structure
|
|
154
217
|
|
|
155
218
|
```
|
|
@@ -170,9 +233,11 @@ pytest-api-core-framework/
|
|
|
170
233
|
│ │ └── env_loader.py # .env file loading
|
|
171
234
|
│ ├── fixtures/
|
|
172
235
|
│ │ └── api_fixtures.py # api_config, auth_provider, api_client, api_*_auth fixtures
|
|
173
|
-
│
|
|
174
|
-
│
|
|
175
|
-
│
|
|
236
|
+
│ ├── reporters/
|
|
237
|
+
│ │ ├── html_reporter.py # parses __API_CALL__/__API_ASSERT__ sentinels, renders HTML report
|
|
238
|
+
│ │ └── templates/report.html
|
|
239
|
+
│ └── security/
|
|
240
|
+
│ └── redaction.py # secret masking for logs, sentinels, assertion messages, report
|
|
176
241
|
├── tests/
|
|
177
242
|
│ ├── unit/test_unit.py # mocked HTTP tests (responses library)
|
|
178
243
|
│ └── e2e/test_e2e_jsonplaceholder.py # live tests against jsonplaceholder.typicode.com
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-api-core"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.6"
|
|
8
8
|
description = "Pytest API automation framework with custom HTML reporting, reusable fixtures, and fluent assertions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -21,8 +21,9 @@ from pytest_api_core.auth.auth_handlers import (
|
|
|
21
21
|
)
|
|
22
22
|
from pytest_api_core.client.api_client import APIClient
|
|
23
23
|
from pytest_api_core.client.api_response import APIResponse
|
|
24
|
+
from pytest_api_core.security.redaction import add_sensitive_keys, register_secret
|
|
24
25
|
|
|
25
|
-
__version__ = "1.
|
|
26
|
+
__version__ = "1.1.0"
|
|
26
27
|
__all__ = [
|
|
27
28
|
"APIClient",
|
|
28
29
|
"APIResponse",
|
|
@@ -38,4 +39,6 @@ __all__ = [
|
|
|
38
39
|
"BasicAuth",
|
|
39
40
|
"APIKeyAuth",
|
|
40
41
|
"OAuth2ClientCredentials",
|
|
42
|
+
"register_secret",
|
|
43
|
+
"add_sensitive_keys",
|
|
41
44
|
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic, standalone string assertion utilities.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
from pytest_api_core.assertions import assert_equal
|
|
7
|
+
|
|
8
|
+
assert_equal(actual, expected)
|
|
9
|
+
assert_contains(actual, "substring")
|
|
10
|
+
assert_matches(actual, r"^\\d+$")
|
|
11
|
+
assert_matches_schema(actual, schema)
|
|
12
|
+
assert_is_not_empty(token, sensitive=True) # value masked in logs/report
|
|
13
|
+
|
|
14
|
+
Registered secrets (auth handler tokens, ``register_secret()``), Bearer
|
|
15
|
+
credentials and JWTs are always scrubbed from sentinels and failure messages.
|
|
16
|
+
Pass ``sensitive=True`` to mask the actual/expected values entirely.
|
|
17
|
+
|
|
18
|
+
Unlike :class:`ResponseAssertions`, these are plain functions for comparing
|
|
19
|
+
arbitrary actual/expected strings — not tied to an ``APIResponse``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import re
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from pytest_api_core.security.redaction import mask, redact, schema_error_message
|
|
30
|
+
|
|
31
|
+
_assert_log = logging.getLogger("pytest_api_core.assertions")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit(name: str, passed: bool, **detail: object) -> None:
|
|
35
|
+
"""Emit a machine-readable sentinel line captured by HTMLReporter (secrets redacted)."""
|
|
36
|
+
_assert_log.debug(
|
|
37
|
+
"__API_ASSERT__ %s",
|
|
38
|
+
json.dumps({"name": name, "passed": passed, **redact(detail)}, separators=(",", ":")),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(message: str | None, default: str) -> None:
|
|
43
|
+
raise AssertionError(f"{message}\n {redact(default)}" if message else redact(default))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _show(value: Any, sensitive: bool) -> Any:
|
|
47
|
+
"""Value as it may appear in sentinels and failure messages."""
|
|
48
|
+
return mask(value) if sensitive else value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def assert_equal(
|
|
52
|
+
actual: str, expected: str, *, message: str | None = None, sensitive: bool = False
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Assert *actual* equals *expected*."""
|
|
55
|
+
passed = actual == expected
|
|
56
|
+
shown_actual, shown_expected = _show(actual, sensitive), _show(expected, sensitive)
|
|
57
|
+
_emit("assert_equal", passed, expected=shown_expected, actual=shown_actual)
|
|
58
|
+
if not passed:
|
|
59
|
+
_fail(message, f"Expected {shown_expected!r}, got {shown_actual!r}.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def assert_not_equal(
|
|
63
|
+
actual: str, expected: str, *, message: str | None = None, sensitive: bool = False
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Assert *actual* does not equal *expected*."""
|
|
66
|
+
passed = actual != expected
|
|
67
|
+
shown_actual, shown_expected = _show(actual, sensitive), _show(expected, sensitive)
|
|
68
|
+
_emit("assert_not_equal", passed, expected=shown_expected, actual=shown_actual)
|
|
69
|
+
if not passed:
|
|
70
|
+
_fail(message, f"Expected value to differ from {shown_expected!r}, got {shown_actual!r}.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def assert_equal_ignore_case(
|
|
74
|
+
actual: str, expected: str, *, message: str | None = None, sensitive: bool = False
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Assert *actual* equals *expected*, ignoring case."""
|
|
77
|
+
passed = actual.lower() == expected.lower()
|
|
78
|
+
shown_actual, shown_expected = _show(actual, sensitive), _show(expected, sensitive)
|
|
79
|
+
_emit("assert_equal_ignore_case", passed, expected=shown_expected, actual=shown_actual)
|
|
80
|
+
if not passed:
|
|
81
|
+
_fail(message, f"Expected {shown_expected!r} (case-insensitive), got {shown_actual!r}.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def assert_contains(
|
|
85
|
+
actual: str, expected_substring: str, *, message: str | None = None, sensitive: bool = False
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Assert *actual* contains *expected_substring*."""
|
|
88
|
+
passed = expected_substring in actual
|
|
89
|
+
shown_actual = _show(actual, sensitive)
|
|
90
|
+
shown_substring = _show(expected_substring, sensitive)
|
|
91
|
+
_emit("assert_contains", passed, expected_substring=shown_substring, actual=shown_actual)
|
|
92
|
+
if not passed:
|
|
93
|
+
_fail(message, f"Expected {shown_actual!r} to contain {shown_substring!r}.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def assert_matches(
|
|
97
|
+
actual: str, pattern: str, *, message: str | None = None, sensitive: bool = False
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Assert *actual* matches the regex *pattern*."""
|
|
100
|
+
passed = re.search(pattern, actual) is not None
|
|
101
|
+
shown_actual = _show(actual, sensitive)
|
|
102
|
+
_emit("assert_matches", passed, pattern=pattern, actual=shown_actual)
|
|
103
|
+
if not passed:
|
|
104
|
+
_fail(message, f"Expected {shown_actual!r} to match /{pattern}/.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def assert_is_empty(actual: str, *, message: str | None = None, sensitive: bool = False) -> None:
|
|
108
|
+
"""Assert *actual* is an empty string."""
|
|
109
|
+
passed = actual == ""
|
|
110
|
+
shown_actual = _show(actual, sensitive)
|
|
111
|
+
_emit("assert_is_empty", passed, actual=shown_actual)
|
|
112
|
+
if not passed:
|
|
113
|
+
_fail(message, f"Expected empty string, got {shown_actual!r}.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def assert_is_not_empty(
|
|
117
|
+
actual: str, *, message: str | None = None, sensitive: bool = False
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Assert *actual* is a non-empty string."""
|
|
120
|
+
passed = actual != ""
|
|
121
|
+
_emit("assert_is_not_empty", passed, actual=_show(actual, sensitive))
|
|
122
|
+
if not passed:
|
|
123
|
+
_fail(message, "Expected non-empty string, got empty string.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def assert_matches_schema(
|
|
127
|
+
actual: object, schema: dict[str, Any], *, message: str | None = None
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Assert *actual* conforms to the given JSON Schema, collecting all errors.
|
|
130
|
+
|
|
131
|
+
Values under sensitive keys (``token``, ``password``, …) are masked in the
|
|
132
|
+
sentinel's ``actual`` payload.
|
|
133
|
+
"""
|
|
134
|
+
import jsonschema
|
|
135
|
+
|
|
136
|
+
validator = jsonschema.Draft7Validator(schema)
|
|
137
|
+
errors = sorted(validator.iter_errors(actual), key=lambda e: list(e.path))
|
|
138
|
+
passed = not errors
|
|
139
|
+
error_messages = [schema_error_message(e) for e in errors]
|
|
140
|
+
_emit(
|
|
141
|
+
"assert_matches_schema",
|
|
142
|
+
passed,
|
|
143
|
+
errors=error_messages,
|
|
144
|
+
schema=json.dumps(schema, indent=2, default=str),
|
|
145
|
+
actual=json.dumps(redact(actual), indent=2, default=str),
|
|
146
|
+
)
|
|
147
|
+
if not passed:
|
|
148
|
+
joined = "; ".join(error_messages)
|
|
149
|
+
_fail(message, f"Schema validation failed with {len(errors)} error(s): {joined}")
|
|
@@ -22,15 +22,27 @@ import re
|
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
24
|
from pytest_api_core.client.api_response import APIResponse
|
|
25
|
+
from pytest_api_core.security.redaction import (
|
|
26
|
+
is_sensitive_key,
|
|
27
|
+
is_sensitive_path,
|
|
28
|
+
mask,
|
|
29
|
+
redact,
|
|
30
|
+
redact_headers,
|
|
31
|
+
redact_text,
|
|
32
|
+
redact_url,
|
|
33
|
+
schema_error_message,
|
|
34
|
+
)
|
|
25
35
|
|
|
26
36
|
_assert_log = logging.getLogger("pytest_api_core.assertions")
|
|
27
37
|
|
|
28
38
|
|
|
29
39
|
def _emit(name: str, passed: bool, **detail: Any) -> None:
|
|
30
|
-
"""Emit a machine-readable sentinel line captured by HTMLReporter."""
|
|
40
|
+
"""Emit a machine-readable sentinel line captured by HTMLReporter (secrets redacted)."""
|
|
31
41
|
_assert_log.debug(
|
|
32
42
|
"__API_ASSERT__ %s",
|
|
33
|
-
json.dumps(
|
|
43
|
+
json.dumps(
|
|
44
|
+
{"name": name, "passed": passed, **redact(detail)}, separators=(",", ":"), default=str
|
|
45
|
+
),
|
|
34
46
|
)
|
|
35
47
|
|
|
36
48
|
|
|
@@ -54,8 +66,8 @@ class ResponseAssertions:
|
|
|
54
66
|
if not passed:
|
|
55
67
|
raise AssertionError(
|
|
56
68
|
f"Expected status {expected}, got {actual}.\n"
|
|
57
|
-
f" URL: {self.
|
|
58
|
-
f" Body: {self.
|
|
69
|
+
f" URL: {self._safe_url()}\n"
|
|
70
|
+
f" Body: {self._safe_body()[:500]}"
|
|
59
71
|
)
|
|
60
72
|
return self
|
|
61
73
|
|
|
@@ -66,7 +78,7 @@ class ResponseAssertions:
|
|
|
66
78
|
_emit("status_in", passed, expected=list(expected), actual=actual)
|
|
67
79
|
if not passed:
|
|
68
80
|
raise AssertionError(
|
|
69
|
-
f"Expected status in {expected}, got {actual}.\n" f" URL: {self.
|
|
81
|
+
f"Expected status in {expected}, got {actual}.\n" f" URL: {self._safe_url()}"
|
|
70
82
|
)
|
|
71
83
|
return self
|
|
72
84
|
|
|
@@ -78,8 +90,8 @@ class ResponseAssertions:
|
|
|
78
90
|
if not passed:
|
|
79
91
|
raise AssertionError(
|
|
80
92
|
f"Expected 2xx status, got {actual}.\n"
|
|
81
|
-
f" URL: {self.
|
|
82
|
-
f" Body: {self.
|
|
93
|
+
f" URL: {self._safe_url()}\n"
|
|
94
|
+
f" Body: {self._safe_body()[:500]}"
|
|
83
95
|
)
|
|
84
96
|
return self
|
|
85
97
|
|
|
@@ -112,7 +124,7 @@ class ResponseAssertions:
|
|
|
112
124
|
if not passed:
|
|
113
125
|
raise AssertionError(
|
|
114
126
|
f"Expected header '{name}' to be present.\n"
|
|
115
|
-
f" Headers: {
|
|
127
|
+
f" Headers: {redact_headers(self._response.headers)}"
|
|
116
128
|
)
|
|
117
129
|
return self
|
|
118
130
|
|
|
@@ -120,9 +132,13 @@ class ResponseAssertions:
|
|
|
120
132
|
"""Assert header *name* equals *expected* (case-insensitive name match)."""
|
|
121
133
|
actual = self._response.headers.get(name)
|
|
122
134
|
passed = actual == expected
|
|
135
|
+
if is_sensitive_key(name):
|
|
136
|
+
expected, actual = mask(expected), mask(actual)
|
|
123
137
|
_emit("header_equals", passed, header=name, expected=expected, actual=actual)
|
|
124
138
|
if not passed:
|
|
125
|
-
raise AssertionError(
|
|
139
|
+
raise AssertionError(
|
|
140
|
+
f"Expected header '{name}' = {redact(expected)!r}, got {redact(actual)!r}."
|
|
141
|
+
)
|
|
126
142
|
return self
|
|
127
143
|
|
|
128
144
|
def content_type_contains(self, fragment: str) -> "ResponseAssertions":
|
|
@@ -146,7 +162,7 @@ class ResponseAssertions:
|
|
|
146
162
|
if not passed:
|
|
147
163
|
raise AssertionError(
|
|
148
164
|
f"Expected JSON key '{key}' to be present.\n"
|
|
149
|
-
f" Body keys: {list(body.keys()) if isinstance(body, dict) else body}"
|
|
165
|
+
f" Body keys: {list(body.keys()) if isinstance(body, dict) else redact(body)}"
|
|
150
166
|
)
|
|
151
167
|
return self
|
|
152
168
|
|
|
@@ -154,15 +170,20 @@ class ResponseAssertions:
|
|
|
154
170
|
"""Assert top-level JSON key *key* equals *expected*."""
|
|
155
171
|
body = self._json_body()
|
|
156
172
|
if not isinstance(body, dict):
|
|
157
|
-
raise AssertionError(f"Response body is not a JSON object: {body!r}")
|
|
173
|
+
raise AssertionError(f"Response body is not a JSON object: {redact(body)!r}")
|
|
158
174
|
actual = body.get(key, _UNSET)
|
|
175
|
+
shown_expected = mask(expected) if is_sensitive_key(key) else expected
|
|
159
176
|
if actual is _UNSET:
|
|
160
|
-
_emit("key_equals", False, key=key, expected=
|
|
177
|
+
_emit("key_equals", False, key=key, expected=shown_expected, actual="<missing>")
|
|
161
178
|
raise AssertionError(f"Key '{key}' not found in response body.")
|
|
162
179
|
passed = actual == expected
|
|
163
|
-
|
|
180
|
+
shown_actual = mask(actual) if is_sensitive_key(key) else actual
|
|
181
|
+
_emit("key_equals", passed, key=key, expected=shown_expected, actual=shown_actual)
|
|
164
182
|
if not passed:
|
|
165
|
-
raise AssertionError(
|
|
183
|
+
raise AssertionError(
|
|
184
|
+
f"Expected body['{key}'] = {redact(shown_expected)!r}, "
|
|
185
|
+
f"got {redact(shown_actual)!r}."
|
|
186
|
+
)
|
|
166
187
|
return self
|
|
167
188
|
|
|
168
189
|
def body_contains(self, text: str) -> "ResponseAssertions":
|
|
@@ -171,8 +192,8 @@ class ResponseAssertions:
|
|
|
171
192
|
_emit("body_contains", passed, expected=text)
|
|
172
193
|
if not passed:
|
|
173
194
|
raise AssertionError(
|
|
174
|
-
f"Expected response body to contain {text!r}.\n"
|
|
175
|
-
f" Body (first 300 chars): {self._response.text[:300]}"
|
|
195
|
+
f"Expected response body to contain {redact(text)!r}.\n"
|
|
196
|
+
f" Body (first 300 chars): {redact_text(self._response.text)[:300]}"
|
|
176
197
|
)
|
|
177
198
|
return self
|
|
178
199
|
|
|
@@ -246,9 +267,11 @@ class ResponseAssertions:
|
|
|
246
267
|
self._json_path_value = _UNSET
|
|
247
268
|
self._json_path_path = None
|
|
248
269
|
passed = actual == expected
|
|
270
|
+
if is_sensitive_path(path):
|
|
271
|
+
expected, actual = mask(expected), mask(actual)
|
|
249
272
|
_emit("equals", passed, path=path, expected=expected, actual=actual)
|
|
250
273
|
if not passed:
|
|
251
|
-
raise AssertionError(f"Expected {expected!r}, got {actual!r}.")
|
|
274
|
+
raise AssertionError(f"Expected {redact(expected)!r}, got {redact(actual)!r}.")
|
|
252
275
|
return self
|
|
253
276
|
|
|
254
277
|
def matches(self, pattern: str) -> "ResponseAssertions":
|
|
@@ -260,9 +283,10 @@ class ResponseAssertions:
|
|
|
260
283
|
self._json_path_value = _UNSET
|
|
261
284
|
self._json_path_path = None
|
|
262
285
|
passed = re.search(pattern, actual) is not None
|
|
263
|
-
|
|
286
|
+
shown = mask(actual) if is_sensitive_path(path) else redact(actual)
|
|
287
|
+
_emit("matches", passed, path=path, pattern=pattern, actual=shown)
|
|
264
288
|
if not passed:
|
|
265
|
-
raise AssertionError(f"Expected value to match /{pattern}/, got {
|
|
289
|
+
raise AssertionError(f"Expected value to match /{pattern}/, got {shown!r}.")
|
|
266
290
|
return self
|
|
267
291
|
|
|
268
292
|
def is_not_none(self) -> "ResponseAssertions":
|
|
@@ -274,7 +298,8 @@ class ResponseAssertions:
|
|
|
274
298
|
self._json_path_value = _UNSET
|
|
275
299
|
self._json_path_path = None
|
|
276
300
|
passed = actual is not None
|
|
277
|
-
|
|
301
|
+
shown = mask(actual) if is_sensitive_path(path) else actual
|
|
302
|
+
_emit("is_not_none", passed, path=path, actual=shown)
|
|
278
303
|
if not passed:
|
|
279
304
|
raise AssertionError("Expected value to be non-null.")
|
|
280
305
|
return self
|
|
@@ -296,7 +321,7 @@ class ResponseAssertions:
|
|
|
296
321
|
if not passed:
|
|
297
322
|
raise AssertionError(
|
|
298
323
|
f"Expected response time < {max_ms}ms, got {actual:.1f}ms.\n"
|
|
299
|
-
f" URL: {self.
|
|
324
|
+
f" URL: {self._safe_url()}"
|
|
300
325
|
)
|
|
301
326
|
return self
|
|
302
327
|
|
|
@@ -315,8 +340,9 @@ class ResponseAssertions:
|
|
|
315
340
|
"jsonschema is required for schema validation: pip install jsonschema"
|
|
316
341
|
)
|
|
317
342
|
except jsonschema.ValidationError as exc:
|
|
318
|
-
|
|
319
|
-
|
|
343
|
+
detail = schema_error_message(exc)
|
|
344
|
+
_emit("matches_schema", False, actual=detail)
|
|
345
|
+
raise AssertionError(f"Schema validation failed: {detail}") from exc
|
|
320
346
|
_emit("matches_schema", True)
|
|
321
347
|
return self
|
|
322
348
|
|
|
@@ -331,9 +357,15 @@ class ResponseAssertions:
|
|
|
331
357
|
raise AssertionError(
|
|
332
358
|
f"Response body is not valid JSON.\n"
|
|
333
359
|
f" Status: {self._response.status_code}\n"
|
|
334
|
-
f" Body: {self._response.text[:200]}"
|
|
360
|
+
f" Body: {redact_text(self._response.text)[:200]}"
|
|
335
361
|
) from exc
|
|
336
362
|
|
|
363
|
+
def _safe_url(self) -> str:
|
|
364
|
+
return redact_url(self._response.url)
|
|
365
|
+
|
|
366
|
+
def _safe_body(self) -> str:
|
|
367
|
+
return redact_text(self._response.response_body_text())
|
|
368
|
+
|
|
337
369
|
|
|
338
370
|
# ---------------------------------------------------------------------------
|
|
339
371
|
# Sentinel & helpers
|
|
@@ -372,7 +404,7 @@ def _resolve_path(data: Any, path: str) -> Any:
|
|
|
372
404
|
current = current[token]
|
|
373
405
|
except (KeyError, IndexError, TypeError) as exc:
|
|
374
406
|
raise AssertionError(
|
|
375
|
-
f"Path token {token!r} not found in {type(current).__name__}: {current!r}"
|
|
407
|
+
f"Path token {token!r} not found in {type(current).__name__}: {redact(current)!r}"
|
|
376
408
|
) from exc
|
|
377
409
|
return current
|
|
378
410
|
|