feat(py): step 3 — full latprobe package with --verbose flag

Adds the complete Python port of the Go latprobe tool plus diagnostic
verbose output that the Go version does not yet have.

Package (python/latprobe/):
- probe.py: raw-socket HTTP timing with CertInfo/VerboseDetail dataclasses;
  TTFB loop accumulates to \r\n\r\n so headers are parseable without changing
  t_first_byte semantics; captures resolved IP, TLS version/cipher/bits,
  verified cert (via getpeercert()), and all response headers when verbose=True
- aggregate.py: summarize() → per-phase min/avg/max PhaseStats
- cli.py: injectable run(args,stdout,stderr)->int; argparse with injected
  streams; ThreadPoolExecutor concurrency across URLs; four text-rendering
  branches; JSON output; worst-exit-code accumulation; -v/--verbose flag
  appends IP/TLS/cert/header block after every timing table; JSON extended
  with "verbose" object (omitted when flag absent)
- duration.py: parse Go-style duration strings (10s, 500ms, 2m, bare seconds)
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400

Tests:
- test_probe.py: 18 hermetic tests (success, 404, DNS/connect/timeout/scheme
  failures, partial-phase invariants, verbose detail fields)
- test_cli.py: 38 hermetic tests (usage errors, single/aggregate/failure text,
  worst-code, --fail, JSON schema/ordering/grouping, verbose text and JSON)
- test_integration.py: 45 tests against live internet services (badssl.com for
  TLS errors, real cert/IP/header validation in verbose mode)

Makefile: PYTHONPATH fix, fnmatch pattern test_[!i]*.py to exclude
integration tests from py-test, new py-test-integration target

Docs: docs/usage/py-latprobe.md, python/configs/usage-latprobe.md (runnable
reference with captured output), plans for both the package and verbose feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:45:56 +02:00
parent 16fff2f964
commit 24ea9c9e71
16 changed files with 3147 additions and 4 deletions

386
python/tests/test_cli.py Normal file
View File

