返回 douyin-downloader
test_notifier_redactor_property.py
根目录 / tests / test_notifier_redactor_property.py
1 # Feature: desktop-workflow-polish, Property F: stderr / config redactor never leaks credentials
2 """Hypothesis property tests for ``utils.notifier`` credential redactors.
3
4 Validates Property F: stderr / config redactor never leaks credentials
5 Validates: Requirements 3.14, 7.10
6
7 The two helpers under test are:
8
9 - ``_mask_credential(value)`` — masks an individual token.
10 - ``_masked_config_for_log(type, config)`` — returns a deep-copied provider
11 config with ``bark.device_key`` / ``telegram.bot_token`` / webhook URL
12 query-string values masked.
13
14 Properties covered for ``_mask_credential``:
15
16 For any token ``v`` with ``len(v) >= 8``:
17 - the middle portion ``v[4:-4]`` is NOT a substring of the masked output
18 - the masked output starts with ``v[:4]`` and ends with ``v[-4:]``
19 - the masked output contains the literal ``***``
20 - the masked output does not grow pathologically
21 (``len(out) < len(v) + 16``)
22
23 Properties covered for ``_masked_config_for_log`` (JSON-serialized output):
24
25 For each provider type (bark / telegram / webhook):
26 - the original secret's middle portion never appears in
27 ``json.dumps(masked)`` — i.e. the secret cannot be recovered via a
28 JSON config dump that callers use for log redaction.
29 """
30
31 from __future__ import annotations
32
33 import json
34 from urllib.parse import quote
35
36 from hypothesis import given
37 from hypothesis import settings as hyp_settings
38 from hypothesis import strategies as st
39
40 from utils.notifier import _mask_credential, _masked_config_for_log
41
42 # The literal mask sentinel emitted by ``_mask_credential``. Any middle-leak
43 # assertion must tolerate substrings of this marker — otherwise a generator
44 # that happens to produce a token whose middle is only ``*`` characters will
45 # falsely flag the mask itself as a leak (e.g. token='0000*0000' produces
46 # masked='0000***0000'; the lone '*' in the middle is indistinguishable from
47 # the sentinel and is NOT a credential leak).
48 _MASK_SENTINEL = "***"
49
50
51 # ---------------------------------------------------------------------------
52 # Generators
53 # ---------------------------------------------------------------------------
54
55 # Arbitrary unicode text — exercises that the redactor handles any byte-safe
56 # credential (Bark device keys, Telegram bot tokens, and webhook query values
57 # may in practice contain only ASCII, but we are defensive in depth).
58 _token_strategy = st.text(min_size=8, max_size=200)
59
60 # Token alphabet used specifically for the webhook-URL test. Hex chars let
61 # us fuzz byte-like credentials while staying disjoint from the lowercase
62 # ASCII we use for host / path / param names, so the middle-leak assertion
63 # measures what it's supposed to — an actual leak of the middle token bytes
64 # — instead of an accidental substring match against the URL structure.
65 _webhook_token_strategy = st.text(
66 alphabet="0123456789abcdef",
67 min_size=16,
68 max_size=200,
69 )
70
71 # Safe alphabet for query param names + URL paths/hosts so that a generated
72 # URL is guaranteed to round-trip through ``urlsplit`` / ``urlencode`` without
73 # the helper choosing to encode special characters inside the masked value.
74 # Intentionally excludes hex digits (0-9a-f) so `_webhook_token_strategy` and
75 # these never collide in a way that would produce spurious failures.
76 _url_safe_alphabet = "ghijklmnopqrstuvwxyzGHIJKLMNOPQRSTUVWXYZ-_."
77 _url_safe_text = st.text(alphabet=_url_safe_alphabet, min_size=1, max_size=20)
78
79
80 # ---------------------------------------------------------------------------
81 # Property: _mask_credential
82 # ---------------------------------------------------------------------------
83
84
85 @given(value=_token_strategy)
86 @hyp_settings(max_examples=100)
87 def test_mask_credential_never_leaks_middle(value: str) -> None:
88 """For any ``v`` with ``len(v) >= 8`` the middle ``v[4:-4]`` is scrubbed.
89
90 Additionally: the masked output keeps the 4-char prefix and 4-char suffix,
91 contains ``'***'``, and does not expand by more than 16 characters.
92 """
93 out = _mask_credential(value)
94
95 assert isinstance(out, str)
96 assert "***" in out
97 assert out.startswith(value[:4])
98 assert out.endswith(value[-4:])
99
100 middle = value[4:-4]
101 # Skip the middle-leak assertion when the middle is unavoidably a
102 # substring of (a) the preserved 4+4 edges, or (b) the mask sentinel
103 # ``***`` itself. Case (a) covers e.g. value='000000000' where
104 # middle='0' trivially appears in preserved='00000000'; case (b) covers
105 # middles consisting only of '*' characters (e.g. value='0000*0000')
106 # which are indistinguishable from the sentinel the masker emits.
107 middle_in_preserved = len(middle) > 0 and (middle in value[:4] or middle in value[-4:])
108 middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL
109 if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel:
110 # The whole middle portion must be gone. Note: a *prefix* or *suffix*
111 # of the middle can legally still appear if it happens to match the
112 # first/last 4 preserved characters, but the full span cannot.
113 assert middle not in out, f"Middle portion {middle!r} leaked into masked output {out!r}"
114
115 # No pathological expansion: ``first4 + '***' + last4`` is 11 chars; we
116 # allow some slack but refuse the redactor growing unboundedly.
117 assert len(out) < len(value) + 16
118
119
120 @given(value=st.text(max_size=7))
121 @hyp_settings(max_examples=100)
122 def test_mask_credential_short_inputs_collapse_to_stars(value: str) -> None:
123 """Inputs shorter than 8 chars must collapse to exactly ``'***'``.
124
125 This proves that partial redaction never accidentally reveals a short
126 secret such as a 4-char PIN.
127 """
128 assert _mask_credential(value) == "***"
129
130
131 # ---------------------------------------------------------------------------
132 # Property: _masked_config_for_log — bark
133 # ---------------------------------------------------------------------------
134
135
136 @given(device_key=_token_strategy)
137 @hyp_settings(max_examples=100)
138 def test_masked_config_bark_device_key_never_leaks(device_key: str) -> None:
139 """Bark ``device_key`` middle portion must not appear in serialized output."""
140 config = {
141 "type": "bark",
142 "device_key": device_key,
143 "sound": "bell",
144 }
145 masked = _masked_config_for_log("bark", config)
146 serialized = json.dumps(masked, ensure_ascii=False)
147
148 # The original config must not have been mutated in place.
149 assert config["device_key"] == device_key
150
151 middle = device_key[4:-4]
152 # Skip when the middle is unavoidably present in either the preserved
153 # 4+4 edges or the mask sentinel itself (see `_MASK_SENTINEL` comment
154 # at top of file for rationale).
155 middle_in_preserved = len(middle) > 0 and (
156 middle in device_key[:4] or middle in device_key[-4:]
157 )
158 middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL
159 if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel:
160 # Check the masked field value directly, not the full JSON blob:
161 # incidental bytes in other fields (e.g. `"sound": "bell"`) could
162 # coincidentally contain the middle character and defeat the
163 # assertion without representing a credential leak.
164 assert middle not in masked["device_key"], (
165 f"device_key middle {middle!r} leaked into masked field {masked['device_key']!r}"
166 )
167 # Sanity: the serialized JSON exists and contains the mask marker.
168 assert "***" in serialized
169
170 # The masked representation must still carry a marker and the short
171 # prefix so operators can recognise which key was configured.
172 assert "***" in masked["device_key"]
173 assert masked["device_key"].startswith(device_key[:4])
174 assert masked["device_key"].endswith(device_key[-4:])
175
176 # Non-sensitive fields untouched.
177 assert masked["sound"] == "bell"
178
179
180 # ---------------------------------------------------------------------------
181 # Property: _masked_config_for_log — telegram
182 # ---------------------------------------------------------------------------
183
184
185 @given(bot_token=_token_strategy)
186 @hyp_settings(max_examples=100)
187 def test_masked_config_telegram_bot_token_never_leaks(bot_token: str) -> None:
188 """Telegram ``bot_token`` middle portion must not appear in serialized output."""
189 config = {
190 "type": "telegram",
191 "bot_token": bot_token,
192 # Distinct suffix so `chat_id` can never spuriously appear in
193 # `bot_token[4:-4]` and defeat the leak assertion.
194 "chat_id": "CHAT_ID_PLACEHOLDER",
195 }
196 masked = _masked_config_for_log("telegram", config)
197
198 assert config["bot_token"] == bot_token # no in-place mutation
199
200 middle = bot_token[4:-4]
201 # Skip when the middle is unavoidably present in either the preserved
202 # 4+4 edges or the mask sentinel itself (see `_MASK_SENTINEL` comment
203 # at top of file for rationale).
204 middle_in_preserved = len(middle) > 0 and (middle in bot_token[:4] or middle in bot_token[-4:])
205 middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL
206 if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel:
207 # Check masked field value only — see bark test for rationale.
208 assert middle not in masked["bot_token"], (
209 f"bot_token middle {middle!r} leaked into masked field {masked['bot_token']!r}"
210 )
211
212 assert "***" in masked["bot_token"]
213 assert masked["chat_id"] == "CHAT_ID_PLACEHOLDER"
214
215
216 # ---------------------------------------------------------------------------
217 # Property: _masked_config_for_log — webhook url query-string
218 # ---------------------------------------------------------------------------
219
220
221 @given(
222 token=_webhook_token_strategy,
223 host=_url_safe_text,
224 path=_url_safe_text,
225 param=_url_safe_text,
226 )
227 @hyp_settings(max_examples=100)
228 def test_masked_config_webhook_url_query_never_leaks(
229 token: str, host: str, path: str, param: str
230 ) -> None:
231 """Webhook URL query values are masked; path/host are preserved.
232
233 The token is URL-quoted before being placed into the query so that the
234 generated URL is always well-formed and the value inside the query string
235 is exactly the original token. After masking we check that the original
236 token's middle portion cannot be recovered from either the URL itself or
237 the JSON-serialized config.
238 """
239 url = "https://{host}/{path}?{param}={value}".format(
240 host=host, path=path, param=param, value=quote(token, safe="")
241 )
242 config = {"type": "webhook", "url": url}
243 masked = _masked_config_for_log("webhook", config)
244 serialized = json.dumps(masked, ensure_ascii=False)
245
246 # Original config untouched.
247 assert config["url"] == url
248
249 middle = token[4:-4]
250 # Same repetitive-middle caveat as the bark / telegram tests above.
251 middle_in_preserved = len(middle) > 0 and (middle in token[:4] or middle in token[-4:])
252 if len(middle) > 0 and not middle_in_preserved:
253 assert middle not in masked["url"], (
254 f"token middle {middle!r} leaked into masked URL {masked['url']!r}"
255 )
256 assert middle not in serialized
257
258 # Host + path must be preserved exactly so operators can still see which
259 # endpoint was configured.
260 assert host in masked["url"]
261 assert path in masked["url"]
262 # The param name is preserved; only its value is masked.
263 assert param + "=" in masked["url"]
264
265
266 # ---------------------------------------------------------------------------
267 # Property: unknown provider types leave config untouched
268 # ---------------------------------------------------------------------------
269
270
271 @given(secret=_token_strategy)
272 @hyp_settings(max_examples=100)
273 def test_masked_config_unknown_type_returns_deep_copy(secret: str) -> None:
274 """Unknown provider types do not crash and return a deep copy.
275
276 This guarantees callers can route any provider type through the helper
277 without special-casing. For an unknown type we do not attempt to guess
278 which fields are sensitive; the unmodified-but-deep-copied payload is
279 returned so the caller can log or discard at its discretion.
280 """
281 config = {"type": "unknown", "secret": secret}
282 masked = _masked_config_for_log("unknown", config)
283
284 # Deep copy: mutating one must not affect the other.
285 masked["secret"] = "CHANGED"
286 assert config["secret"] == secret
287
287 lines PYTHON