Three defects made the panel look broken to the operator:
- POST /api/proxies with a Chinese name slugged the id down to its ASCII
digits ("香港01" -> "01"), collided with an existing node and returned
HTTP 500 "proxy id already exists". Non-ASCII names now get sequential
node-N ids; ASCII names keep a readable slug. Both paths de-duplicate.
- A host pasted as a URL ("http://192.168.3.71") went straight to DNS, so
every check failed with "[Errno -2] Name does not resolve". normalize_host
accepts host, host:port, scheme://user:pass@host:port and [IPv6]:port, and
existing configs are healed at startup.
- str(asyncio.TimeoutError()) is empty, so a black-holed proxy showed a blank
error in the UI and looked like an internal bug. describe_exc always
produces a reason.
Also:
- Expected validation failures return 400 with an error message instead of
500 + traceback; the frontend surfaces every API error as a toast.
- PUT/DELETE on an unknown proxy id return 404 instead of silently creating
or reporting ok=false with 200.
- POST /api/check/<id> forces a check on disabled nodes instead of returning
ok=true without doing anything.
- Port range is validated (1..65535).
- Store.close() lets tests release SQLite handles.
- 33 unittest cases cover id generation, host normalization, error text,
startup migration and manual checks.
212 lines
8.8 KiB
Python
212 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression tests for node id generation, host normalization and error text.
|
|
|
|
These cover the three defects that made the panel look broken:
|
|
1. Chinese node names slugged into ids that collided with existing nodes.
|
|
2. A pasted URL in the Host field went straight to DNS and always failed.
|
|
3. asyncio.TimeoutError has an empty str(), so the UI showed a blank error.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import socket
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
spec = importlib.util.spec_from_file_location("socks5_app", ROOT / "app.py")
|
|
assert spec is not None and spec.loader is not None
|
|
app = importlib.util.module_from_spec(spec)
|
|
sys.modules["socks5_app"] = app
|
|
spec.loader.exec_module(app)
|
|
|
|
|
|
def make_app(proxies: list[dict] | None = None) -> tuple[object, Path, tempfile.TemporaryDirectory]:
|
|
"""Build an App on a throwaway config; caller must use the TemporaryDirectory."""
|
|
td = tempfile.TemporaryDirectory()
|
|
cfg = Path(td.name) / "config.json"
|
|
cfg.write_text(json.dumps({
|
|
"proxies": proxies or [],
|
|
"notifications": {"channels": []},
|
|
"db_path": str(Path(td.name) / "monitor.db"),
|
|
}), encoding="utf-8")
|
|
return app.App(cfg), cfg, td
|
|
|
|
|
|
class AppCase(unittest.TestCase):
|
|
"""Base class that builds an App and always closes its SQLite handle."""
|
|
|
|
def build(self, proxies: list[dict] | None = None):
|
|
application, cfg, td = make_app(proxies)
|
|
self.addCleanup(td.cleanup)
|
|
self.addCleanup(application.store.close)
|
|
return application, cfg
|
|
|
|
|
|
class NormalizeHostTest(unittest.TestCase):
|
|
def test_bare_host_has_no_port(self):
|
|
self.assertEqual(app.normalize_host("192.168.3.71"), ("192.168.3.71", None))
|
|
|
|
def test_host_colon_port(self):
|
|
self.assertEqual(app.normalize_host("192.168.3.71:7890"), ("192.168.3.71", 7890))
|
|
|
|
def test_full_url_is_stripped(self):
|
|
self.assertEqual(app.normalize_host("http://192.168.3.71:7890"), ("192.168.3.71", 7890))
|
|
|
|
def test_scheme_and_credentials_are_stripped(self):
|
|
self.assertEqual(app.normalize_host("socks5://user:pw@1.2.3.4:1080"), ("1.2.3.4", 1080))
|
|
|
|
def test_trailing_path_is_dropped(self):
|
|
self.assertEqual(app.normalize_host("socks5h://host.example.com:1080/"), ("host.example.com", 1080))
|
|
|
|
def test_bracketed_ipv6(self):
|
|
self.assertEqual(app.normalize_host("[2001:db8::1]:1080"), ("2001:db8::1", 1080))
|
|
|
|
def test_bare_ipv6_is_not_split_on_colons(self):
|
|
self.assertEqual(app.normalize_host("2001:db8::1"), ("2001:db8::1", None))
|
|
|
|
def test_whitespace_and_empty(self):
|
|
self.assertEqual(app.normalize_host(" chickliu.fun "), ("chickliu.fun", None))
|
|
self.assertEqual(app.normalize_host(""), ("", None))
|
|
self.assertEqual(app.normalize_host(None), ("", None))
|
|
|
|
def test_non_numeric_port_is_kept_as_host(self):
|
|
self.assertEqual(app.normalize_host("host:notaport"), ("host", None))
|
|
|
|
|
|
class DescribeExcTest(unittest.TestCase):
|
|
def test_timeout_is_never_blank(self):
|
|
for exc in (asyncio.TimeoutError(), TimeoutError()):
|
|
msg = app.describe_exc(exc)
|
|
self.assertTrue(msg.strip())
|
|
self.assertIn("超时", msg)
|
|
|
|
def test_incomplete_read_is_labelled_handshake(self):
|
|
self.assertIn("握手", app.describe_exc(asyncio.IncompleteReadError(b"", 2)))
|
|
|
|
def test_refused_and_dns(self):
|
|
self.assertIn("拒绝", app.describe_exc(ConnectionRefusedError()))
|
|
self.assertIn("解析", app.describe_exc(socket.gaierror(-2, "Name does not resolve")))
|
|
|
|
def test_message_is_preserved_for_other_errors(self):
|
|
self.assertEqual(app.describe_exc(RuntimeError("rep=0x05")), "rep=0x05")
|
|
|
|
def test_bare_exception_falls_back_to_class_name(self):
|
|
self.assertEqual(app.describe_exc(RuntimeError()), "RuntimeError")
|
|
|
|
|
|
class SlugIdTest(AppCase):
|
|
def test_chinese_name_does_not_collide_with_existing_id(self):
|
|
application, _ = self.build([{"id": "01", "name": "15690", "host": "h", "port": 1}])
|
|
self.assertEqual(application.slug_id("香港01"), "node-1")
|
|
|
|
def test_repeated_non_ascii_names_get_distinct_ids(self):
|
|
application, _ = self.build()
|
|
first = application.validate_proxy({"name": "香港01", "host": "1.1.1.1"})
|
|
application.config["proxies"].append(first)
|
|
second = application.validate_proxy({"name": "香港01", "host": "1.1.1.1"})
|
|
self.assertNotEqual(first["id"], second["id"])
|
|
self.assertEqual([first["id"], second["id"]], ["node-1", "node-2"])
|
|
|
|
def test_emoji_only_name_is_handled(self):
|
|
application, _ = self.build()
|
|
self.assertEqual(application.slug_id("🇯🇵日本"), "node-1")
|
|
|
|
def test_ascii_name_keeps_readable_slug(self):
|
|
application, _ = self.build()
|
|
self.assertEqual(application.slug_id("HK Node 01"), "HK-Node-01")
|
|
|
|
def test_ascii_collision_is_suffixed(self):
|
|
application, _ = self.build([{"id": "hk", "name": "hk", "host": "h", "port": 1}])
|
|
self.assertEqual(application.slug_id("hk"), "hk-2")
|
|
|
|
|
|
class ValidateProxyTest(AppCase):
|
|
def test_pasted_url_is_normalized_into_host_and_port(self):
|
|
application, _ = self.build()
|
|
p = application.validate_proxy({"name": "u", "host": "http://192.168.3.71:7890"})
|
|
self.assertEqual((p["host"], p["port"]), ("192.168.3.71", 7890))
|
|
|
|
def test_explicit_port_wins_over_url_port(self):
|
|
application, _ = self.build()
|
|
p = application.validate_proxy({"name": "u", "host": "http://1.2.3.4:7890", "port": 9050})
|
|
self.assertEqual(p["port"], 9050)
|
|
|
|
def test_missing_host_raises_value_error(self):
|
|
application, _ = self.build()
|
|
with self.assertRaises(ValueError):
|
|
application.validate_proxy({"name": "no-host"})
|
|
with self.assertRaises(ValueError):
|
|
application.validate_proxy({"name": "blank", "host": " "})
|
|
|
|
def test_out_of_range_port_raises_value_error(self):
|
|
application, _ = self.build()
|
|
with self.assertRaises(ValueError):
|
|
application.validate_proxy({"name": "p", "host": "1.1.1.1", "port": 70000})
|
|
|
|
def test_edit_preserves_existing_id(self):
|
|
existing = {"id": "410-5585", "name": "old", "host": "1.1.1.1", "port": 1080}
|
|
application, _ = self.build([existing])
|
|
p = application.validate_proxy({"name": "renamed", "host": "2.2.2.2"}, existing=existing)
|
|
self.assertEqual(p["id"], "410-5585")
|
|
self.assertEqual(p["name"], "renamed")
|
|
|
|
|
|
class MigrateProxyHostsTest(AppCase):
|
|
def test_url_host_is_healed_at_startup(self):
|
|
application, _ = self.build([
|
|
{"id": "a", "name": "a", "host": "http://192.168.3.71", "port": 7890},
|
|
{"id": "b", "name": "b", "host": "192.168.3.46", "port": 7890},
|
|
])
|
|
self.assertTrue(application._migrated)
|
|
self.assertEqual(application.get_proxy("a")["host"], "192.168.3.71")
|
|
self.assertEqual(application.get_proxy("a")["port"], 7890)
|
|
self.assertEqual(application.get_proxy("b")["host"], "192.168.3.46")
|
|
|
|
def test_url_port_adopted_when_port_is_default(self):
|
|
application, _ = self.build([
|
|
{"id": "a", "name": "a", "host": "http://10.0.0.5:1088", "port": 1080},
|
|
])
|
|
self.assertEqual(application.get_proxy("a")["port"], 1088)
|
|
|
|
def test_clean_config_is_not_marked_migrated(self):
|
|
application, _ = self.build([{"id": "a", "name": "a", "host": "1.1.1.1", "port": 1080}])
|
|
self.assertFalse(application._migrated)
|
|
|
|
def test_migration_is_persisted_by_start_tasks(self):
|
|
application, cfg = self.build([
|
|
{"id": "a", "name": "a", "host": "http://192.168.3.71", "port": 7890, "enabled": False},
|
|
])
|
|
asyncio.run(application.start_tasks())
|
|
saved = json.loads(cfg.read_text(encoding="utf-8"))
|
|
self.assertEqual(saved["proxies"][0]["host"], "192.168.3.71")
|
|
|
|
|
|
class ManualCheckTest(AppCase):
|
|
def test_force_runs_on_disabled_node_and_records_error(self):
|
|
application, _ = self.build([
|
|
{"id": "dead", "name": "dead", "host": "127.0.0.1", "port": 19999,
|
|
"enabled": False, "timeout_seconds": 2},
|
|
])
|
|
proxy = application.get_proxy("dead")
|
|
asyncio.run(application.run_once(proxy, force=True))
|
|
st = application.state_for("dead")
|
|
self.assertIs(st.last_ok, False)
|
|
self.assertTrue((st.error or "").strip(), "manual check must report a reason")
|
|
|
|
def test_without_force_a_disabled_node_is_skipped(self):
|
|
application, _ = self.build([
|
|
{"id": "dead", "name": "dead", "host": "127.0.0.1", "port": 19999,
|
|
"enabled": False, "timeout_seconds": 2},
|
|
])
|
|
asyncio.run(application.run_once(application.get_proxy("dead")))
|
|
self.assertIsNone(application.state_for("dead").last_ok)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|