返回 douyin-downloader
test_proxy_validator_parity.py
根目录 / tests / test_proxy_validator_parity.py
1 # Feature: desktop-workflow-polish, Property C: Proxy_Probe parameter validation parity with _is_valid_proxy
2 """Hypothesis property tests for ``server.app._is_valid_proxy`` parity.
3
4 Validates Property C: Proxy_Probe parameter validation parity with
5 ``_is_valid_proxy``
6 Validates: Requirements 8.2, 8.9
7
8 This file is the **Python half** of a bit-for-bit parity contract between
9 the desktop renderer's ``isValidProxy`` (TypeScript) and the sidecar's
10 ``_is_valid_proxy`` (Python). The two must agree for every input so that
11 the renderer can disable the "测试代理" button for strings the server will
12 reject with HTTP 400 — no round trip required.
13
14 Two sources of samples are combined:
15
16 1. A static fixture ``tests/fixtures/proxy_samples.json`` that both sides
17 replay. If the fixture is missing the test gracefully skips the fixture
18 replay but keeps the Hypothesis generator portion running (useful during
19 iterative development before task 7.2 lands).
20 2. Hypothesis-generated arbitrary strings, biased with a weighted sample
21 pool so the shrinker explores representative edge cases — every legal
22 scheme, non-whitelisted schemes, empty host, whitespace-only host, mixed
23 case, and random unicode.
24
25 Kept Python 3.8 compatible (no walrus, no ``match`` statements, no PEP-604
26 union syntax). The file is shared with the CLI project per task 7.1, so the
27 imports must stay portable.
28 """
29
30 from __future__ import annotations
31
32 import json
33 import os
34 from typing import Any, Dict, List, Optional
35
36 import pytest
37 from hypothesis import given
38 from hypothesis import settings as hyp_settings
39 from hypothesis import strategies as st
40
41 # Sidecar-only symbols. In environments that ship a stripped-down
42 # `server/app.py` (e.g. the CLI project before the proxy-validator
43 # feature lands there), the import would break collection. Downgrade
44 # that to a module-level skip so `pytest tests/` still runs the rest
45 # of the suite cleanly.
46 try:
47 from server.app import _PROXY_ALLOWED_SCHEMES, _is_valid_proxy
48 except ImportError as _exc:
49 pytest.skip(
50 f"server.app proxy validator symbols unavailable in this worktree: {_exc}",
51 allow_module_level=True,
52 )
53
54 _FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "fixtures", "proxy_samples.json")
55
56
57 def _load_fixture_samples() -> List[Dict[str, Any]]:
58 """Return the samples array from the parity fixture, or [] if missing.
59
60 The fixture is shared with the TypeScript half of Property C via task
61 7.1's sync script. During iterative development (or on a stripped-down
62 worktree) the fixture may not exist yet; in that case we silently fall
63 back to an empty list so the Hypothesis portion of this file still runs.
64 """
65 if not os.path.exists(_FIXTURE_PATH):
66 return []
67 try:
68 with open(_FIXTURE_PATH, "r", encoding="utf-8") as f:
69 payload = json.load(f)
70 except (OSError, ValueError):
71 return []
72 samples = payload.get("samples")
73 if not isinstance(samples, list):
74 return []
75 return samples
76
77
78 _FIXTURE_SAMPLES = _load_fixture_samples()
79
80
81 # ---------------------------------------------------------------------------
82 # Fixture replay — one parametrised test per row
83 # ---------------------------------------------------------------------------
84
85
86 @pytest.mark.skipif(
87 not _FIXTURE_SAMPLES,
88 reason=(
89 "tests/fixtures/proxy_samples.json missing; skip fixture replay "
90 "(task 7.2 creates the fixture)."
91 ),
92 )
93 @pytest.mark.parametrize(
94 "sample",
95 _FIXTURE_SAMPLES,
96 ids=[
97 # Avoid huge / unreadable ids: just expose the input string (truncated)
98 # and the expected validity. The shape is ``{input, expected: {isValid}}``.
99 "[{}]{}".format(
100 "OK " if s.get("expected", {}).get("isValid") else "NG ",
101 (s.get("input", "") or "<empty>")[:40],
102 )
103 for s in _FIXTURE_SAMPLES
104 ],
105 )
106 def test_is_valid_proxy_matches_fixture(sample: Dict[str, Any]) -> None:
107 """Every fixture sample's ``expected.isValid`` must match ``_is_valid_proxy``.
108
109 The parity contract is bit-for-bit: if ``isValid=true`` the Python half
110 accepts the string; if ``isValid=false`` the Python half rejects it.
111 Schema: ``{input: string, expected: {isValid: bool, scheme?: string}}``.
112 """
113 input_value = sample.get("input")
114 expected = sample.get("expected") or {}
115 expected_valid = bool(expected.get("isValid"))
116
117 assert isinstance(input_value, str), "fixture row must carry a string `input`; got {!r}".format(
118 input_value
119 )
120
121 actual_valid = _is_valid_proxy(input_value)
122 assert actual_valid == expected_valid, "parity break: input={!r} expected={} got={}".format(
123 input_value, expected_valid, actual_valid
124 )
125
126
127 # ---------------------------------------------------------------------------
128 # Hypothesis generator — weighted sample pool + arbitrary strings
129 # ---------------------------------------------------------------------------
130
131
132 _LEGAL_SCHEMES = list(_PROXY_ALLOWED_SCHEMES)
133 _ILLEGAL_SCHEMES = ["ftp", "ws", "wss", "socks", "socks4", "tcp", "rsync", "file"]
134
135
136 def _scheme_with_host_strategy() -> st.SearchStrategy[str]:
137 """Generate ``<legal_scheme>://<host>`` strings with non-blank hosts.
138
139 Hosts can be IPv4/IPv6/hostnames/with-ports/with-auth; we just need a
140 post-scheme remainder with at least one non-whitespace byte so
141 ``_is_valid_proxy`` accepts them.
142 """
143 scheme = st.sampled_from(_LEGAL_SCHEMES)
144 # Restrict host to printable ASCII so we can reason about "non-whitespace
145 # after scheme" without unicode ambiguity. Exclude control chars.
146 host = st.text(
147 alphabet=st.characters(
148 min_codepoint=33,
149 max_codepoint=126, # printable ASCII, no space
150 ),
151 min_size=1,
152 max_size=60,
153 )
154 return st.builds(lambda s, h: "{}://{}".format(s, h), scheme, host)
155
156
157 def _illegal_scheme_strategy() -> st.SearchStrategy[str]:
158 """Generate strings with a non-whitelisted scheme."""
159 scheme = st.sampled_from(_ILLEGAL_SCHEMES)
160 host = st.text(
161 alphabet=st.characters(min_codepoint=33, max_codepoint=126),
162 min_size=1,
163 max_size=40,
164 )
165 return st.builds(lambda s, h: "{}://{}".format(s, h), scheme, host)
166
167
168 def _empty_or_blank_host_strategy() -> st.SearchStrategy[str]:
169 """Generate ``<legal_scheme>://`` + optional whitespace-only tail."""
170 scheme = st.sampled_from(_LEGAL_SCHEMES)
171 ws = st.text(alphabet=" \t\n\r", min_size=0, max_size=8)
172 return st.builds(lambda s, w: "{}://{}".format(s, w), scheme, ws)
173
174
175 def _whitespace_only_strategy() -> st.SearchStrategy[str]:
176 """Generate pure-whitespace strings (including empty)."""
177 return st.text(alphabet=" \t\n\r", min_size=0, max_size=8)
178
179
180 def _mixed_case_scheme_strategy() -> st.SearchStrategy[str]:
181 """Generate schemes with uppercase letters to exercise case sensitivity.
182
183 ``_PROXY_RE`` is case-sensitive, so ``HTTP://example.com`` must be
184 rejected even though the scheme name is a whitelisted one.
185 """
186 # Hand-crafted mixed-case variants — Hypothesis sampled_from keeps the
187 # search space tight and the shrinker well-behaved.
188 variants = [
189 "HTTP",
190 "HTTPS",
191 "SOCKS5",
192 "SOCKS5H",
193 "Http",
194 "HtTp",
195 "HTtPs",
196 "Socks5",
197 "Socks5H",
198 ]
199 scheme = st.sampled_from(variants)
200 host = st.text(
201 alphabet=st.characters(min_codepoint=33, max_codepoint=126),
202 min_size=1,
203 max_size=40,
204 )
205 return st.builds(lambda s, h: "{}://{}".format(s, h), scheme, host)
206
207
208 _WEIGHTED_POOL = st.one_of(
209 _scheme_with_host_strategy(),
210 _illegal_scheme_strategy(),
211 _empty_or_blank_host_strategy(),
212 _whitespace_only_strategy(),
213 _mixed_case_scheme_strategy(),
214 # Fully arbitrary text to catch cases the hand-crafted pools miss.
215 st.text(max_size=120),
216 )
217
218
219 def _expected_is_valid(s: str) -> bool:
220 """Reference oracle mirroring the exact contract of ``_is_valid_proxy``.
221
222 The oracle is derived from :data:`server.app._PROXY_RE` and the docstring
223 on :func:`server.app._is_valid_proxy`:
224
225 - Empty string → valid (means "no proxy").
226 - Otherwise must match ``^(https?|socks5h?)://(.+)$`` (case-sensitive)
227 AND the post-scheme host portion must contain at least one
228 non-whitespace character.
229 """
230 if s == "":
231 return True
232 # Case-sensitive scheme check.
233 for scheme in ("http", "https", "socks5h", "socks5"):
234 # Order matters: match ``socks5h`` before ``socks5`` so the longer
235 # prefix wins.
236 prefix = scheme + "://"
237 if s.startswith(prefix):
238 host = s[len(prefix) :]
239 return host.strip() != ""
240 return False
241
242
243 # ---------------------------------------------------------------------------
244 # Property: oracle / implementation agreement
245 # ---------------------------------------------------------------------------
246
247
248 @given(value=_WEIGHTED_POOL)
249 @hyp_settings(max_examples=100)
250 def test_is_valid_proxy_matches_oracle(value: str) -> None:
251 """Across all generated inputs the oracle and the implementation agree.
252
253 The oracle mirrors the documented contract on :func:`_is_valid_proxy`.
254 If this test fails, either the regex in ``_PROXY_RE`` drifted from its
255 docstring, or the oracle above needs updating — both require a code
256 review of the parity fixture too (task 7.2).
257 """
258 actual = _is_valid_proxy(value)
259 expected = _expected_is_valid(value)
260 assert actual == expected, "parity break: value={!r} oracle={} actual={}".format(
261 value, expected, actual
262 )
263
264
265 # ---------------------------------------------------------------------------
266 # Property: whitelist invariant
267 # ---------------------------------------------------------------------------
268
269
270 @given(value=_WEIGHTED_POOL)
271 @hyp_settings(max_examples=100)
272 def test_non_empty_valid_proxy_has_whitelisted_scheme(value: str) -> None:
273 """Non-empty inputs that pass validation must start with a legal scheme.
274
275 Complements the oracle test: proves that ``_is_valid_proxy`` never
276 "silently accepts" a scheme that the ``POST /api/v1/network/test-proxy``
277 handler would subsequently 400 on (Requirement 8.9). Empty string is
278 the well-known exception (means "no proxy").
279 """
280 if not _is_valid_proxy(value):
281 return
282 if value == "":
283 return
284 scheme_prefix: Optional[str] = None
285 for scheme in ("socks5h", "socks5", "https", "http"):
286 candidate = scheme + "://"
287 if value.startswith(candidate):
288 scheme_prefix = scheme
289 break
290 assert scheme_prefix is not None, (
291 "value passed _is_valid_proxy but has no whitelisted scheme prefix: {!r}".format(value)
292 )
293 assert scheme_prefix in _PROXY_ALLOWED_SCHEMES
294
294 lines PYTHON