| 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 |