|
29 | 29 | """ The license for the module """ |
30 | 30 |
|
31 | 31 | import re |
| 32 | +import json |
| 33 | +import time |
| 34 | +import socket |
32 | 35 | import unittest |
| 36 | +import threading |
33 | 37 | import collections |
34 | 38 |
|
| 39 | +import http.client |
| 40 | + |
35 | 41 | import netius |
36 | 42 | import netius.extra |
37 | 43 | import netius.clients |
@@ -849,20 +855,6 @@ def _make_response_parser(self, backend, code="200", status="OK"): |
849 | 855 | return parser |
850 | 856 |
|
851 | 857 | def test_close_no_loop_destroys_before_event(self): |
852 | | - """ |
853 | | - When a `Protocol` has no event loop (`_loop` is None), |
854 | | - `close_c()` calls `delay(self.finish)` which invokes |
855 | | - `finish()` immediately (synchronously). `finish()` calls |
856 | | - `destroy()` -> `unbind_all()` which removes all event |
857 | | - handlers. Then when `close()` reaches `trigger("close")` |
858 | | - the handler list is already empty and the relay never |
859 | | - fires, so `_on_prx_close` is never called and the |
860 | | - `conn_map` entry is never cleaned up. |
861 | | -
|
862 | | - This test reproduces the scenario using a real |
863 | | - `StreamProtocol` whose `_loop` is None. |
864 | | - """ |
865 | | - |
866 | 858 | if mock == None: |
867 | 859 | self.skipTest("Skipping test: mock unavailable") |
868 | 860 |
|
@@ -905,3 +897,141 @@ def test_close_no_loop_destroys_before_event(self): |
905 | 897 | # the entry remains (this is the bug) |
906 | 898 | self.assertNotIn(backend, self.server.conn_map) |
907 | 899 | self.assertEqual(self.server.busy_conn, 0) |
| 900 | + |
| 901 | + |
| 902 | +class ReverseProxyIntegrationTest(unittest.TestCase): |
| 903 | + """ |
| 904 | + End-to-end integration tests for the reverse proxy. |
| 905 | +
|
| 906 | + Starts a real ReverseProxyServer in a background thread |
| 907 | + and makes HTTP requests through it to an httpbin |
| 908 | + backend. Verifies the complete data flow including |
| 909 | + routing, header forwarding (Via, X-Forwarded-*), |
| 910 | + response relay, body integrity, and error handling |
| 911 | + for unmatched hosts. |
| 912 | +
|
| 913 | + These tests exercise the protocol-level address |
| 914 | + attribute and other backward-compatibility properties |
| 915 | + that are only reachable through actual network I/O. |
| 916 | +
|
| 917 | + Requires network; skipped when NO_NETWORK is set. |
| 918 | + """ |
| 919 | + |
| 920 | + @classmethod |
| 921 | + def setUpClass(cls): |
| 922 | + if netius.conf("NO_NETWORK", False, cast=bool): |
| 923 | + return |
| 924 | + |
| 925 | + cls.httpbin = netius.conf("HTTPBIN", "httpbin.org") |
| 926 | + |
| 927 | + # create a reverse proxy that forwards all requests to httpbin |
| 928 | + cls.server = netius.extra.ReverseProxyServer( |
| 929 | + hosts={"default": "http://%s" % cls.httpbin}, |
| 930 | + env=False, |
| 931 | + resolve=False, |
| 932 | + ) |
| 933 | + cls.server.x_forwarded_proto = None |
| 934 | + cls.server.x_forwarded_port = None |
| 935 | + |
| 936 | + # call serve() with start=False to bind the socket and set up |
| 937 | + # the poll without entering the event loop yet, using port 0 |
| 938 | + # lets the OS pick a free port which is retrieved afterwards |
| 939 | + cls.server.serve(host="127.0.0.1", port=0, start=False) |
| 940 | + cls.proxy_port = cls.server.port |
| 941 | + |
| 942 | + # start the proxy server event loop in a background thread |
| 943 | + cls.server_thread = threading.Thread(target=cls.server.start, daemon=True) |
| 944 | + cls.server_thread.start() |
| 945 | + |
| 946 | + # wait for the server to be ready (accepting connections) |
| 947 | + for _i in range(50): |
| 948 | + time.sleep(0.1) |
| 949 | + try: |
| 950 | + probe = socket.create_connection( |
| 951 | + ("127.0.0.1", cls.proxy_port), timeout=1 |
| 952 | + ) |
| 953 | + probe.close() |
| 954 | + break |
| 955 | + except (ConnectionRefusedError, OSError): |
| 956 | + continue |
| 957 | + |
| 958 | + @classmethod |
| 959 | + def tearDownClass(cls): |
| 960 | + if not hasattr(cls, "server"): |
| 961 | + return |
| 962 | + cls.server.stop() |
| 963 | + cls.server_thread.join(timeout=5) |
| 964 | + |
| 965 | + def setUp(self): |
| 966 | + if netius.conf("NO_NETWORK", False, cast=bool): |
| 967 | + self.skipTest("Network access is disabled") |
| 968 | + |
| 969 | + def _request(self, path, headers=None): |
| 970 | + conn = http.client.HTTPConnection("127.0.0.1", self.proxy_port, timeout=30) |
| 971 | + try: |
| 972 | + _headers = {"Host": self.httpbin} |
| 973 | + if headers: |
| 974 | + _headers.update(headers) |
| 975 | + conn.request("GET", path, headers=_headers) |
| 976 | + response = conn.getresponse() |
| 977 | + body = response.read() |
| 978 | + response_headers = dict(response.getheaders()) |
| 979 | + return response.status, response_headers, body |
| 980 | + finally: |
| 981 | + conn.close() |
| 982 | + |
| 983 | + def test_simple_get(self): |
| 984 | + code, headers, body = self._request("/get") |
| 985 | + self.assertEqual(code, 200) |
| 986 | + self.assertGreater(len(body), 0) |
| 987 | + |
| 988 | + def test_response_body_integrity(self): |
| 989 | + code, headers, body = self._request("/get") |
| 990 | + self.assertEqual(code, 200) |
| 991 | + data = json.loads(body.decode("utf-8")) |
| 992 | + self.assertIn("headers", data) |
| 993 | + self.assertIn("url", data) |
| 994 | + |
| 995 | + def test_via_header(self): |
| 996 | + code, headers, body = self._request("/get") |
| 997 | + self.assertEqual(code, 200) |
| 998 | + via = headers.get("Via", None) |
| 999 | + self.assertIsNotNone(via, "Proxy should add a Via header to the response") |
| 1000 | + |
| 1001 | + def test_x_forwarded_headers_sent(self): |
| 1002 | + code, headers, body = self._request("/get") |
| 1003 | + self.assertEqual(code, 200) |
| 1004 | + data = json.loads(body.decode("utf-8")) |
| 1005 | + request_headers = data.get("headers", {}) |
| 1006 | + |
| 1007 | + # httpbin echoes the request headers back to us, |
| 1008 | + # build a case-insensitive lookup since different httpbin |
| 1009 | + # implementations normalize casing differently |
| 1010 | + headers_lower = {k.lower(): v for k, v in request_headers.items()} |
| 1011 | + |
| 1012 | + # verify the proxy injected the x-forwarded-* headers, |
| 1013 | + # note that some httpbin variants or upstream proxies may |
| 1014 | + # strip certain headers, so we check for the ones that are |
| 1015 | + # reliably passed through |
| 1016 | + self.assertIn("x-forwarded-host", headers_lower) |
| 1017 | + self.assertIn("x-client-ip", headers_lower) |
| 1018 | + |
| 1019 | + def test_404_no_match(self): |
| 1020 | + connection = http.client.HTTPConnection( |
| 1021 | + "127.0.0.1", self.proxy_port, timeout=30 |
| 1022 | + ) |
| 1023 | + try: |
| 1024 | + connection.request( |
| 1025 | + "GET", "/get", headers={"Host": "unknown.host.example.com"} |
| 1026 | + ) |
| 1027 | + response = connection.getresponse() |
| 1028 | + response.read() |
| 1029 | + self.assertEqual(response.status, 404) |
| 1030 | + finally: |
| 1031 | + connection.close() |
| 1032 | + |
| 1033 | + def test_multiple_requests(self): |
| 1034 | + for _i in range(3): |
| 1035 | + code, headers, body = self._request("/get") |
| 1036 | + self.assertEqual(code, 200) |
| 1037 | + self.assertGreater(len(body), 0) |
0 commit comments