@@ -0,0 +1,386 @@
import http.server
import io
import json
import socket
import threading
import unittest
from latprobe.cli import (
EXIT_CONNECT,
EXIT_DNS,
EXIT_HTTP,
EXIT_OK,
EXIT_TIMEOUT,
EXIT_USAGE,
run,
)
class _OKHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"hello latprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found")
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _black_hole_port() -> int:
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(10)
port = srv.getsockname()[1]
conns: list = []
def _serve():
while True:
try:
conn, _ = srv.accept()
conns.append(conn)
except OSError:
break
threading.Thread(target=_serve, daemon=True).start()
return port
def _invoke(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
class TestCLIUsageErrors(unittest.TestCase):
def test_no_args_returns_usage(self):
code, out, err = _invoke([])
self.assertEqual(code, EXIT_USAGE)
def test_help_returns_ok(self):
code, out, err = _invoke(["-h"])
self.assertEqual(code, EXIT_OK)
self.assertIn("latprobe", out)
def test_invalid_timeout_returns_usage(self):
code, out, err = _invoke(["--timeout", "bad", "http://example.com"])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("timeout", err)
class TestCLISuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_single_url_success(self):
code, out, err = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("Total", out)
self.assertIn("DNS lookup", out)
def test_output_has_separator(self):
code, out, _ = _invoke([self._url()])
self.assertIn("", out)
def test_count_shows_aggregate(self):
code, out, _ = _invoke(["-n", "3", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("3 samples", out)
self.assertIn("min", out)
self.assertIn("avg", out)
self.assertIn("max", out)
def test_multiple_urls_output_separated_by_blank_line(self):
url = self._url()
code, out, _ = _invoke([url, url])
self.assertEqual(code, EXIT_OK)
self.assertIn("\n\n", out)
def test_fail_flag_ok_on_200(self):
code, out, _ = _invoke(["--fail", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("", out)
def test_fail_flag_on_404(self):
code, out, _ = _invoke(["--fail", self._url(self.nf_port)])
self.assertEqual(code, EXIT_HTTP)
self.assertIn("", out)
class TestCLIFailures(unittest.TestCase):
def test_dns_failure(self):
code, out, _ = _invoke(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
self.assertIn("FAILED", out)
self.assertIn("", out)
def test_connection_refused(self):
port = _free_port()
code, out, _ = _invoke([f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("FAILED", out)
def test_timeout(self):
port = _black_hole_port()
code, _, _ = _invoke([f"http://127.0.0.1:{port}", "--timeout", "200ms"])
self.assertEqual(code, EXIT_TIMEOUT)
def test_worst_code_across_urls(self):
server, port = _start_server(
type("_H", (http.server.BaseHTTPRequestHandler,), {
"do_GET": lambda self: (self.send_response(200), self.end_headers(), self.wfile.write(b"ok")),
"log_message": lambda *a: None,
})
)
try:
code, _, _ = _invoke([
f"http://127.0.0.1:{port}", # exit 0
"http://no.such.host.invalid", # exit 2
])
self.assertEqual(code, EXIT_DNS)
finally:
server.shutdown()
def test_all_failed_single_header(self):
code, out, _ = _invoke(["http://no.such.host.invalid"])
self.assertIn("(FAILED)", out)
self.assertNotIn("0/1 succeeded", out)
def test_all_failed_multi_header(self):
code, out, _ = _invoke([
"-n", "3",
"http://no.such.host.invalid",
])
self.assertIn("0/3 succeeded", out)
def test_mixed_aggregate_shows_samples(self):
port = _free_port()
server, ok_port = _start_server(_OKHandler)
try:
code, out, _ = _invoke([
"-n", "1",
f"http://127.0.0.1:{ok_port}",
f"http://127.0.0.1:{port}",
])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("200", out)
finally:
server.shutdown()
class TestCLIJSON(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_json_success_schema(self):
code, out, _ = _invoke(["--json", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertIsInstance(data, list)
self.assertEqual(len(data), 1)
entry = data[0]
self.assertEqual(entry["url"], self._url())
self.assertEqual(entry["status"], 200)
self.assertEqual(entry["succeeded"], 1)
self.assertEqual(entry["failed"], 0)
self.assertIn("phases", entry)
self.assertIn("total", entry["phases"])
self.assertNotIn("errors", entry)
def test_json_phase_fields(self):
code, out, _ = _invoke(["--json", self._url()])
data = json.loads(out)
total = data[0]["phases"]["total"]
for key in ("min_ms", "avg_ms", "max_ms"):
self.assertIn(key, total)
self.assertGreater(total[key], 0)
def test_json_dns_failure_schema(self):
code, out, _ = _invoke(["--json", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
data = json.loads(out)
entry = data[0]
self.assertEqual(entry["succeeded"], 0)
self.assertEqual(entry["failed"], 1)
self.assertNotIn("phases", entry)
self.assertIn("errors", entry)
self.assertEqual(entry["errors"][0]["phase"], "dns")
def test_json_sampling(self):
code, out, _ = _invoke(["--json", "-n", "3", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertEqual(data[0]["succeeded"], 3)
def test_json_multiple_urls_ordered(self):
code, out, _ = _invoke([
"--json",
self._url(),
"http://no.such.host.invalid",
])
data = json.loads(out)
self.assertEqual(len(data), 2)
self.assertEqual(data[0]["status"], 200)
self.assertEqual(data[1]["succeeded"], 0)
def test_json_fail_flag_exit_code(self):
code, out, _ = _invoke(["--json", "--fail", self._url(self.nf_port)])
self.assertEqual(code, EXIT_HTTP)
data = json.loads(out)
self.assertEqual(data[0]["status"], 404)
def test_json_error_grouping(self):
code, out, _ = _invoke([
"--json", "-n", "2",
"http://no.such.host.invalid",
])
data = json.loads(out)
self.assertEqual(data[0]["errors"][0]["count"], 2)
class TestCLIVerbose(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_verbose_shows_ip(self):
code, out, _ = _invoke(["--verbose", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("IP", out)
self.assertIn("127.0.0.1", out)
def test_short_flag_v(self):
code, out, _ = _invoke(["-v", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("127.0.0.1", out)
def test_verbose_no_tls_for_http(self):
code, out, _ = _invoke(["--verbose", self._url()])
self.assertNotIn("TLS", out)
self.assertNotIn("Cert", out)
def test_verbose_shows_headers(self):
code, out, _ = _invoke(["--verbose", self._url()])
# BaseHTTPServer sends Server and Content-Type
self.assertIn("Server", out)
def test_verbose_absent_without_flag(self):
code, out, _ = _invoke([self._url()])
# Without --verbose, there is no IP label row (the URL contains the
# IP but is not followed by a " IP" verbose block line)
self.assertNotIn("\n IP ", out)
def test_verbose_aggregate_shows_ip(self):
code, out, _ = _invoke(["-v", "-n", "2", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("127.0.0.1", out)
self.assertIn("2 samples", out)
def test_verbose_on_connect_fail_shows_ip(self):
port = _free_port()
code, out, _ = _invoke(["--verbose", f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("FAILED", out)
self.assertIn("127.0.0.1", out)
def test_verbose_dns_fail_no_ip(self):
code, out, _ = _invoke(["--verbose", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
# IP is unknown for DNS failures — verbose block is empty so no IP row
self.assertNotIn("IP", out)
def test_verbose_json_includes_ip(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertIn("verbose", data[0])
self.assertEqual(data[0]["verbose"]["ip"], "127.0.0.1")
def test_verbose_json_includes_headers(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
data = json.loads(out)
self.assertIn("headers", data[0]["verbose"])
self.assertIsInstance(data[0]["verbose"]["headers"], dict)
def test_no_verbose_key_in_json_without_flag(self):
code, out, _ = _invoke(["--json", self._url()])
data = json.loads(out)
self.assertNotIn("verbose", data[0])
def test_verbose_json_no_tls_for_http(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
data = json.loads(out)
self.assertNotIn("tls_version", data[0]["verbose"])
self.assertNotIn("cert", data[0]["verbose"])
def test_verbose_json_connect_fail_has_ip(self):
port = _free_port()
code, out, _ = _invoke(["--verbose", "--json", f"http://127.0.0.1:{port}"])
data = json.loads(out)
self.assertIn("verbose", data[0])
self.assertEqual(data[0]["verbose"]["ip"], "127.0.0.1")
def test_verbose_json_dns_fail_no_verbose_object(self):
code, out, _ = _invoke(["--verbose", "--json", "http://no.such.host.invalid"])
data = json.loads(out)
# DNS failure: detail has no IP, so the verbose dict is empty → omitted
self.assertNotIn("verbose", data[0])

View File

@@ -0,0 +1,430 @@
"""Integration tests against live internet services.
Run with:
make py-test-integration # from repo root
PYTHONPATH=python python3.14 python/tests/test_integration.py -v
These tests require network access. The full suite takes roughly 2040 s
because TLS handshakes to badssl.com are slow and the timeout test waits
2 s for a non-routable IP to time out.
badssl.com tests are marked with a note — that host occasionally resets
connections mid-handshake, so the failure may show up as 'connect' rather
than 'tls'. Both are accepted.
"""
import io
import json
import socket
import sys
import unittest
from latprobe.cli import EXIT_DNS, EXIT_HTTP, EXIT_OK, EXIT_TIMEOUT, EXIT_TLS, run
from latprobe.probe import Options, VerboseDetail, measure
# ── connectivity guard ────────────────────────────────────────────────────────
def _online() -> bool:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(("8.8.8.8", 53))
s.close()
return True
except OSError:
return False
_NEEDS_NET = unittest.skipUnless(_online(), "no internet connectivity")
# ── helpers ───────────────────────────────────────────────────────────────────
def _run(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
# ── success ───────────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestSuccess(unittest.TestCase):
def test_https_all_phases_present(self):
r = measure("https://example.com")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.tls.present)
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_http_no_tls_phase(self):
r = measure("http://example.com")
self.assertIsNone(r.err, r.err)
self.assertFalse(r.tls.present)
def test_iana_org(self):
r = measure("https://www.iana.org")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 200)
def test_google_com(self):
r = measure("https://www.google.com")
self.assertIsNone(r.err, r.err)
self.assertIn(r.status_code, (200, 301, 302))
def test_timings_are_positive(self):
r = measure("https://example.com")
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
ph = getattr(r, attr)
if ph.present:
self.assertGreater(ph.ms, 0, f"{attr}.ms should be > 0")
def test_total_covers_all_present_phases(self):
r = measure("https://example.com")
phase_sum = sum(
getattr(r, a).ms
for a in ("dns", "connect", "tls", "ttfb", "transfer")
if getattr(r, a).present
)
self.assertGreaterEqual(r.total.ms, phase_sum * 0.9)
# ── DNS failure ───────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestDNSFailure(unittest.TestCase):
def test_invalid_tld_phase(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_invalid_tld_https(self):
r = measure("https://this-host-does-not-exist.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertFalse(r.tls.present)
def test_cli_exit_code(self):
code, out, _ = _run(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
self.assertIn("FAILED", out)
self.assertIn("dns", out)
def test_cli_json_errors_field(self):
code, out, _ = _run(["--json", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
data = json.loads(out)
self.assertEqual(data[0]["succeeded"], 0)
self.assertEqual(data[0]["failed"], 1)
self.assertNotIn("phases", data[0])
self.assertEqual(data[0]["errors"][0]["phase"], "dns")
# ── TLS errors (badssl.com) ───────────────────────────────────────────────────
@_NEEDS_NET
class TestTLSErrors(unittest.TestCase):
"""badssl.com occasionally resets mid-handshake; 'connect' is also accepted."""
def _assert_tls_or_connect_fail(self, url: str) -> None:
r = measure(url)
self.assertIsNotNone(r.err, f"{url} — expected failure")
self.assertIn(r.fail_phase, ("tls", "connect"),
f"unexpected phase for {url}: {r.fail_phase}")
self.assertTrue(r.dns.present, "dns should have completed")
def test_expired_cert(self):
self._assert_tls_or_connect_fail("https://expired.badssl.com/")
def test_self_signed_cert(self):
self._assert_tls_or_connect_fail("https://self-signed.badssl.com/")
def test_incomplete_chain(self):
self._assert_tls_or_connect_fail("https://incomplete-chain.badssl.com/")
def test_cli_exit_code_tls(self):
code, out, _ = _run(["https://expired.badssl.com/"])
# badssl may reset connection → connect (3) or tls (5); both > 0
self.assertGreater(code, 0)
self.assertIn("FAILED", out)
def test_partial_phases_dns_and_connect_present(self):
r = measure("https://expired.badssl.com/")
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
# tls phase is recorded even when it fails
self.assertTrue(r.tls.present)
# ── HTTP 4xx / 5xx ───────────────────────────────────────────────────────────
@_NEEDS_NET
class TestHTTP4xx(unittest.TestCase):
def test_google_404_probe_succeeds_at_network_level(self):
r = measure("https://www.google.com/this-page-does-not-exist-at-all-1234567890")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
self.assertTrue(r.total.present)
def test_github_404_all_phases_present(self):
r = measure("https://github.com/this-repo-does-not-exist-abcxyz123/no-way")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
for attr in ("dns", "connect", "tls", "ttfb", "transfer"):
self.assertTrue(getattr(r, attr).present, f"{attr} should be present")
def test_cli_exit_ok_without_fail_flag(self):
code, out, _ = _run(
["https://www.google.com/this-page-does-not-exist-at-all-1234567890"]
)
self.assertEqual(code, EXIT_OK)
self.assertIn("404", out)
self.assertNotIn("", out)
def test_cli_exit_http_with_fail_flag(self):
code, out, _ = _run([
"--fail",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_HTTP)
self.assertIn("", out)
def test_cli_json_404_status(self):
code, out, _ = _run([
"--json",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertEqual(data[0]["status"], 404)
self.assertEqual(data[0]["succeeded"], 1)
self.assertNotIn("errors", data[0])
def test_iana_404(self):
r = measure("https://www.iana.org/this-page-does-not-exist-either")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
# ── timeout ───────────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestTimeout(unittest.TestCase):
"""Uses a non-routable IP (RFC 5737).
On networks that silently drop packets the connect phase waits the full
timeout → fail_phase='timeout'. On networks that return ICMP unreachable
immediately the connect fails with an OS error → fail_phase='connect'.
Both outcomes are accepted; the important assertion is that the probe fails
and the exit code is ≥ EXIT_CONNECT.
"""
_URL = "http://10.255.255.1/"
_OPTS = Options(timeout=2.0)
_FAIL_PHASES = ("timeout", "connect")
def test_probe_fail_phase(self):
r = measure(self._URL, self._OPTS)
self.assertIn(r.fail_phase, self._FAIL_PHASES)
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present) # IP literal — no real DNS lookup
self.assertTrue(r.connect.present)
self.assertTrue(r.total.present)
def test_cli_exit_code(self):
from latprobe.cli import EXIT_CONNECT
code, out, _ = _run(["--timeout", "2s", self._URL])
self.assertIn(code, (EXIT_CONNECT, EXIT_TIMEOUT))
self.assertIn("FAILED", out)
def test_cli_json_error_phase(self):
from latprobe.cli import EXIT_CONNECT
code, out, _ = _run(["--json", "--timeout", "2s", self._URL])
self.assertIn(code, (EXIT_CONNECT, EXIT_TIMEOUT))
data = json.loads(out)
self.assertIn(data[0]["errors"][0]["phase"], self._FAIL_PHASES)
# ── end-to-end multi-URL ─────────────────────────────────────────────────────
@_NEEDS_NET
class TestEndToEnd(unittest.TestCase):
def test_multi_url_worst_code_is_dns(self):
"""success (0) + dns failure (2) → worst exit = 2."""
code, out, _ = _run([
"https://example.com",
"http://no.such.host.invalid",
])
self.assertEqual(code, EXIT_DNS)
self.assertIn("200", out)
self.assertIn("FAILED", out)
def test_multi_url_output_ordered(self):
"""URLs appear in input order regardless of which resolves faster."""
code, out, _ = _run([
"https://www.iana.org",
"https://example.com",
])
self.assertEqual(code, EXIT_OK)
iana_pos = out.find("iana.org")
example_pos = out.find("example.com")
self.assertLess(iana_pos, example_pos)
def test_sampling_json_schema(self):
code, out, _ = _run(["--json", "-n", "3", "https://example.com"])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
entry = data[0]
self.assertEqual(entry["succeeded"], 3)
self.assertIn("tls", entry["phases"])
for phase, stats in entry["phases"].items():
self.assertLessEqual(stats["min_ms"], stats["avg_ms"])
self.assertLessEqual(stats["avg_ms"], stats["max_ms"])
def test_sampling_aggregate_text(self):
code, out, _ = _run(["-n", "3", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("3 samples", out)
self.assertIn("min", out)
self.assertIn("max", out)
def test_concurrency_flag(self):
code, out, _ = _run([
"-c", "2",
"https://example.com",
"https://www.iana.org",
])
self.assertEqual(code, EXIT_OK)
self.assertIn("example.com", out)
self.assertIn("iana.org", out)
def test_mixed_fail_flag_exit_code(self):
"""200 (ok) + 404 with --fail → worst = 6."""
code, out, _ = _run([
"--fail",
"https://example.com",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_HTTP)
# ── verbose mode ─────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestVerboseIntegration(unittest.TestCase):
def test_https_verbose_has_ip(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertRegex(r.detail.resolved_ip, r"^\d+\.\d+\.\d+\.\d+$",
"expected IPv4 address")
def test_https_verbose_has_tls_version(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIn("TLS", r.detail.tls_version,
f"expected TLSv1.x, got: {r.detail.tls_version!r}")
def test_https_verbose_has_cipher(self):
r = measure("https://example.com", Options(verbose=True))
self.assertNotEqual(r.detail.tls_cipher, "")
def test_https_verbose_cert_cn(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNotNone(r.detail.cert)
self.assertIn("example.com", r.detail.cert.cn)
def test_https_verbose_cert_expiry_format(self):
r = measure("https://example.com", Options(verbose=True))
expiry = r.detail.cert.expiry
self.assertRegex(expiry, r"^\d{4}-\d{2}-\d{2}$",
f"expected YYYY-MM-DD, got: {expiry!r}")
def test_https_verbose_cert_verified(self):
r = measure("https://example.com", Options(verbose=True))
self.assertTrue(r.detail.cert.verified)
def test_https_verbose_cert_issuer_set(self):
r = measure("https://example.com", Options(verbose=True))
self.assertNotEqual(r.detail.cert.issuer_cn, "")
def test_https_verbose_headers_present(self):
r = measure("https://example.com", Options(verbose=True))
self.assertGreater(len(r.detail.headers), 0)
# example.com always sends Content-Type
self.assertIn("Content-Type", r.detail.headers)
def test_http_verbose_no_tls(self):
r = measure("http://example.com", Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
self.assertIsNone(r.detail.cert)
def test_redirect_verbose_shows_location(self):
r = measure("http://example.com", Options(verbose=True))
# example.com HTTP redirects to HTTPS — Location header should be present
if r.status_code in (301, 302, 307, 308):
self.assertIn("Location", r.detail.headers)
def test_dns_fail_verbose_no_ip(self):
r = measure("http://no.such.host.invalid", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertEqual(r.detail.resolved_ip, "")
self.assertEqual(r.detail.headers, {})
def test_cli_verbose_text_shows_ip(self):
code, out, _ = _run(["--verbose", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("IP", out)
self.assertIn("TLS", out)
self.assertIn("Cert", out)
self.assertIn("Content-Type", out)
def test_cli_verbose_json_schema(self):
code, out, _ = _run(["--verbose", "--json", "https://example.com"])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
v = data[0]["verbose"]
self.assertIn("ip", v)
self.assertIn("tls_version", v)
self.assertIn("tls_cipher", v)
self.assertIn("tls_bits", v)
self.assertIn("cert", v)
self.assertIn("headers", v)
def test_cli_verbose_json_cert_fields(self):
code, out, _ = _run(["--verbose", "--json", "https://example.com"])
data = json.loads(out)
cert = data[0]["verbose"]["cert"]
self.assertIn("cn", cert)
self.assertIn("expiry", cert)
self.assertIn("issuer_cn", cert)
self.assertTrue(cert["verified"])
def test_cli_verbose_tls_fail_shows_ip(self):
code, out, _ = _run(["--verbose", "https://expired.badssl.com/"])
self.assertGreater(code, 0)
# IP should be shown even when TLS fails (DNS + TCP succeeded)
self.assertIn("IP", out)
if __name__ == "__main__":
unittest.main(verbosity=2)

208
python/tests/test_probe.py Normal file
View File

@@ -0,0 +1,208 @@
import http.server
import socket
import threading
import time
import unittest
from latprobe.probe import Options, Phase, Result, VerboseDetail, measure
class _OKHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"hello latprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found")
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _black_hole_port() -> int:
"""Bind a port that accepts TCP but never sends any data (triggers TTFB timeout)."""
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(10)
port = srv.getsockname()[1]
conns: list = []
def _serve():
while True:
try:
conn, _ = srv.accept()
conns.append(conn)
except OSError:
break
threading.Thread(target=_serve, daemon=True).start()
return port
class TestMeasureSuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def test_all_phases_present_on_success(self):
r = measure(f"http://127.0.0.1:{self.ok_port}")
self.assertIsNone(r.err)
self.assertEqual(r.fail_phase, "")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.tls.present) # http — no TLS
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_total_gte_sum_of_phases(self):
r = measure(f"http://127.0.0.1:{self.ok_port}")
phase_sum = r.dns.ms + r.connect.ms + r.ttfb.ms + r.transfer.ms
self.assertGreaterEqual(r.total.ms, phase_sum * 0.9)
def test_status_code_404(self):
r = measure(f"http://127.0.0.1:{self.nf_port}")
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 404)
self.assertTrue(r.total.present)
def test_options_default_timeout(self):
opts = Options()
r = measure(f"http://127.0.0.1:{self.ok_port}", opts)
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 200)
class TestMeasureFailures(unittest.TestCase):
def test_dns_failure(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_connection_refused(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}")
self.assertEqual(r.fail_phase, "connect")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.ttfb.present)
self.assertTrue(r.total.present)
def test_ttfb_timeout(self):
port = _black_hole_port()
r = measure(f"http://127.0.0.1:{port}", Options(timeout=0.2))
self.assertEqual(r.fail_phase, "timeout")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.total.present)
def test_bad_scheme(self):
r = measure("ftp://example.com")
self.assertEqual(r.fail_phase, "request")
self.assertIsNotNone(r.err)
self.assertFalse(r.total.present)
def test_partial_phases_preserved_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}")
self.assertTrue(r.dns.present, "dns should be recorded before connect")
self.assertTrue(r.connect.present, "connect duration recorded even on refusal")
self.assertGreater(r.dns.ms, 0)
self.assertGreater(r.connect.ms, 0)
class TestVerboseDetail(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
def _url(self):
return f"http://127.0.0.1:{self.ok_port}"
def test_detail_none_without_verbose(self):
r = measure(self._url())
self.assertIsNone(r.detail)
def test_detail_present_with_verbose(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail, VerboseDetail)
def test_resolved_ip_set_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
def test_no_tls_fields_for_http(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
self.assertEqual(r.detail.tls_cipher, "")
self.assertEqual(r.detail.tls_bits, 0)
self.assertIsNone(r.detail.cert)
def test_headers_populated_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail.headers, dict)
# BaseHTTPServer always sends Content-Type for 200 responses
self.assertTrue(len(r.detail.headers) > 0)
def test_ip_set_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}", Options(verbose=True))
self.assertEqual(r.fail_phase, "connect")
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
self.assertEqual(r.detail.headers, {})
def test_detail_none_on_dns_failure_without_verbose(self):
r = measure("http://no.such.host.invalid")
self.assertIsNone(r.detail)
def test_detail_empty_ip_on_dns_failure(self):
r = measure("http://no.such.host.invalid", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertEqual(r.detail.resolved_ip, "")
def test_headers_not_populated_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}", Options(verbose=True))
self.assertEqual(r.detail.headers, {})