Files
s5p/tests/test_api.py
user 29b4a36863 feat: named proxy pools with per-listener assignment
Add proxy_pools: top-level config (dict of name -> pool config) so
listeners can draw from different proxy sources. Each pool has
independent sources, health testing, state persistence, and refresh
cycles.

- PoolSourceConfig gains mitm: bool|None for API ?mitm=0/1 filtering
- ListenerConfig gains pool_name for named pool assignment
- ProxyPool gains name param with prefixed log messages and
  per-name state file derivation (pool-{name}.json)
- server.py replaces single proxy_pool with proxy_pools dict,
  validates listener pool references at startup, per-listener closure
- API /pool merges all pools (with pool field on multi-pool entries),
  /status and /config expose per-pool summaries
- Backward compat: singular proxy_pool: registers as "default"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:33:53 +01:00

554 lines
17 KiB
Python

"""Tests for the control API module."""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
from s5p.api import (
_handle_config,
_handle_metrics,
_handle_pool,
_handle_status,
_handle_tor,
_handle_tor_newnym,
_json_response,
_parse_request,
_route,
)
from s5p.config import ChainHop, Config, ListenerConfig, PoolSourceConfig, ProxyPoolConfig
from s5p.metrics import Metrics
# -- request parsing ---------------------------------------------------------
class TestParseRequest:
"""Test HTTP request line parsing."""
def test_get(self):
assert _parse_request(b"GET /status HTTP/1.1\r\n") == ("GET", "/status")
def test_post(self):
assert _parse_request(b"POST /reload HTTP/1.1\r\n") == ("POST", "/reload")
def test_strips_query_string(self):
assert _parse_request(b"GET /pool?foo=bar HTTP/1.1\r\n") == ("GET", "/pool")
def test_method_uppercased(self):
assert _parse_request(b"get /metrics HTTP/1.1\r\n") == ("GET", "/metrics")
def test_empty(self):
assert _parse_request(b"") == ("", "")
def test_garbage(self):
assert _parse_request(b"\xff\xfe") == ("", "")
def test_incomplete(self):
assert _parse_request(b"GET\r\n") == ("", "")
# -- JSON response -----------------------------------------------------------
class TestJsonResponse:
"""Test HTTP JSON response builder."""
def test_format(self):
writer = MagicMock()
written = bytearray()
writer.write = lambda data: written.extend(data)
_json_response(writer, 200, {"ok": True})
text = written.decode()
assert "HTTP/1.1 200 OK\r\n" in text
assert "Content-Type: application/json\r\n" in text
assert "Connection: close\r\n" in text
# body after double newline
body = text.split("\r\n\r\n", 1)[1]
assert json.loads(body) == {"ok": True}
def test_404(self):
writer = MagicMock()
written = bytearray()
writer.write = lambda data: written.extend(data)
_json_response(writer, 404, {"error": "not found"})
text = written.decode()
assert "HTTP/1.1 404 Not Found\r\n" in text
# -- helpers -----------------------------------------------------------------
def _make_ctx(
config: Config | None = None,
pool: MagicMock | None = None,
pools: dict | None = None,
tor: MagicMock | None = None,
) -> dict:
"""Build a mock context dict."""
return {
"config": config or Config(),
"metrics": Metrics(),
"pool": pool,
"pools": pools,
"hop_pool": None,
"tor": tor,
}
# -- GET handlers ------------------------------------------------------------
class TestHandleStatus:
"""Test GET /status handler."""
def test_basic(self):
ctx = _make_ctx()
ctx["metrics"].connections = 10
ctx["metrics"].success = 8
status, body = _handle_status(ctx)
assert status == 200
assert body["connections"] == 10
assert body["success"] == 8
assert "uptime" in body
assert "rate" in body
assert "latency" in body
def test_with_pool(self):
pool = MagicMock()
pool.alive_count = 5
pool.count = 10
ctx = _make_ctx(pool=pool)
_, body = _handle_status(ctx)
assert body["pool"] == {"alive": 5, "total": 10}
def test_with_listeners(self):
config = Config(
listeners=[
ListenerConfig(
listen_host="0.0.0.0", listen_port=1080,
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
),
ListenerConfig(
listen_host="0.0.0.0", listen_port=1081,
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
pool_hops=1,
),
],
)
ctx = _make_ctx(config=config)
# record some latency for the first listener
ctx["metrics"].get_listener_latency("0.0.0.0:1080").record(0.2)
_, body = _handle_status(ctx)
assert len(body["listeners"]) == 2
assert body["listeners"][0]["chain"] == ["socks5://127.0.0.1:9050"]
assert body["listeners"][0]["pool_hops"] == 0
assert body["listeners"][1]["pool_hops"] == 1
# per-listener latency present on each entry
assert "latency" in body["listeners"][0]
assert body["listeners"][0]["latency"]["count"] == 1
assert "latency" in body["listeners"][1]
assert body["listeners"][1]["latency"] is None
class TestHandleStatusPools:
"""Test GET /status with multiple named pools."""
def test_multi_pool_summary(self):
pool_a = MagicMock()
pool_a.alive_count = 5
pool_a.count = 10
pool_a.name = "clean"
pool_b = MagicMock()
pool_b.alive_count = 3
pool_b.count = 8
pool_b.name = "mitm"
ctx = _make_ctx(pools={"clean": pool_a, "mitm": pool_b})
_, body = _handle_status(ctx)
assert body["pool"] == {"alive": 8, "total": 18}
assert body["pools"]["clean"] == {"alive": 5, "total": 10}
assert body["pools"]["mitm"] == {"alive": 3, "total": 8}
class TestHandleMetrics:
"""Test GET /metrics handler."""
def test_returns_dict(self):
ctx = _make_ctx()
ctx["metrics"].connections = 42
ctx["metrics"].bytes_in = 1024
status, body = _handle_metrics(ctx)
assert status == 200
assert body["connections"] == 42
assert body["bytes_in"] == 1024
assert "uptime" in body
assert "rate" in body
assert "latency" in body
assert "listener_latency" in body
class TestHandlePool:
"""Test GET /pool handler."""
def test_no_pool(self):
ctx = _make_ctx()
status, body = _handle_pool(ctx)
assert status == 200
assert body == {"alive": 0, "total": 0, "proxies": {}}
def test_with_entries(self):
pool = MagicMock()
pool.alive_count = 1
pool.count = 2
entry_alive = MagicMock(
alive=True, fails=0, tests=5,
last_ok=100.0, last_test=100.0, last_seen=100.0,
)
entry_dead = MagicMock(
alive=False, fails=3, tests=5,
last_ok=0.0, last_test=100.0, last_seen=100.0,
)
pool._proxies = {
"socks5://1.2.3.4:1080": entry_alive,
"socks5://5.6.7.8:1080": entry_dead,
}
ctx = _make_ctx(pool=pool)
_, body = _handle_pool(ctx)
assert len(body["proxies"]) == 2
assert body["proxies"]["socks5://1.2.3.4:1080"]["alive"] is True
def test_alive_only(self):
pool = MagicMock()
pool.alive_count = 1
pool.count = 2
entry_alive = MagicMock(
alive=True, fails=0, tests=5,
last_ok=100.0, last_test=100.0, last_seen=100.0,
)
entry_dead = MagicMock(
alive=False, fails=3, tests=5,
last_ok=0.0, last_test=100.0, last_seen=100.0,
)
pool._proxies = {
"socks5://1.2.3.4:1080": entry_alive,
"socks5://5.6.7.8:1080": entry_dead,
}
ctx = _make_ctx(pool=pool)
_, body = _handle_pool(ctx, alive_only=True)
assert len(body["proxies"]) == 1
assert "socks5://1.2.3.4:1080" in body["proxies"]
class TestHandlePoolMulti:
"""Test GET /pool with multiple named pools."""
def test_merges_entries(self):
pool_a = MagicMock()
pool_a.alive_count = 1
pool_a.count = 1
pool_a.name = "clean"
entry_a = MagicMock(
alive=True, fails=0, tests=5,
last_ok=100.0, last_test=100.0, last_seen=100.0,
)
pool_a._proxies = {"socks5://1.2.3.4:1080": entry_a}
pool_b = MagicMock()
pool_b.alive_count = 1
pool_b.count = 1
pool_b.name = "mitm"
entry_b = MagicMock(
alive=True, fails=0, tests=3,
last_ok=90.0, last_test=90.0, last_seen=90.0,
)
pool_b._proxies = {"socks5://5.6.7.8:1080": entry_b}
ctx = _make_ctx(pools={"clean": pool_a, "mitm": pool_b})
_, body = _handle_pool(ctx)
assert body["alive"] == 2
assert body["total"] == 2
assert len(body["proxies"]) == 2
assert body["proxies"]["socks5://1.2.3.4:1080"]["pool"] == "clean"
assert body["proxies"]["socks5://5.6.7.8:1080"]["pool"] == "mitm"
assert "pools" in body
assert body["pools"]["clean"] == {"alive": 1, "total": 1}
def test_single_pool_no_pool_field(self):
"""Single pool: no 'pool' field on entries, no 'pools' summary."""
pool = MagicMock()
pool.alive_count = 1
pool.count = 1
pool.name = "default"
entry = MagicMock(
alive=True, fails=0, tests=5,
last_ok=100.0, last_test=100.0, last_seen=100.0,
)
pool._proxies = {"socks5://1.2.3.4:1080": entry}
ctx = _make_ctx(pools={"default": pool})
_, body = _handle_pool(ctx)
assert "pool" not in body["proxies"]["socks5://1.2.3.4:1080"]
assert "pools" not in body
class TestHandleConfig:
"""Test GET /config handler."""
def test_basic(self):
config = Config(
timeout=15.0, retries=5, log_level="debug",
listeners=[ListenerConfig(listen_host="0.0.0.0", listen_port=1080)],
)
ctx = _make_ctx(config=config)
status, body = _handle_config(ctx)
assert status == 200
assert body["timeout"] == 15.0
assert body["retries"] == 5
assert body["log_level"] == "debug"
assert len(body["listeners"]) == 1
assert body["listeners"][0]["listen"] == "0.0.0.0:1080"
def test_with_proxy_pool(self):
pp = ProxyPoolConfig(
sources=[PoolSourceConfig(url="http://api:8081/proxies")],
refresh=600.0,
test_interval=60.0,
max_fails=5,
)
config = Config(
proxy_pool=pp,
listeners=[ListenerConfig(
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
pool_hops=1,
)],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert body["proxy_pool"]["refresh"] == 600.0
assert body["proxy_pool"]["sources"][0]["url"] == "http://api:8081/proxies"
assert body["listeners"][0]["pool_hops"] == 1
def test_with_proxy_pools(self):
pp_clean = ProxyPoolConfig(
sources=[PoolSourceConfig(url="http://api:8081/proxies/all", mitm=False)],
refresh=300.0,
test_interval=120.0,
max_fails=3,
)
pp_mitm = ProxyPoolConfig(
sources=[PoolSourceConfig(url="http://api:8081/proxies/all", mitm=True)],
refresh=300.0,
test_interval=120.0,
max_fails=3,
)
config = Config(
proxy_pools={"clean": pp_clean, "mitm": pp_mitm},
listeners=[ListenerConfig(
listen_host="0.0.0.0", listen_port=1080,
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
pool_hops=2, pool_name="clean",
)],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert "proxy_pools" in body
assert body["proxy_pools"]["clean"]["sources"][0]["mitm"] is False
assert body["proxy_pools"]["mitm"]["sources"][0]["mitm"] is True
assert body["listeners"][0]["pool"] == "clean"
def test_with_tor_nodes(self):
config = Config(
tor_nodes=[
ChainHop("socks5", "10.200.1.1", 9050),
ChainHop("socks5", "10.200.1.13", 9050),
],
listeners=[ListenerConfig()],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert body["tor_nodes"] == [
"socks5://10.200.1.1:9050",
"socks5://10.200.1.13:9050",
]
def test_no_tor_nodes(self):
config = Config(listeners=[ListenerConfig()])
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert "tor_nodes" not in body
class TestHandleStatusTorNodes:
"""Test tor_nodes in GET /status response."""
def test_tor_nodes_in_status(self):
config = Config(
tor_nodes=[
ChainHop("socks5", "10.200.1.1", 9050),
ChainHop("socks5", "10.200.1.13", 9050),
],
listeners=[ListenerConfig()],
)
ctx = _make_ctx(config=config)
_, body = _handle_status(ctx)
assert body["tor_nodes"] == [
"socks5://10.200.1.1:9050",
"socks5://10.200.1.13:9050",
]
def test_no_tor_nodes_in_status(self):
ctx = _make_ctx()
_, body = _handle_status(ctx)
assert "tor_nodes" not in body
# -- routing -----------------------------------------------------------------
class TestRouting:
"""Test request routing and error responses."""
def test_get_status(self):
ctx = _make_ctx()
status, body = asyncio.run(_route("GET", "/status", ctx))
assert status == 200
def test_get_metrics(self):
ctx = _make_ctx()
status, _ = asyncio.run(_route("GET", "/metrics", ctx))
assert status == 200
def test_get_pool(self):
ctx = _make_ctx()
status, _ = asyncio.run(_route("GET", "/pool", ctx))
assert status == 200
def test_get_pool_alive(self):
ctx = _make_ctx()
status, _ = asyncio.run(_route("GET", "/pool/alive", ctx))
assert status == 200
def test_get_config(self):
ctx = _make_ctx()
status, _ = asyncio.run(_route("GET", "/config", ctx))
assert status == 200
def test_post_reload(self):
ctx = _make_ctx()
ctx["reload_fn"] = AsyncMock()
status, body = asyncio.run(_route("POST", "/reload", ctx))
assert status == 200
assert body == {"ok": True}
def test_post_pool_test(self):
pool = MagicMock()
pool._run_health_tests = AsyncMock()
ctx = _make_ctx(pool=pool)
status, body = asyncio.run(_route("POST", "/pool/test", ctx))
assert status == 200
assert body == {"ok": True}
def test_post_pool_refresh(self):
pool = MagicMock()
pool._fetch_all_sources = AsyncMock()
ctx = _make_ctx(pool=pool)
status, body = asyncio.run(_route("POST", "/pool/refresh", ctx))
assert status == 200
assert body == {"ok": True}
def test_404(self):
ctx = _make_ctx()
status, body = asyncio.run(_route("GET", "/nonexistent", ctx))
assert status == 404
assert "error" in body
def test_405_wrong_method(self):
ctx = _make_ctx()
status, body = asyncio.run(_route("POST", "/status", ctx))
assert status == 405
assert "GET" in body["error"]
def test_405_get_on_post_route(self):
ctx = _make_ctx()
status, body = asyncio.run(_route("GET", "/reload", ctx))
assert status == 405
assert "POST" in body["error"]
def test_get_tor(self):
ctx = _make_ctx()
status, body = asyncio.run(_route("GET", "/tor", ctx))
assert status == 200
assert body == {"enabled": False}
def test_post_tor_newnym(self):
tor = MagicMock()
tor.newnym = AsyncMock(return_value=True)
ctx = _make_ctx(tor=tor)
status, body = asyncio.run(_route("POST", "/tor/newnym", ctx))
assert status == 200
assert body == {"ok": True}
# -- Tor endpoints ----------------------------------------------------------
class TestHandleTor:
"""Test GET /tor handler."""
def test_disabled(self):
ctx = _make_ctx()
status, body = _handle_tor(ctx)
assert status == 200
assert body == {"enabled": False}
def test_connected(self):
tor = MagicMock()
tor.connected = True
tor.last_newnym = 0.0
tor.newnym_interval = 60.0
ctx = _make_ctx(tor=tor)
status, body = _handle_tor(ctx)
assert status == 200
assert body["enabled"] is True
assert body["connected"] is True
assert body["last_newnym"] is None
assert body["newnym_interval"] == 60.0
def test_with_last_newnym(self):
import time
tor = MagicMock()
tor.connected = True
tor.last_newnym = time.monotonic() - 42.0
tor.newnym_interval = 0.0
ctx = _make_ctx(tor=tor)
status, body = _handle_tor(ctx)
assert status == 200
assert body["last_newnym"] is not None
assert body["last_newnym"] >= 42.0
class TestHandleTorNewnym:
"""Test POST /tor/newnym handler."""
def test_success(self):
tor = MagicMock()
tor.newnym = AsyncMock(return_value=True)
ctx = _make_ctx(tor=tor)
status, body = asyncio.run(_handle_tor_newnym(ctx))
assert status == 200
assert body == {"ok": True}
def test_rate_limited(self):
tor = MagicMock()
tor.newnym = AsyncMock(return_value=False)
ctx = _make_ctx(tor=tor)
status, body = asyncio.run(_handle_tor_newnym(ctx))
assert status == 200
assert body["ok"] is False
assert "reason" in body
def test_not_configured(self):
ctx = _make_ctx()
status, body = asyncio.run(_handle_tor_newnym(ctx))
assert status == 400
assert "error" in body