fix: audit round 3 - fail-closed leak, shared asyncio loop, GOST bundling, close-X UX, persona key lookup, +29 tests
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user