diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..9bb3b42 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,266 @@ +"""Unit tests for `invisible_playwright._proxy.configure_proxy`. + +Decision-table coverage of every input partition: None/empty/direct, +SOCKS4/5/default, HTTP/HTTPS, case variants, malformed, mutation contract. +""" +import pytest + +from invisible_playwright._proxy import configure_proxy + + +# ────────────────────────────────────────────────────────────────────── +# CP1-CP7: no-op cases — return None, do NOT mutate prefs +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp1_none_proxy_returns_none(): + prefs = {} + assert configure_proxy(None, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp2_empty_dict_returns_none(): + prefs = {} + assert configure_proxy({}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp3_empty_server_returns_none(): + prefs = {} + assert configure_proxy({"server": ""}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp4_whitespace_server_returns_none(): + prefs = {} + assert configure_proxy({"server": " "}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp5_direct_scheme_returns_none(): + prefs = {} + assert configure_proxy({"server": "direct://"}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp6_direct_scheme_uppercase_returns_none(): + prefs = {} + assert configure_proxy({"server": "DIRECT://"}, prefs) is None + assert prefs == {} + + +@pytest.mark.unit +def test_cp7_direct_scheme_mixed_case_returns_none(): + prefs = {} + assert configure_proxy({"server": "DiReCt://"}, prefs) is None + assert prefs == {} + + +# ────────────────────────────────────────────────────────────────────── +# CP8-CP9: HTTP/HTTPS — passthrough (return proxy unchanged, no mutation) +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp8_http_proxy_passthrough(): + prefs = {} + proxy = {"server": "http://proxy:8080"} + result = configure_proxy(proxy, prefs) + assert result == proxy + # No SOCKS-related mutations. + assert "network.proxy.type" not in prefs + assert "network.proxy.socks" not in prefs + + +@pytest.mark.unit +def test_cp9_https_proxy_passthrough(): + prefs = {} + proxy = {"server": "https://proxy:8080"} + result = configure_proxy(proxy, prefs) + assert result == proxy + assert "network.proxy.type" not in prefs + + +@pytest.mark.unit +def test_cp8b_http_with_username_password_passthrough(): + """HTTP proxies preserve username/password for Playwright to consume.""" + prefs = {} + proxy = {"server": "http://proxy:8080", "username": "user", "password": "pw"} + result = configure_proxy(proxy, prefs) + assert result == proxy + assert "network.proxy.type" not in prefs + + +# ────────────────────────────────────────────────────────────────────── +# CP10-CP13: SOCKS — mutate prefs, return None +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp10_socks5_with_credentials(): + prefs = {} + proxy = { + "server": "socks5://host:1080", + "username": "u", + "password": "p", + } + result = configure_proxy(proxy, prefs) + assert result is None + assert prefs["network.proxy.type"] == 1 + assert prefs["network.proxy.socks"] == "host" + assert prefs["network.proxy.socks_port"] == 1080 + assert prefs["network.proxy.socks_version"] == 5 + assert prefs["network.proxy.socks_username"] == "u" + assert prefs["network.proxy.socks_password"] == "p" + assert prefs["network.proxy.socks_remote_dns"] is True + + +@pytest.mark.unit +def test_cp11_socks4_sets_version_4(): + prefs = {} + configure_proxy({"server": "socks4://host:1080"}, prefs) + assert prefs["network.proxy.socks_version"] == 4 + + +@pytest.mark.unit +def test_cp12_bare_socks_defaults_to_v5(): + prefs = {} + configure_proxy({"server": "socks://host:1080"}, prefs) + assert prefs["network.proxy.socks_version"] == 5 + + +@pytest.mark.unit +def test_cp13_socks_scheme_is_case_insensitive(): + prefs = {} + proxy = {"server": "SOCKS5://HOST:1080"} + result = configure_proxy(proxy, prefs) + assert result is None + assert prefs["network.proxy.type"] == 1 + # Host preserves case (only the scheme is case-folded). + assert prefs["network.proxy.socks"] == "HOST" + assert prefs["network.proxy.socks_version"] == 5 + + +# ────────────────────────────────────────────────────────────────────── +# CP14-CP15: edge SOCKS inputs +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp14_socks_without_port_dropped_silently(): + prefs = {} + result = configure_proxy({"server": "socks5://hostonly"}, prefs) + assert result is None + # Malformed input drops silently — no mutations. + assert "network.proxy.type" not in prefs + assert "network.proxy.socks" not in prefs + + +@pytest.mark.unit +def test_cp15_socks_without_credentials_uses_empty_strings(): + prefs = {} + configure_proxy({"server": "socks5://host:1080"}, prefs) + assert prefs["network.proxy.socks_username"] == "" + assert prefs["network.proxy.socks_password"] == "" + + +@pytest.mark.unit +def test_cp15b_socks_with_none_credentials_uses_empty_strings(): + """`proxy.get("username")` returning None should resolve to "".""" + prefs = {} + configure_proxy( + {"server": "socks5://host:1080", "username": None, "password": None}, + prefs, + ) + assert prefs["network.proxy.socks_username"] == "" + assert prefs["network.proxy.socks_password"] == "" + + +# ────────────────────────────────────────────────────────────────────── +# CP16: mutation contract — prefs dict mutated in-place +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp16_prefs_mutated_in_place(): + """Caller's prefs dict receives the SOCKS keys directly (not a copy).""" + prefs = {"existing.pref": "kept"} + sentinel = prefs + configure_proxy({"server": "socks5://host:1080"}, prefs) + # Same object identity — mutated, not replaced. + assert prefs is sentinel + # Existing pref preserved. + assert prefs["existing.pref"] == "kept" + # SOCKS keys added. + assert "network.proxy.type" in prefs + assert "network.proxy.socks" in prefs + + +# ────────────────────────────────────────────────────────────────────── +# CP17: boundary — IPv6-style host preserved via rsplit +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_cp17_ipv6_bracketed_host_preserved_via_rsplit(): + """rsplit(':', 1) keeps brackets intact for `[::1]:1080`-style hosts.""" + prefs = {} + configure_proxy({"server": "socks5://[::1]:1080"}, prefs) + assert prefs["network.proxy.socks"] == "[::1]" + assert prefs["network.proxy.socks_port"] == 1080 + + +# ────────────────────────────────────────────────────────────────────── +# Recheck additions — branches discovered while re-reading _proxy.py +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_socks_with_surrounding_whitespace_in_server_stripped(): + """The implementation strips whitespace before scheme checks.""" + prefs = {} + result = configure_proxy({"server": " socks5://host:1080 "}, prefs) + assert result is None + assert prefs["network.proxy.socks"] == "host" + assert prefs["network.proxy.socks_port"] == 1080 + + +@pytest.mark.unit +def test_server_key_missing_returns_none(): + """No 'server' key → treated as empty → no-op.""" + prefs = {} + result = configure_proxy({"username": "u"}, prefs) + assert result is None + assert prefs == {} + + +@pytest.mark.unit +def test_server_key_none_returns_none(): + """`server: None` is normalized to "" by the implementation.""" + prefs = {} + result = configure_proxy({"server": None}, prefs) + assert result is None + assert prefs == {} + + +@pytest.mark.unit +def test_socks_port_coerced_to_int(): + """Port string is parsed via int() — not a numeric string.""" + prefs = {} + configure_proxy({"server": "socks5://host:443"}, prefs) + assert prefs["network.proxy.socks_port"] == 443 + assert isinstance(prefs["network.proxy.socks_port"], int) + + +@pytest.mark.unit +def test_socks_non_numeric_port_raises_value_error(): + """Non-numeric port is a programmer error — int() raises.""" + prefs = {} + with pytest.raises(ValueError): + configure_proxy({"server": "socks5://host:notaport"}, prefs)