"""Hermetic tests for hxprobe.probe.measure(), including a redirect test covering the Go-http.DefaultClient-parity feature (redirects followed by default) that a plain raw-socket HTTP/1.1 client would not have.""" import http.server import socket import threading import unittest from hxprobe.probe import Options, VerboseDetail, measure class _OKHandler(http.server.BaseHTTPRequestHandler): # HTTP/1.1 (BaseHTTPRequestHandler defaults to 1.0) so hxprobe negotiates # http_version="HTTP/1.1" instead of "HTTP/1.0". Keep-alive is then in # play, so every response below sends an explicit Content-Length — # without it, h11 has no way to detect body-end short of connection # close, and the client hangs until it hits the request timeout. protocol_version = "HTTP/1.1" def _send_body(self, status: int, body: bytes) -> None: self.send_response(status) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): self._send_body(200, b"hello hxprobe") def log_message(self, *args): pass class _NotFoundHandler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): body = b"not found" self.send_response(404) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass class _RedirectHandler(http.server.BaseHTTPRequestHandler): """/redirect -> 302 to /landed; /landed -> 200.""" protocol_version = "HTTP/1.1" def do_GET(self): if self.path == "/redirect": self.send_response(302) self.send_header("Location", "/landed") self.send_header("Content-Length", "0") self.end_headers() else: body = b"landed" self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) 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 a read 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_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_follows_redirects_and_http2(self): opts = Options() self.assertTrue(opts.follow_redirects) self.assertTrue(opts.http2) 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) class TestRedirects(unittest.TestCase): """The Go-http.DefaultClient-parity feature: redirects followed by default, with dns/connect/tls timed from the first hop only.""" @classmethod def setUpClass(cls): cls.server, cls.port = _start_server(_RedirectHandler) @classmethod def tearDownClass(cls): cls.server.shutdown() def _url(self, path: str) -> str: return f"http://127.0.0.1:{self.port}{path}" def test_follows_redirect_by_default(self): r = measure(self._url("/redirect"), Options(verbose=True)) self.assertIsNone(r.err) self.assertEqual(r.status_code, 200) self.assertEqual(r.detail.redirect_count, 1) def test_no_follow_redirects_reports_302(self): r = measure(self._url("/redirect"), Options(follow_redirects=False, verbose=True)) self.assertIsNone(r.err) self.assertEqual(r.status_code, 302) self.assertEqual(r.detail.redirect_count, 0) def test_dns_and_connect_present_across_redirect(self): r = measure(self._url("/redirect")) self.assertTrue(r.dns.present) self.assertTrue(r.connect.present) self.assertTrue(r.ttfb.present) self.assertTrue(r.total.present) 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_http_version_set_on_success(self): r = measure(self._url(), Options(verbose=True)) self.assertIn(r.detail.http_version, ("HTTP/1.1", "HTTP/2")) def test_no_tls_fields_for_http(self): r = measure(self._url(), Options(verbose=True)) self.assertEqual(r.detail.tls_version, "") 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) 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") if __name__ == "__main__": unittest.main()