Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Tests for the proxy resolution layer (#655)."""
|
|
|
|
import os
|
|
from typing import Any, Dict, Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from vocalinux.utils.proxy import (
|
|
DEFAULT_HTTPS_PROXY_PORT,
|
|
DEFAULT_PROXY_MODE,
|
|
DEFAULT_PROXY_PROTOCOL,
|
|
DEFAULT_SOCKS5_PORT,
|
|
default_port_for_protocol,
|
|
effective_proxy_config,
|
|
manual_proxy_url,
|
|
normalize_proxy_mode,
|
|
normalize_proxy_protocol,
|
|
proxy_env,
|
|
proxy_env_vars,
|
|
requests_proxies,
|
|
urllib_proxy_handler,
|
|
)
|
|
|
|
|
|
def _manual_config(**overrides: Any) -> Dict[str, Any]:
|
|
"""Build a manual-mode proxy config dict with sensible defaults."""
|
|
config: Dict[str, Any] = {
|
|
"mode": "manual",
|
|
"protocol": "socks5",
|
|
"host": "proxy.example.com",
|
|
"port": 1080,
|
|
"username": "",
|
|
"password": "",
|
|
}
|
|
config.update(overrides)
|
|
return config
|
|
|
|
|
|
class TestModeAndProtocolParsing:
|
|
def test_default_mode_is_system(self) -> None:
|
|
assert DEFAULT_PROXY_MODE == "system"
|
|
assert normalize_proxy_mode(None) == "system"
|
|
assert normalize_proxy_mode("") == "system"
|
|
|
|
def test_mode_normalization(self) -> None:
|
|
assert normalize_proxy_mode("Off") == "off"
|
|
assert normalize_proxy_mode(" MANUAL ") == "manual"
|
|
assert normalize_proxy_mode("bogus") == "system"
|
|
|
|
def test_default_protocol_is_socks5(self) -> None:
|
|
assert DEFAULT_PROXY_PROTOCOL == "socks5"
|
|
assert normalize_proxy_protocol(None) == "socks5"
|
|
assert normalize_proxy_protocol("") == "socks5"
|
|
|
|
def test_protocol_normalization(self) -> None:
|
|
assert normalize_proxy_protocol("HTTPS") == "https"
|
|
assert normalize_proxy_protocol("bogus") == "socks5"
|
|
|
|
def test_default_ports(self) -> None:
|
|
assert default_port_for_protocol("socks5") == DEFAULT_SOCKS5_PORT
|
|
assert default_port_for_protocol("https") == DEFAULT_HTTPS_PROXY_PORT
|
|
assert default_port_for_protocol("bogus") == DEFAULT_SOCKS5_PORT
|
|
|
|
|
|
class TestManualProxyUrl:
|
|
def test_socks5_uses_remote_dns_scheme(self) -> None:
|
|
url = manual_proxy_url(_manual_config())
|
|
assert url == "socks5h://proxy.example.com:1080"
|
|
|
|
def test_https_uses_http_connect_scheme(self) -> None:
|
|
url = manual_proxy_url(_manual_config(protocol="https", port=8080))
|
|
assert url == "http://proxy.example.com:8080"
|
|
|
|
def test_auth_is_url_encoded(self) -> None:
|
|
url = manual_proxy_url(_manual_config(username="us er", password="p@ss:word"))
|
|
assert url == "socks5h://us%20er:p%40ss%3Aword@proxy.example.com:1080"
|
|
|
|
def test_username_without_password(self) -> None:
|
|
url = manual_proxy_url(_manual_config(username="alice"))
|
|
assert url == "socks5h://alice@proxy.example.com:1080"
|
|
|
|
def test_password_without_username_is_ignored(self) -> None:
|
|
# Proxy auth always needs a user; a bare password must not produce
|
|
# a "user-less" authority component.
|
|
url = manual_proxy_url(_manual_config(password="secret"))
|
|
assert url == "socks5h://proxy.example.com:1080"
|
|
|
|
def test_missing_host_returns_none(self) -> None:
|
|
assert manual_proxy_url(_manual_config(host="")) is None
|
|
assert manual_proxy_url(_manual_config(host=" ")) is None
|
|
|
|
def test_invalid_port_falls_back_to_default(self) -> None:
|
|
url = manual_proxy_url(_manual_config(port="abc"))
|
|
assert url == "socks5h://proxy.example.com:1080"
|
|
|
|
def test_out_of_range_port_falls_back(self) -> None:
|
|
url = manual_proxy_url(_manual_config(port=99999))
|
|
assert url == "socks5h://proxy.example.com:1080"
|
|
|
|
def test_ipv6_host_is_bracketed(self) -> None:
|
|
url = manual_proxy_url(_manual_config(host="::1"))
|
|
assert url == "socks5h://[::1]:1080"
|
|
|
|
def test_ipv6_host_already_bracketed_passes_through(self) -> None:
|
|
url = manual_proxy_url(_manual_config(host="[2001:db8::1]"))
|
|
assert url == "socks5h://[2001:db8::1]:1080"
|
|
|
|
|
|
class TestEffectiveProxyConfig:
|
|
def test_reads_saved_section(self) -> None:
|
|
saved = {"proxy": {"mode": "manual", "host": "h", "port": 3128}}
|
|
with patch(
|
|
"vocalinux.utils.proxy._saved_proxy_section",
|
|
return_value=dict(saved["proxy"]),
|
|
):
|
|
cfg = effective_proxy_config()
|
|
assert cfg["mode"] == "manual"
|
|
assert cfg["host"] == "h"
|
|
assert cfg["port"] == 3128
|
|
|
|
def test_passed_config_wins_over_saved(self) -> None:
|
|
with patch(
|
|
"vocalinux.utils.proxy._saved_proxy_section",
|
|
return_value={"mode": "off", "host": "x"},
|
|
):
|
|
cfg = effective_proxy_config({"mode": "manual", "host": "h"})
|
|
assert cfg["mode"] == "manual"
|
|
assert cfg["host"] == "h"
|
|
|
|
def test_saved_values_are_normalized(self) -> None:
|
|
with patch(
|
|
"vocalinux.utils.proxy._saved_proxy_section",
|
|
return_value={"mode": "MANUAL", "protocol": "HTTPS", "host": " h "},
|
|
):
|
|
cfg = effective_proxy_config()
|
|
assert cfg["mode"] == "manual"
|
|
assert cfg["protocol"] == "https"
|
|
assert cfg["host"] == "h"
|
|
|
|
|
|
class TestRequestsProxies:
|
|
def test_off_bypasses_env_proxies(self) -> None:
|
|
# {"no_proxy": "*"} is how requests is told to connect directly even
|
|
# when *_proxy environment variables are set.
|
|
proxies = requests_proxies({"mode": "off"})
|
|
assert proxies == {"no_proxy": "*"}
|
|
|
|
def test_system_with_env_returns_none(self) -> None:
|
|
# requests already honors the environment; None means "do that".
|
|
with patch.dict(os.environ, {"HTTPS_PROXY": "http://10.0.0.1:3128"}, clear=False):
|
|
assert requests_proxies({"mode": "system"}) is None
|
|
|
|
def test_system_without_env_reads_gnome(self) -> None:
|
|
with (
|
|
patch.dict(os.environ, {}, clear=True),
|
|
patch(
|
|
"vocalinux.utils.proxy._gnome_system_proxy",
|
|
return_value={"url": "http://g:8080", "no_proxy": "localhost"},
|
|
),
|
|
):
|
|
proxies = requests_proxies({"mode": "system"})
|
|
assert proxies == {
|
|
"http": "http://g:8080",
|
|
"https": "http://g:8080",
|
|
"no_proxy": "localhost",
|
|
}
|
|
|
|
def test_system_without_env_or_gnome_is_direct(self) -> None:
|
|
with (
|
|
patch.dict(os.environ, {}, clear=True),
|
|
patch("vocalinux.utils.proxy._gnome_system_proxy", return_value=None),
|
|
):
|
|
assert requests_proxies({"mode": "system"}) is None
|
|
|
|
def test_manual_returns_http_and_https(self) -> None:
|
|
proxies = requests_proxies(_manual_config())
|
|
assert proxies == {
|
|
"http": "socks5h://proxy.example.com:1080",
|
|
"https": "socks5h://proxy.example.com:1080",
|
|
}
|
|
|
|
def test_manual_without_host_goes_direct(self) -> None:
|
|
# A saved manual mode with an empty host must not silently fall back
|
|
# to env proxies the user meant to bypass.
|
|
proxies = requests_proxies(_manual_config(host=""))
|
|
assert proxies == {"no_proxy": "*"}
|
|
|
|
|
|
class TestUrllibProxyHandler:
|
|
def test_off_disables_proxies(self) -> None:
|
|
handler = urllib_proxy_handler({"mode": "off"})
|
|
assert handler.proxies == {}
|
|
|
|
def test_system_uses_environment(self) -> None:
|
|
with (
|
|
patch.dict(os.environ, {}, clear=True),
|
|
patch("vocalinux.utils.proxy._gnome_system_proxy", return_value=None),
|
|
):
|
|
handler = urllib_proxy_handler({"mode": "system"})
|
|
# No proxies kwarg means urllib reads the environment itself.
|
|
assert handler.proxies == {}
|
|
|
|
def test_manual_maps_to_http_and_https(self) -> None:
|
|
handler = urllib_proxy_handler(_manual_config(protocol="https", port=8080))
|
|
assert handler.proxies == {
|
|
"http": "http://proxy.example.com:8080",
|
|
"https": "http://proxy.example.com:8080",
|
|
}
|
|
|
|
|
|
class TestProxyEnv:
|
|
def test_manual_sets_upper_and_lower_vars(self) -> None:
|
|
env = proxy_env_vars(_manual_config())
|
|
url = "socks5h://proxy.example.com:1080"
|
|
assert env == {
|
|
"HTTP_PROXY": url,
|
|
"HTTPS_PROXY": url,
|
|
"ALL_PROXY": url,
|
|
"http_proxy": url,
|
|
"https_proxy": url,
|
|
"all_proxy": url,
|
|
}
|
|
|
|
def test_off_and_system_set_nothing(self) -> None:
|
|
assert proxy_env_vars({"mode": "off"}) == {}
|
|
assert proxy_env_vars({"mode": "system", "host": "h"}) == {}
|
|
|
|
def test_manual_without_host_sets_nothing(self) -> None:
|
|
assert proxy_env_vars(_manual_config(host="")) == {}
|
|
|
|
def test_proxy_env_never_mutates_os_environ(self) -> None:
|
|
base = {"PATH": "/bin", "HTTP_PROXY": "http://old:1"}
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
merged = proxy_env(base, _manual_config())
|
|
# The process environment is untouched: callers pass the returned
|
|
# copy to subprocess env= instead of mutating os.environ.
|
|
assert os.environ == {}
|
|
assert base["HTTP_PROXY"] == "http://old:1"
|
|
assert merged["PATH"] == "/bin"
|
|
assert merged["HTTP_PROXY"] == "socks5h://proxy.example.com:1080"
|
|
|
|
|
|
class TestGnomeSystemProxy:
|
|
"""GNOME org.gnome.system.proxy parsing (manual mode only)."""
|
|
|
|
def _run_proxy(
|
|
self, stdout: str, env: Optional[Dict[str, str]] = None
|
|
) -> Optional[Dict[str, str]]:
|
|
from vocalinux.utils.proxy import _gnome_system_proxy
|
|
|
|
fake = MagicMock()
|
|
fake.returncode = 0
|
|
fake.stdout = stdout
|
|
with patch("vocalinux.utils.proxy.subprocess.run", return_value=fake):
|
|
return _gnome_system_proxy()
|
|
|
|
def test_none_mode_returns_none(self) -> None:
|
|
proxies = self._run_proxy("org.gnome.system.proxy mode 'none'\n")
|
|
assert proxies is None
|
|
|
|
def test_auto_mode_is_unsupported(self) -> None:
|
|
proxies = self._run_proxy("org.gnome.system.proxy mode 'auto'\n")
|
|
assert proxies is None
|
|
|
|
def test_manual_mode_builds_url(self) -> None:
|
|
stdout = (
|
|
"org.gnome.system.proxy mode 'manual'\n"
|
|
"org.gnome.system.proxy.http host 'g.proxy'\n"
|
|
"org.gnome.system.proxy.http port 8080\n"
|
|
"org.gnome.system.proxy.http enabled true\n"
|
|
"org.gnome.system.proxy.https host ''\n"
|
|
"org.gnome.system.proxy.https port 0\n"
|
|
"org.gnome.system.proxy.socks host ''\n"
|
|
"org.gnome.system.proxy.socks port 0\n"
|
|
"org.gnome.system.proxy ignore-hosts ['localhost', '127.0.0.1']\n"
|
|
)
|
|
proxies = self._run_proxy(stdout)
|
|
assert proxies == {
|
|
"url": "http://g.proxy:8080",
|
|
"no_proxy": "localhost,127.0.0.1",
|
|
}
|
|
|
|
def test_gsettings_failure_returns_none(self) -> None:
|
|
from vocalinux.utils.proxy import _gnome_system_proxy
|
|
|
|
with patch(
|
|
"vocalinux.utils.proxy.subprocess.run",
|
|
side_effect=OSError("gsettings not found"),
|
|
):
|
|
assert _gnome_system_proxy() is None
|