first commit
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
48
tests/test_ban_tester.py
Normal file
48
tests/test_ban_tester.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Heuristic tests for ban_tester."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from proxy_chain_manager import ban_tester
|
||||
|
||||
|
||||
class TestBannedBodyHints(unittest.TestCase):
|
||||
def test_normal_page_is_not_banned(self) -> None:
|
||||
self.assertFalse(ban_tester._looks_banned_body("Welcome to my homepage"))
|
||||
self.assertFalse(ban_tester._looks_banned_body("<html><body>hello</body></html>"))
|
||||
|
||||
def test_naked_captcha_word_is_not_banned(self) -> None:
|
||||
# 'captcha' alone is too common (privacy policies, login pages) —
|
||||
# earlier audit removed it from hints.
|
||||
self.assertFalse(ban_tester._looks_banned_body(
|
||||
"We use a captcha on the signup page to prevent bots."
|
||||
))
|
||||
|
||||
def test_captcha_required_phrase_is_banned(self) -> None:
|
||||
self.assertTrue(ban_tester._looks_banned_body("Captcha required to continue."))
|
||||
self.assertTrue(ban_tester._looks_banned_body("Complete the captcha below."))
|
||||
|
||||
def test_access_denied_is_banned(self) -> None:
|
||||
self.assertTrue(ban_tester._looks_banned_body("Access denied"))
|
||||
self.assertTrue(ban_tester._looks_banned_body("ACCESS DENIED — 403"))
|
||||
|
||||
def test_cloudflare_ray_is_banned(self) -> None:
|
||||
self.assertTrue(ban_tester._looks_banned_body("Cloudflare Ray ID: abc123"))
|
||||
|
||||
def test_empty_body_is_not_banned(self) -> None:
|
||||
self.assertFalse(ban_tester._looks_banned_body(""))
|
||||
self.assertFalse(ban_tester._looks_banned_body(None)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestSiteCategories(unittest.TestCase):
|
||||
def test_all_category_includes_known_sites(self) -> None:
|
||||
all_urls = {u for _, u in ban_tester.SITE_CATEGORIES["All"]}
|
||||
self.assertTrue(len(all_urls) > 5)
|
||||
|
||||
def test_categories_present(self) -> None:
|
||||
for key in ("Social / General", "Shopping", "Crypto Exchanges", "DNS Providers", "All"):
|
||||
self.assertIn(key, ban_tester.SITE_CATEGORIES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
105
tests/test_config_round_trip.py
Normal file
105
tests/test_config_round_trip.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Round-trip / migrate / sanitize tests for config.Settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from proxy_chain_manager import config
|
||||
|
||||
|
||||
class TestSanitize(unittest.TestCase):
|
||||
def test_clamps_chain_length(self) -> None:
|
||||
s = config.Settings()
|
||||
s.chain_length = 99
|
||||
s, changed = config.sanitize_settings(s)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(s.chain_length, 8)
|
||||
|
||||
def test_chain_length_string_resets_to_default(self) -> None:
|
||||
s = config.Settings()
|
||||
s.chain_length = "not a number" # type: ignore[assignment]
|
||||
s, changed = config.sanitize_settings(s)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(s.chain_length, 3)
|
||||
|
||||
def test_local_port_clamped(self) -> None:
|
||||
s = config.Settings()
|
||||
s.local_port = 99999
|
||||
s, changed = config.sanitize_settings(s)
|
||||
self.assertTrue(changed)
|
||||
self.assertLessEqual(s.local_port, 65535)
|
||||
|
||||
def test_http_source_url_rejected(self) -> None:
|
||||
# _is_safe_https_url should strip plaintext HTTP sources.
|
||||
s = config.Settings()
|
||||
s.sources = ["http://insecure.example/list.json"]
|
||||
s, _ = config.sanitize_settings(s)
|
||||
self.assertTrue(all(u.startswith("https://") for u in s.sources))
|
||||
|
||||
def test_rfc1918_source_url_rejected(self) -> None:
|
||||
s = config.Settings()
|
||||
s.sources = [
|
||||
"https://192.168.1.1/list.json",
|
||||
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
|
||||
]
|
||||
s, _ = config.sanitize_settings(s)
|
||||
for u in s.sources:
|
||||
# rfc1918 host must be gone.
|
||||
self.assertNotIn("192.168.", u)
|
||||
|
||||
|
||||
class TestMigrate(unittest.TestCase):
|
||||
def test_unversioned_dict_gets_versioned(self) -> None:
|
||||
raw = {"chain_length": 4}
|
||||
out = config.migrate(raw)
|
||||
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
|
||||
self.assertEqual(out["chain_length"], 4)
|
||||
|
||||
def test_already_current_passthrough(self) -> None:
|
||||
raw = {"settings_version": config.SETTINGS_SCHEMA_VERSION, "chain_length": 4}
|
||||
out = config.migrate(raw)
|
||||
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
|
||||
|
||||
|
||||
class TestLoadSaveRoundTrip(unittest.TestCase):
|
||||
def test_save_then_load_preserves_values(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||
s = config.Settings()
|
||||
s.chain_length = 5
|
||||
s.local_port = 19191
|
||||
s.kill_switch_enabled = False
|
||||
config.save_settings(s)
|
||||
loaded = config.load_settings()
|
||||
self.assertEqual(loaded.chain_length, 5)
|
||||
self.assertEqual(loaded.local_port, 19191)
|
||||
self.assertFalse(loaded.kill_switch_enabled)
|
||||
|
||||
def test_corrupt_settings_backed_up_and_defaults_returned(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
(tmp_path / "settings.json").write_text("{ not json", encoding="utf-8")
|
||||
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||
loaded = config.load_settings()
|
||||
self.assertEqual(loaded.chain_length, config.Settings().chain_length)
|
||||
self.assertTrue((tmp_path / "settings.json.corrupt").is_file())
|
||||
|
||||
def test_save_writes_backup_before_overwriting(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||
config.save_settings(config.Settings())
|
||||
# Mutate + save again — .bak should now exist.
|
||||
s = config.load_settings()
|
||||
s.chain_length = 7
|
||||
config.save_settings(s)
|
||||
self.assertTrue((tmp_path / "settings.json.bak").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
26
tests/test_fail_closed.py
Normal file
26
tests/test_fail_closed.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Fail-closed leak-detection contract tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from proxy_chain_manager.leak_detect import is_chain_leak, leak_reason
|
||||
|
||||
|
||||
class TestFailClosed(unittest.TestCase):
|
||||
def test_unknown_real_ip_is_leak(self) -> None:
|
||||
"""When the direct IP could not be determined we cannot prove the
|
||||
chain is forwarding — the README promises fail-closed behavior."""
|
||||
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=False))
|
||||
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=True))
|
||||
|
||||
def test_unknown_exit_ip_is_leak(self) -> None:
|
||||
self.assertTrue(is_chain_leak(None, "1.2.3.4", vpn_active=False))
|
||||
|
||||
def test_leak_reason_explains_unknown_direct_ip(self) -> None:
|
||||
msg = leak_reason("5.6.7.8", None, vpn_active=False)
|
||||
self.assertIn("direct IP", msg)
|
||||
self.assertIn("fail-closed", msg.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
42
tests/test_firewall_helpers.py
Normal file
42
tests/test_firewall_helpers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Non-destructive tests for firewall helpers.
|
||||
|
||||
These DO NOT call netsh — they only exercise pure functions / branches that
|
||||
should never modify the system firewall state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from proxy_chain_manager import firewall
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "Windows-only firewall API")
|
||||
class TestEmergencyDisengageGuard(unittest.TestCase):
|
||||
def test_no_op_when_not_engaged(self) -> None:
|
||||
"""emergency_disengage() must NOT touch firewall state when our rules
|
||||
are not present (the audit fix to C-08)."""
|
||||
called = []
|
||||
with patch.object(firewall, "is_engaged", return_value=False), \
|
||||
patch.object(firewall, "_set_outbound_policy",
|
||||
side_effect=lambda *_a, **_k: called.append("policy")), \
|
||||
patch.object(firewall, "_delete_rules",
|
||||
side_effect=lambda *_a, **_k: called.append("delete")):
|
||||
firewall.emergency_disengage()
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_runs_disengage_when_engaged(self) -> None:
|
||||
called = []
|
||||
with patch.object(firewall, "is_engaged", return_value=True), \
|
||||
patch.object(firewall, "_set_outbound_policy",
|
||||
side_effect=lambda *_a, **_k: called.append("policy")), \
|
||||
patch.object(firewall, "_delete_rules",
|
||||
side_effect=lambda *_a, **_k: called.append("delete")):
|
||||
firewall.emergency_disengage()
|
||||
self.assertIn("policy", called)
|
||||
self.assertIn("delete", called)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
60
tests/test_gost_util.py
Normal file
60
tests/test_gost_util.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Hash verify / sidecar tests for gost_util."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from proxy_chain_manager import gost_util
|
||||
|
||||
|
||||
class TestVerifyZipSha256(unittest.TestCase):
|
||||
def test_matching_hash_passes(self) -> None:
|
||||
data = b"hello world"
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
gost_util._verify_zip_sha256(data, digest) # must not raise
|
||||
|
||||
def test_wrong_hash_raises(self) -> None:
|
||||
data = b"hello world"
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gost_util._verify_zip_sha256(data, "0" * 64)
|
||||
self.assertIn("supply-chain", str(ctx.exception).lower())
|
||||
|
||||
def test_case_insensitive_match(self) -> None:
|
||||
data = b"PROXYGOD"
|
||||
digest = hashlib.sha256(data).hexdigest().upper()
|
||||
gost_util._verify_zip_sha256(data, digest)
|
||||
|
||||
|
||||
class TestExeHashSidecar(unittest.TestCase):
|
||||
def test_sidecar_round_trip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / "gost.exe"
|
||||
exe.write_bytes(b"\x90" * 1024)
|
||||
digest = gost_util._hash_file(exe)
|
||||
gost_util._write_pinned_exe_hash(exe, digest)
|
||||
self.assertEqual(gost_util._read_pinned_exe_hash(exe), digest)
|
||||
|
||||
def test_missing_sidecar_returns_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / "gost.exe"
|
||||
exe.write_bytes(b"x")
|
||||
self.assertIsNone(gost_util._read_pinned_exe_hash(exe))
|
||||
|
||||
def test_hash_file_matches_python_hashlib(self) -> None:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(b"some payload bytes")
|
||||
f.flush()
|
||||
p = Path(f.name)
|
||||
try:
|
||||
self.assertEqual(
|
||||
gost_util._hash_file(p),
|
||||
hashlib.sha256(b"some payload bytes").hexdigest(),
|
||||
)
|
||||
finally:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
424
tests/test_proxy_god.py
Normal file
424
tests/test_proxy_god.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""Automated checks for Proxy God core logic (no GUI). Run: python -m unittest discover -s tests -v"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from proxy_chain_manager.leak_detect import is_chain_leak, is_same_subnet
|
||||
|
||||
from proxy_chain_manager.config import (
|
||||
Settings,
|
||||
merge_proxy_credentials,
|
||||
normalize_proxy_url,
|
||||
parse_exit_proxy_input,
|
||||
redact_proxy_url,
|
||||
sanitize_settings,
|
||||
split_proxy_for_edit,
|
||||
)
|
||||
from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries
|
||||
from proxy_chain_manager.validator import (
|
||||
check_chain_exit_ip,
|
||||
check_https_tunnel,
|
||||
get_direct_ip,
|
||||
validate_proxies,
|
||||
)
|
||||
|
||||
|
||||
class TestSameNetwork(unittest.TestCase):
|
||||
def test_exact_match(self) -> None:
|
||||
self.assertTrue(is_same_subnet("1.2.3.4", "1.2.3.4"))
|
||||
|
||||
def test_same_slash16(self) -> None:
|
||||
self.assertTrue(is_same_subnet("185.220.101.5", "185.220.200.9"))
|
||||
|
||||
def test_different_slash16(self) -> None:
|
||||
self.assertFalse(is_same_subnet("185.220.101.5", "104.28.3.99"))
|
||||
|
||||
def test_none_safe(self) -> None:
|
||||
self.assertFalse(is_same_subnet(None, "1.2.3.4"))
|
||||
self.assertFalse(is_same_subnet("1.2.3.4", None))
|
||||
|
||||
def test_non_ipv4_safe(self) -> None:
|
||||
self.assertFalse(is_same_subnet("not-an-ip", "1.2.3.4"))
|
||||
|
||||
|
||||
class TestLeakDetect(unittest.TestCase):
|
||||
def test_strict_no_vpn_same_ip(self) -> None:
|
||||
self.assertTrue(is_chain_leak("1.2.3.4", "1.2.3.4", vpn_active=False))
|
||||
|
||||
def test_strict_no_vpn_different_ip(self) -> None:
|
||||
self.assertFalse(is_chain_leak("5.6.7.8", "1.2.3.4", vpn_active=False))
|
||||
|
||||
def test_vpn_subnet_leak(self) -> None:
|
||||
self.assertTrue(is_chain_leak("185.220.101.5", "185.220.200.9", vpn_active=True))
|
||||
|
||||
def test_vpn_different_subnet_ok(self) -> None:
|
||||
self.assertFalse(is_chain_leak("104.28.3.99", "185.220.101.5", vpn_active=True))
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_normalize_proxy_url(self) -> None:
|
||||
self.assertEqual(normalize_proxy_url(""), "")
|
||||
self.assertEqual(normalize_proxy_url("1.2.3.4:8080"), "http://1.2.3.4:8080")
|
||||
self.assertEqual(normalize_proxy_url("socks5://x:1"), "socks5://x:1")
|
||||
|
||||
def test_merge_split_proxy_auth(self) -> None:
|
||||
merged = merge_proxy_credentials("http://1.2.3.4:8080", "user", "p:ass")
|
||||
self.assertIn("user", merged)
|
||||
self.assertIn("p%3Aass", merged)
|
||||
base, u, pw = split_proxy_for_edit(merged)
|
||||
self.assertEqual(base, "http://1.2.3.4:8080")
|
||||
self.assertEqual(u, "user")
|
||||
self.assertEqual(pw, "p:ass")
|
||||
|
||||
def test_merge_preserves_embedded_auth_when_fields_empty(self) -> None:
|
||||
raw = normalize_proxy_url("http://x:y@9.9.9.9:12")
|
||||
self.assertEqual(merge_proxy_credentials(raw, "", ""), raw)
|
||||
|
||||
def test_redact_proxy_url(self) -> None:
|
||||
r = redact_proxy_url("http://alice:secret@proxy.example:8888")
|
||||
self.assertIn("***:***@", r)
|
||||
self.assertIn("proxy.example", r)
|
||||
self.assertNotIn("secret", r)
|
||||
self.assertNotIn("alice", r)
|
||||
|
||||
def test_sanitize_restores_empty_sources(self) -> None:
|
||||
s = Settings()
|
||||
s.sources = []
|
||||
s2, changed = sanitize_settings(s)
|
||||
self.assertTrue(changed)
|
||||
self.assertGreater(len(s2.sources), 0)
|
||||
|
||||
def test_parse_exit_host_port_only_defaults_to_socks5(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080")
|
||||
self.assertEqual(base, "socks5://1.2.3.4:1080")
|
||||
self.assertEqual(u, "")
|
||||
self.assertEqual(pw, "")
|
||||
|
||||
def test_parse_exit_colon_quadruple(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080:alice:s3cret")
|
||||
self.assertEqual(base, "socks5://1.2.3.4:1080")
|
||||
self.assertEqual(u, "alice")
|
||||
self.assertEqual(pw, "s3cret")
|
||||
|
||||
def test_parse_exit_password_with_colon_kept_intact(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("h:9:u:a:b:c")
|
||||
self.assertEqual(base, "socks5://h:9")
|
||||
self.assertEqual(u, "u")
|
||||
self.assertEqual(pw, "a:b:c")
|
||||
|
||||
def test_parse_exit_at_form_no_scheme(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("alice:secret@1.2.3.4:1080")
|
||||
self.assertEqual(base, "socks5://1.2.3.4:1080")
|
||||
self.assertEqual(u, "alice")
|
||||
self.assertEqual(pw, "secret")
|
||||
|
||||
def test_parse_exit_respects_explicit_scheme(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("http://x:y@9.9.9.9:8080")
|
||||
self.assertEqual(base, "http://9.9.9.9:8080")
|
||||
self.assertEqual(u, "x")
|
||||
self.assertEqual(pw, "y")
|
||||
|
||||
def test_parse_exit_whitespace_form(self) -> None:
|
||||
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080 alice s3cret")
|
||||
self.assertEqual(base, "socks5://1.2.3.4:1080")
|
||||
self.assertEqual(u, "alice")
|
||||
self.assertEqual(pw, "s3cret")
|
||||
|
||||
def test_parse_exit_empty(self) -> None:
|
||||
self.assertEqual(parse_exit_proxy_input(" "), ("", "", ""))
|
||||
|
||||
def test_sanitize_clamps_extremes(self) -> None:
|
||||
s = Settings(
|
||||
chain_length=0,
|
||||
health_check_seconds=999999,
|
||||
full_refresh_seconds=10,
|
||||
validation_timeout_seconds=900.0,
|
||||
)
|
||||
s, changed = sanitize_settings(s)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(s.chain_length, 1)
|
||||
self.assertEqual(s.health_check_seconds, 3600)
|
||||
self.assertEqual(s.full_refresh_seconds, 60)
|
||||
self.assertEqual(s.validation_timeout_seconds, 120.0)
|
||||
|
||||
|
||||
class TestFetcher(unittest.TestCase):
|
||||
def test_normalize_entries_empty(self) -> None:
|
||||
self.assertEqual(normalize_entries([], False), [])
|
||||
|
||||
def test_fetch_smoke(self) -> None:
|
||||
"""One real HTTP GET — may fail offline; then skip assertion."""
|
||||
try:
|
||||
rows = fetch_proxy_json(
|
||||
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
|
||||
timeout=25.0,
|
||||
)
|
||||
except Exception:
|
||||
self.skipTest("network unavailable")
|
||||
self.assertIsInstance(rows, list)
|
||||
if rows:
|
||||
self.assertTrue(isinstance(rows[0], dict))
|
||||
|
||||
|
||||
class TestValidator(unittest.TestCase):
|
||||
def test_validate_empty_list_returns_fast(self) -> None:
|
||||
async def run() -> list[str]:
|
||||
return await validate_proxies(
|
||||
[],
|
||||
"https://api.ipify.org?format=json",
|
||||
8,
|
||||
5.0,
|
||||
)
|
||||
|
||||
self.assertEqual(asyncio.run(run()), [])
|
||||
|
||||
def test_validate_dead_proxy_fast(self) -> None:
|
||||
async def run() -> list[str]:
|
||||
return await validate_proxies(
|
||||
["http://127.0.0.1:1"],
|
||||
"https://api.ipify.org?format=json",
|
||||
1,
|
||||
3.0,
|
||||
)
|
||||
|
||||
self.assertEqual(asyncio.run(run()), [])
|
||||
|
||||
def test_validate_early_exit_target(self) -> None:
|
||||
"""Once target is reached remaining tasks should be skipped (all dead here → no early exit but no crash)."""
|
||||
async def run() -> list[str]:
|
||||
return await validate_proxies(
|
||||
["http://127.0.0.1:1"] * 10,
|
||||
"https://api.ipify.org?format=json",
|
||||
4,
|
||||
2.0,
|
||||
target=2, # target unreachable — should still return [] cleanly
|
||||
)
|
||||
|
||||
result = asyncio.run(run())
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_get_direct_ip_network(self) -> None:
|
||||
async def run() -> str | None:
|
||||
return await get_direct_ip("https://api.ipify.org?format=json", 12.0)
|
||||
|
||||
ip = asyncio.run(run())
|
||||
if ip is None:
|
||||
self.skipTest("could not reach ipify (offline or blocked)")
|
||||
self.assertGreater(len(ip), 4)
|
||||
|
||||
def test_check_https_tunnel_invalid_proxy_quick(self) -> None:
|
||||
async def run() -> tuple[bool, str]:
|
||||
return await check_https_tunnel("http://127.0.0.1:1", timeout_seconds=4.0)
|
||||
|
||||
ok, msg = asyncio.run(run())
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(msg)
|
||||
|
||||
def test_check_chain_exit_ip_invalid_proxy_quick(self) -> None:
|
||||
async def run() -> str | None:
|
||||
return await check_chain_exit_ip(
|
||||
"http://127.0.0.1:1",
|
||||
"https://api.ipify.org?format=json",
|
||||
8.0,
|
||||
)
|
||||
|
||||
self.assertIsNone(asyncio.run(run()))
|
||||
|
||||
|
||||
class TestBrowserPersona(unittest.TestCase):
|
||||
def test_blend_persona_relaxes_block_flags(self) -> None:
|
||||
from proxy_chain_manager.browser_profile import (
|
||||
FirefoxHardening, apply_persona, build_user_js,
|
||||
)
|
||||
h = FirefoxHardening(
|
||||
persona_key="blend_windows_chrome",
|
||||
resist_fingerprinting=True,
|
||||
first_party_isolation=True,
|
||||
strict_tracking_protection=True,
|
||||
timezone_utc=True,
|
||||
)
|
||||
applied = apply_persona(h)
|
||||
self.assertFalse(applied.resist_fingerprinting)
|
||||
self.assertFalse(applied.first_party_isolation)
|
||||
self.assertFalse(applied.strict_tracking_protection)
|
||||
self.assertFalse(applied.timezone_utc)
|
||||
self.assertIsNotNone(applied.persona)
|
||||
# And the user.js carries a spoofed UA when blend is selected.
|
||||
js = build_user_js("127.0.0.1", 18888, h)
|
||||
self.assertIn("general.useragent.override", js)
|
||||
self.assertIn("Chrome/124", js)
|
||||
|
||||
def test_hardened_persona_keeps_block_flags(self) -> None:
|
||||
from proxy_chain_manager.browser_profile import FirefoxHardening, apply_persona
|
||||
h = FirefoxHardening(
|
||||
persona_key="hardened",
|
||||
resist_fingerprinting=False, # should be forced True
|
||||
first_party_isolation=False,
|
||||
)
|
||||
applied = apply_persona(h)
|
||||
self.assertTrue(applied.resist_fingerprinting)
|
||||
self.assertTrue(applied.first_party_isolation)
|
||||
self.assertTrue(applied.strict_tracking_protection)
|
||||
self.assertIsNone(applied.persona)
|
||||
|
||||
def test_cookie_behavior_mapping(self) -> None:
|
||||
from proxy_chain_manager.browser_identity import (
|
||||
cookie_behavior_value, cookie_session_only,
|
||||
)
|
||||
self.assertEqual(cookie_behavior_value("accept_all"), 0)
|
||||
self.assertEqual(cookie_behavior_value("block_third_party"), 1)
|
||||
self.assertEqual(cookie_behavior_value("block_third_party_trackers"), 4)
|
||||
self.assertEqual(cookie_behavior_value("session_only"), 0)
|
||||
self.assertEqual(cookie_behavior_value("block_all"), 2)
|
||||
self.assertTrue(cookie_session_only("session_only"))
|
||||
self.assertFalse(cookie_session_only("accept_all"))
|
||||
|
||||
def test_persona_env_returns_tz_for_blend(self) -> None:
|
||||
from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env
|
||||
env = persona_env(FirefoxHardening(persona_key="blend_windows_chrome"))
|
||||
self.assertIn("TZ", env)
|
||||
self.assertTrue(env["TZ"])
|
||||
|
||||
def test_persona_env_empty_for_hardened(self) -> None:
|
||||
from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env
|
||||
self.assertEqual(persona_env(FirefoxHardening(persona_key="hardened")), {})
|
||||
|
||||
|
||||
class TestPrivacyLan(unittest.TestCase):
|
||||
def test_snapshot_round_trip(self) -> None:
|
||||
from proxy_chain_manager.privacy_lan import (
|
||||
LanSnapshot, snapshot_from_json, snapshot_to_json,
|
||||
)
|
||||
s = LanSnapshot(
|
||||
netbios_per_interface={"{a-b-c}": 0, "{d-e-f}": 2},
|
||||
llmnr_present=True,
|
||||
llmnr_prev_value=1,
|
||||
mdns_present=False,
|
||||
mdns_prev_value=None,
|
||||
)
|
||||
round_tripped = snapshot_from_json(snapshot_to_json(s))
|
||||
self.assertIsNotNone(round_tripped)
|
||||
assert round_tripped is not None
|
||||
self.assertEqual(round_tripped.netbios_per_interface, s.netbios_per_interface)
|
||||
self.assertEqual(round_tripped.llmnr_present, s.llmnr_present)
|
||||
self.assertEqual(round_tripped.llmnr_prev_value, s.llmnr_prev_value)
|
||||
self.assertEqual(round_tripped.mdns_present, s.mdns_present)
|
||||
self.assertEqual(round_tripped.mdns_prev_value, s.mdns_prev_value)
|
||||
|
||||
def test_lan_status_returns_keys(self) -> None:
|
||||
from proxy_chain_manager.privacy_lan import lan_status
|
||||
st = lan_status()
|
||||
self.assertIn("netbios", st)
|
||||
self.assertIn("llmnr", st)
|
||||
self.assertIn("mdns", st)
|
||||
|
||||
|
||||
class TestLeakAuditPure(unittest.TestCase):
|
||||
def test_categorize_exit_private(self) -> None:
|
||||
from proxy_chain_manager.leak_audit import _categorize_exit
|
||||
self.assertEqual(_categorize_exit("10.0.0.5"), "PRIVATE — chain broken")
|
||||
self.assertEqual(_categorize_exit("192.168.1.1"), "PRIVATE — chain broken")
|
||||
self.assertEqual(_categorize_exit("172.20.5.5"), "PRIVATE — chain broken")
|
||||
|
||||
def test_categorize_exit_loopback(self) -> None:
|
||||
from proxy_chain_manager.leak_audit import _categorize_exit
|
||||
self.assertEqual(_categorize_exit("127.0.0.1"), "LOOPBACK — chain broken")
|
||||
|
||||
def test_categorize_exit_public(self) -> None:
|
||||
from proxy_chain_manager.leak_audit import _categorize_exit
|
||||
self.assertEqual(_categorize_exit("104.28.3.99"), "public")
|
||||
self.assertEqual(_categorize_exit("8.8.8.8"), "public")
|
||||
|
||||
def test_categorize_exit_none(self) -> None:
|
||||
from proxy_chain_manager.leak_audit import _categorize_exit
|
||||
self.assertEqual(_categorize_exit(None), "unknown")
|
||||
|
||||
def test_audit_finding_aggregation(self) -> None:
|
||||
from proxy_chain_manager.leak_audit import AuditReport
|
||||
rep = AuditReport()
|
||||
rep.add("X", True, "ok")
|
||||
self.assertTrue(rep.overall_ok)
|
||||
rep.add("Y", False, "bad", "note")
|
||||
self.assertFalse(rep.overall_ok)
|
||||
self.assertEqual(len(rep.findings), 2)
|
||||
|
||||
|
||||
class TestExitIntelGeo(unittest.TestCase):
|
||||
def test_parse_ipapi_lat_lon(self) -> None:
|
||||
from proxy_chain_manager.exit_intel import _parse_ipapi
|
||||
|
||||
out = _parse_ipapi(
|
||||
{
|
||||
"status": "success",
|
||||
"query": "8.8.8.8",
|
||||
"country": "United States",
|
||||
"countryCode": "US",
|
||||
"regionName": "Virginia",
|
||||
"city": "Ashburn",
|
||||
"lat": 39.03,
|
||||
"lon": -77.5,
|
||||
"timezone": "America/New_York",
|
||||
"isp": "Google LLC",
|
||||
"org": "Google Public DNS",
|
||||
"as": "AS15169 Google LLC",
|
||||
"mobile": False,
|
||||
"proxy": False,
|
||||
"hosting": True,
|
||||
}
|
||||
)
|
||||
self.assertTrue(out.ok)
|
||||
self.assertAlmostEqual(out.lat or 0, 39.03)
|
||||
self.assertAlmostEqual(out.lon or 0, -77.5)
|
||||
|
||||
def test_parse_ipwhois_lat_lon(self) -> None:
|
||||
from proxy_chain_manager.exit_intel import _parse_ipwhois
|
||||
|
||||
out = _parse_ipwhois(
|
||||
{
|
||||
"success": True,
|
||||
"ip": "1.1.1.1",
|
||||
"country": "Australia",
|
||||
"country_code": "AU",
|
||||
"city": "Sydney",
|
||||
"region": "New South Wales",
|
||||
"latitude": -33.86,
|
||||
"longitude": 151.2,
|
||||
"timezone": {"id": "Australia/Sydney"},
|
||||
"connection": {"asn": 13335, "org": "Cloudflare", "isp": "Cloudflare"},
|
||||
}
|
||||
)
|
||||
self.assertTrue(out.ok)
|
||||
self.assertAlmostEqual(out.lat or 0, -33.86)
|
||||
self.assertAlmostEqual(out.lon or 0, 151.2)
|
||||
|
||||
|
||||
class TestChainMap(unittest.TestCase):
|
||||
def test_extract_hop_host(self) -> None:
|
||||
from proxy_chain_manager.chain_map import extract_hop_host
|
||||
|
||||
self.assertEqual(extract_hop_host("http://203.0.113.10:8080"), "203.0.113.10")
|
||||
self.assertEqual(extract_hop_host("socks5://proxy.example:1080"), "proxy.example")
|
||||
self.assertIsNone(extract_hop_host(""))
|
||||
|
||||
def test_project_corners(self) -> None:
|
||||
from proxy_chain_manager.chain_map import _project
|
||||
|
||||
x, y = _project(0, 0, 360, 180, 0)
|
||||
self.assertAlmostEqual(x, 180.0)
|
||||
self.assertAlmostEqual(y, 90.0)
|
||||
|
||||
def test_render_chain_map_with_points(self) -> None:
|
||||
from proxy_chain_manager.chain_map import MapPoint, render_chain_map
|
||||
|
||||
pts = [
|
||||
MapPoint("you", "YOU", 40.0, -74.0, detail="NYC"),
|
||||
MapPoint("hop", "H1", 51.5, -0.1, detail="London"),
|
||||
MapPoint("exit", "EXIT", 35.6, 139.7, detail="Tokyo"),
|
||||
]
|
||||
img = render_chain_map(pts, width=640, height=200, status="healthy")
|
||||
self.assertEqual(img.size, (640, 200))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
48
tests/test_secrets_store.py
Normal file
48
tests/test_secrets_store.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Round-trip tests for the DPAPI-backed secrets store.
|
||||
|
||||
These are skipped automatically on non-Windows so the test suite stays cross-
|
||||
platform. On Windows, DPAPI is part of the OS — no extra deps required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from proxy_chain_manager import secrets_store
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "DPAPI only on Windows")
|
||||
class TestSecretsStore(unittest.TestCase):
|
||||
def test_empty_passthrough(self) -> None:
|
||||
self.assertEqual(secrets_store.encrypt(""), "")
|
||||
self.assertEqual(secrets_store.decrypt(""), "")
|
||||
|
||||
def test_legacy_plaintext_passthrough(self) -> None:
|
||||
# Anything without the DPAPI: prefix decrypts to itself so migration
|
||||
# from plaintext files is automatic on first read.
|
||||
self.assertEqual(secrets_store.decrypt("legacy_pw"), "legacy_pw")
|
||||
|
||||
def test_round_trip_ascii(self) -> None:
|
||||
secret = "hunter2!"
|
||||
token = secrets_store.encrypt(secret)
|
||||
self.assertTrue(token.startswith("DPAPI:v1:"))
|
||||
self.assertEqual(secrets_store.decrypt(token), secret)
|
||||
|
||||
def test_round_trip_unicode(self) -> None:
|
||||
secret = "пароль · 🔐 · café"
|
||||
token = secrets_store.encrypt(secret)
|
||||
self.assertEqual(secrets_store.decrypt(token), secret)
|
||||
|
||||
def test_double_encrypt_idempotent(self) -> None:
|
||||
token = secrets_store.encrypt("once")
|
||||
again = secrets_store.encrypt(token)
|
||||
self.assertEqual(token, again)
|
||||
|
||||
def test_is_encrypted_helper(self) -> None:
|
||||
self.assertFalse(secrets_store.is_encrypted(""))
|
||||
self.assertFalse(secrets_store.is_encrypted("plaintext"))
|
||||
self.assertTrue(secrets_store.is_encrypted(secrets_store.encrypt("x")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user