Files
voculinux/tests/test_proxy.py
T
775863fd30 feat(settings): add proxy support for model downloads and update checks (#909)
* feat(settings): add proxy support for model downloads and update checks

Add a Proxy page to Settings (above About) with Off / System / Manual
modes. Manual mode supports SOCKS5 and HTTPS (HTTP CONNECT) proxies
with optional username/password auth; the password is stored in
config.json in plaintext like the other settings. System mode honors
the *_proxy environment variables and falls back to the GNOME
org.gnome.system.proxy manual configuration.

The resolved proxy is applied to every in-app outbound path that
fetches models or checks updates: the shared requests download path in
recognition_manager (_stream_model_download, used by the whisper.cpp,
whisper, Vosk, Parakeet and faster-whisper downloaders) and the
release checks in update_checker. A small Test Connection button on
the Proxy page verifies reachability through the configured proxy.

SOCKS5 needs PySocks, added as a runtime dependency; uv.lock, the
requirements/*.txt exports and the Flatpak dependency manifest are
regenerated by just lock (with a fix in sync_flatpak_deps.py for
packages like PySocks that publish more than one universal wheel).

Fixes #655

* fix(proxy): bracket IPv6 hosts, escape test errors, declare pysocks for AUR

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:16:48 -07:00

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