pytest-api-core 1.0.5__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.
Files changed (34) hide show
  1. {pytest_api_core-1.0.5/src/pytest_api_core.egg-info → pytest_api_core-1.0.6}/PKG-INFO +71 -6
  2. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/README.md +70 -5
  3. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/pyproject.toml +1 -1
  4. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/__init__.py +4 -1
  5. pytest_api_core-1.0.6/src/pytest_api_core/assertions/assert_utils.py +149 -0
  6. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/assertions/response_assertions.py +57 -25
  7. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/auth/auth_handlers.py +9 -1
  8. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/client/api_client.py +31 -16
  9. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/config/config_manager.py +3 -1
  10. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/plugin.py +21 -0
  11. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/html_reporter.py +9 -7
  12. pytest_api_core-1.0.6/src/pytest_api_core/security/__init__.py +25 -0
  13. pytest_api_core-1.0.6/src/pytest_api_core/security/redaction.py +220 -0
  14. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6/src/pytest_api_core.egg-info}/PKG-INFO +71 -6
  15. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/SOURCES.txt +3 -1
  16. pytest_api_core-1.0.5/src/pytest_api_core/assertions/assert_utils.py +0 -114
  17. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/LICENSE +0 -0
  18. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/MANIFEST.in +0 -0
  19. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/setup.cfg +0 -0
  20. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/assertions/__init__.py +0 -0
  21. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/auth/__init__.py +0 -0
  22. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/client/__init__.py +0 -0
  23. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/client/api_response.py +0 -0
  24. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/config/__init__.py +0 -0
  25. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/config/base_settings.py +0 -0
  26. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/config/env_loader.py +0 -0
  27. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/fixtures/__init__.py +0 -0
  28. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/fixtures/api_fixtures.py +0 -0
  29. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/__init__.py +0 -0
  30. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core/reporters/templates/report.html +0 -0
  31. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/dependency_links.txt +0 -0
  32. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/entry_points.txt +0 -0
  33. {pytest_api_core-1.0.5 → pytest_api_core-1.0.6}/src/pytest_api_core.egg-info/requires.txt +0 -0
  34. {pytest_api_core-1.0.5 → 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.5
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.5
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.5"
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
- │ └── reporters/
240
- │ ├── html_reporter.py # parses __API_CALL__/__API_ASSERT__ sentinels, renders HTML report
241
- │ └── templates/report.html
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.5
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.5"
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
- │ └── reporters/
174
- │ ├── html_reporter.py # parses __API_CALL__/__API_ASSERT__ sentinels, renders HTML report
175
- │ └── templates/report.html
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.5"
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.0.0"
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({"name": name, "passed": passed, **detail}, separators=(",", ":")),
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._response.url}\n"
58
- f" Body: {self._response.response_body_text()[:500]}"
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._response.url}"
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._response.url}\n"
82
- f" Body: {self._response.response_body_text()[:500]}"
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: {dict(self._response.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(f"Expected header '{name}' = {expected!r}, got {actual!r}.")
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=expected, actual="<missing>")
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
- _emit("key_equals", passed, key=key, expected=expected, actual=actual)
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(f"Expected body['{key}'] = {expected!r}, got {actual!r}.")
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
- _emit("matches", passed, path=path, pattern=pattern, actual=actual)
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 {actual!r}.")
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
- _emit("is_not_none", passed, path=path, actual=actual)
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._response.url}"
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
- _emit("matches_schema", False, actual=exc.message)
319
- raise AssertionError(f"Schema validation failed: {exc.message}") from exc
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