Add a test proxy (#2029)

* Added a proxy implementation and test cases
* Simplified servlet tests to implement all HTTP methods
diff --git a/CHANGES b/CHANGES
index e8dbb53..b199135 100644
--- a/CHANGES
+++ b/CHANGES
@@ -29,6 +29,8 @@
   * Bugfix: in W3CDom, if the jsoup input document contained an empty doctype, the conversion would fail with a
     DOMException. Now, said doctype is discarded, and the conversion continues.
 
+  * Build Improvement: added a local test proxy implementation, for proxy integration tests.
+
 Release 1.16.2 [20-Oct-2023]
   * Improvement: optimized the performance of complex CSS selectors, by adding a cost-based query planner. Evaluators
     are sorted by their relative execution cost, and executed in order of lower to higher cost. This speeds the
diff --git a/src/test/java/org/jsoup/integration/ConnectTest.java b/src/test/java/org/jsoup/integration/ConnectTest.java
index f02840a..1043489 100644
--- a/src/test/java/org/jsoup/integration/ConnectTest.java
+++ b/src/test/java/org/jsoup/integration/ConnectTest.java
@@ -78,7 +78,7 @@
         assertTrue(threw);
     }
 
-    private static String ihVal(String key, Document doc) {
+    static String ihVal(String key, Document doc) {
         final Element first = doc.select("th:contains(" + key + ") + td").first();
         return first != null ? first.text() : null;
     }
@@ -403,7 +403,7 @@
 
     @Test
     public void supportsDeflate() throws IOException {
-        Connection.Response res = Jsoup.connect(Deflateservlet.Url).execute();
+        Connection.Response res = Jsoup.connect(DeflateServlet.Url).execute();
         assertEquals("deflate", res.header("Content-Encoding"));
 
         Document doc = res.parse();
diff --git a/src/test/java/org/jsoup/integration/ProxyTest.java b/src/test/java/org/jsoup/integration/ProxyTest.java
new file mode 100644
index 0000000..2650bf4
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/ProxyTest.java
@@ -0,0 +1,75 @@
+package org.jsoup.integration;
+
+import org.jsoup.Connection;
+import org.jsoup.Jsoup;
+import org.jsoup.integration.servlets.EchoServlet;
+import org.jsoup.integration.servlets.FileServlet;
+import org.jsoup.integration.servlets.HelloServlet;
+import org.jsoup.integration.servlets.ProxyServlet;
+import org.jsoup.integration.servlets.RedirectServlet;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.jsoup.integration.ConnectTest.ihVal;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ Tests Jsoup.connect proxy support
+ */
+public class ProxyTest {
+    private static String echoUrl;
+    private static TestServer.ProxySettings proxy;
+
+    @BeforeAll
+    public static void setUp() {
+        echoUrl = EchoServlet.Url;
+        proxy = ProxyServlet.ProxySettings;
+    }
+
+    @Test void fetchViaProxy() throws IOException {
+        Connection con = Jsoup.connect(HelloServlet.Url)
+            .proxy(proxy.hostname, proxy.port);
+
+        Connection.Response res = con.execute();
+        assertVia(res);
+
+        Document doc = res.parse();
+        Element p = doc.expectFirst("p");
+        assertEquals("Hello, World!", p.text());
+    }
+
+    private static void assertVia(Connection.Response res) {
+        assertEquals(res.header("Via"), ProxyServlet.Via);
+    }
+
+    @Test void redirectViaProxy() throws IOException {
+        Connection.Response res = Jsoup
+            .connect(RedirectServlet.Url)
+            .data(RedirectServlet.LocationParam, echoUrl)
+            .header("Random-Header-name", "hello")
+            .proxy(proxy.hostname, proxy.port)
+            .execute();
+
+        assertVia(res);
+        Document doc = res.parse();
+        assertEquals(echoUrl, doc.location());
+        assertEquals("hello", ihVal("Random-Header-name", doc));
+        assertVia(res);
+    }
+
+    @Test void proxyForSession() throws IOException {
+        Connection session = Jsoup.newSession().proxy(proxy.hostname, proxy.port);
+
+        Connection.Response medRes = session.newRequest().url(FileServlet.urlTo("/htmltests/medium.html")).execute();
+        Connection.Response largeRes = session.newRequest().url(FileServlet.urlTo("/htmltests/large.html")).execute();
+
+        assertVia(medRes);
+        assertVia(largeRes);
+        assertEquals("Medium HTML", medRes.parse().title());
+        assertEquals("Large HTML", largeRes.parse().title());
+    }
+}
diff --git a/src/test/java/org/jsoup/integration/TestServer.java b/src/test/java/org/jsoup/integration/TestServer.java
index fa370d1..3284c2a 100644
--- a/src/test/java/org/jsoup/integration/TestServer.java
+++ b/src/test/java/org/jsoup/integration/TestServer.java
@@ -4,15 +4,28 @@
 import org.eclipse.jetty.server.ServerConnector;
 import org.eclipse.jetty.servlet.ServletHandler;
 import org.jsoup.integration.servlets.BaseServlet;
+import org.jsoup.integration.servlets.ProxyServlet;
 
 import java.net.InetSocketAddress;
 
 public class TestServer {
-    private static final Server jetty = new Server(new InetSocketAddress("localhost", 0));
+    private static final String localhost = "localhost";
+    private static final Server jetty = newServer();
     private static final ServletHandler handler = new ServletHandler();
+    static int port;
+
+    private static final Server proxy = newServer();
+    private static final ServletHandler proxyHandler = new ServletHandler();
+    private static final ProxySettings proxySettings = new ProxySettings();
+
+    private static Server newServer() {
+        return new Server(new InetSocketAddress(localhost, 0));
+    }
 
     static {
         jetty.setHandler(handler);
+        proxy.setHandler(proxyHandler);
+        proxyHandler.addServletWithMapping(ProxyServlet.class, "/*");
     }
 
     private TestServer() {
@@ -20,8 +33,14 @@
 
     public static void start() {
         synchronized (jetty) {
+            if (jetty.isStarted()) return;
+
             try {
                 jetty.start(); // jetty will safely no-op a start on an already running instance
+                port = ((ServerConnector) jetty.getConnectors()[0]).getLocalPort();
+
+                proxy.start();
+                proxySettings.port = ((ServerConnector) proxy.getConnectors()[0]).getLocalPort();
             } catch (Exception e) {
                 throw new IllegalStateException(e);
             }
@@ -35,8 +54,22 @@
 
             String path = "/" + servletClass.getSimpleName();
             handler.addServletWithMapping(servletClass, path + "/*");
-            int port = ((ServerConnector) jetty.getConnectors()[0]).getLocalPort();
-            return "http://localhost:" + port + path;
+            return "http://" + localhost + ":" + port + path;
         }
     }
+
+    public static ProxySettings proxySettings(Class<? extends BaseServlet> servletClass) {
+        synchronized (jetty) {
+            if (!jetty.isStarted())
+                start(); // if running out of the test cases
+
+            return proxySettings;
+        }
+    }
+
+    //public static String proxy
+    public static class ProxySettings {
+        final String hostname = localhost;
+        int port;
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/BaseServlet.java b/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
index 46d8e47..57acca8 100644
--- a/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
@@ -9,24 +9,25 @@
 public abstract class BaseServlet extends HttpServlet {
     static final String TextHtml = "text/html; charset=UTF-8";
 
-    // these are overridden just to get the response name to be 'res' not 'resp'
+    abstract protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException;
+
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        super.doGet(req, res);
+    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+        doIt(req, res);
     }
 
     @Override
-    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        super.doPost(req, res);
+    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+        doIt(req, res);
     }
 
     @Override
-    protected void doPut(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        super.doPut(req, res);
+    protected void doPut(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+        doIt(req, res);
     }
 
     @Override
-    protected void doDelete(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        super.doPut(req, res);
+    protected void doDelete(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+        doIt(req, res);
     }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/CookieServlet.java b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
index 2249f97..fefe3fe 100644
--- a/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
@@ -2,35 +2,19 @@
 
 import org.jsoup.integration.TestServer;
 
-import javax.servlet.ServletException;
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.PrintWriter;
 
-public class CookieServlet extends BaseServlet{
+public class CookieServlet extends BaseServlet {
     public static final String Url = TestServer.map(CookieServlet.class);
     public static final String SetCookiesParam = "setCookies";
     public static final String LocationParam = "loc";
 
-
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    @Override
-    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    @Override
-    protected void doPut(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    private void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         // Do we want to set cookies?
         if (req.getParameter(SetCookiesParam) != null)
             setCookies(res);
diff --git a/src/test/java/org/jsoup/integration/servlets/Deflateservlet.java b/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
similarity index 83%
rename from src/test/java/org/jsoup/integration/servlets/Deflateservlet.java
rename to src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
index 13af916..bf321c1 100644
--- a/src/test/java/org/jsoup/integration/servlets/Deflateservlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
@@ -9,11 +9,11 @@
 import java.util.zip.Deflater;
 import java.util.zip.DeflaterOutputStream;
 
-public class Deflateservlet extends BaseServlet {
-    public static final String Url = TestServer.map(Deflateservlet.class);
+public class DeflateServlet extends BaseServlet {
+    public static final String Url = TestServer.map(DeflateServlet.class);
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         res.setContentType(TextHtml);
         res.setStatus(HttpServletResponse.SC_OK);
         res.setHeader("Content-Encoding", "deflate");
diff --git a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
index c3a9c44..0039c04 100644
--- a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
@@ -26,26 +26,7 @@
     private static final int DefaultCode = HttpServletResponse.SC_OK;
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    @Override
-    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    @Override
-    protected void doPut(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    @Override
-    protected void doDelete(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
-        doIt(req, res);
-    }
-
-    private void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
         int intCode = DefaultCode;
         String code = req.getHeader(CodeParam);
         if (code != null)
diff --git a/src/test/java/org/jsoup/integration/servlets/FileServlet.java b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
index 7447e8c..db68e18 100644
--- a/src/test/java/org/jsoup/integration/servlets/FileServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
@@ -16,7 +16,7 @@
     public static final String DefaultType = "text/html";
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         String contentType = req.getParameter(ContentTypeParam);
         if (contentType == null)
             contentType = DefaultType;
@@ -33,16 +33,11 @@
             Files.copy(file.toPath(), out);
             out.flush();
         } else {
-            res.setStatus(HttpServletResponse.SC_NOT_FOUND);
+            res.sendError(HttpServletResponse.SC_NOT_FOUND);
         }
     }
 
     public static String urlTo(String path) {
         return Url + path;
     }
-
-    @Override
-    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException {
-        doGet(req, res);
-    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/HelloServlet.java b/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
index 7a7924c..4c9f380 100644
--- a/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
@@ -10,7 +10,7 @@
     public static final String Url = TestServer.map(HelloServlet.class);
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         res.setContentType(TextHtml);
         res.setStatus(HttpServletResponse.SC_OK);
 
diff --git a/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java b/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
index 22180e1..67554c5 100644
--- a/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
@@ -12,9 +12,8 @@
     public static final String Magnitude = "magnitude";
     public static final String Larger = "larger";
 
-
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         String magnitude = req.getParameter(Magnitude);
         magnitude  = magnitude == null ? "" : magnitude;
         res.setContentType(TextHtml);
diff --git a/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
new file mode 100644
index 0000000..0c9e30e
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
@@ -0,0 +1,74 @@
+package org.jsoup.integration.servlets;
+
+import org.jsoup.Connection;
+import org.jsoup.Jsoup;
+import org.jsoup.integration.TestServer;
+
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+public class ProxyServlet extends BaseServlet{
+    public static TestServer.ProxySettings ProxySettings = TestServer.proxySettings(ProxyServlet.class);
+    public static String Via = "1.1 jsoup test proxy";
+
+    @Override
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
+        StringBuffer urlBuf = req.getRequestURL();
+        if (req.getQueryString() != null) {
+            urlBuf.append('?').append(req.getQueryString());
+        }
+        String url = urlBuf.toString();
+        //log("Proxying URL: " + url);
+
+        Connection.Method method = Enum.valueOf(Connection.Method.class, req.getMethod());
+        Connection fetch = Jsoup.connect(url)
+            .method(method)
+            .followRedirects(false)
+            .ignoreHttpErrors(true);
+
+        // request headers
+        Enumeration<String> headerNames = req.getHeaderNames();
+        while (headerNames.hasMoreElements()) {
+            String name = headerNames.nextElement();
+            Enumeration<String> values = req.getHeaders(name);
+            while (values.hasMoreElements()) {
+                String value = values.nextElement();
+                //System.out.println("Header: " + name + " = " + value);
+                fetch.header(name, value); // todo - this invocation will replace existing header, not add
+            }
+        }
+
+        // execute
+        Connection.Response fetchRes = fetch.execute();
+        res.setStatus(fetchRes.statusCode());
+
+        // write the response headers
+        res.addHeader("Via", Via);
+        for (Map.Entry<String, List<String>> entry : fetchRes.multiHeaders().entrySet()) {
+            String header = entry.getKey();
+            for (String value : entry.getValue()) {
+                res.addHeader(header,value);
+            }
+        }
+
+        // write the body
+        ServletOutputStream outputStream = res.getOutputStream();
+        BufferedInputStream inputStream = fetchRes.bodyStream();
+        byte[] buffer = new byte[1024];
+        int bytesRead;
+        while ((bytesRead = inputStream.read(buffer)) != -1) {
+            outputStream.write(buffer, 0, bytesRead);
+        }
+
+        outputStream.close();
+        inputStream.close();
+    }
+}
diff --git a/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java b/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
index 5ab52a0..1f90c2f 100644
--- a/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
@@ -5,6 +5,7 @@
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
 
 public class RedirectServlet extends BaseServlet {
     public static final String Url = TestServer.map(RedirectServlet.class);
@@ -14,7 +15,7 @@
     private static final int DefaultCode = HttpServletResponse.SC_MOVED_TEMPORARILY;
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         String location = req.getParameter(LocationParam);
         if (location == null)
             location = "";
@@ -31,10 +32,7 @@
 
         res.setHeader("Location", location);
         res.setStatus(intCode);
+        res.flushBuffer();
     }
 
-    @Override
-    protected void doPost(HttpServletRequest req, HttpServletResponse res) {
-        doGet(req, res);
-    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/SlowRider.java b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
index e8db460..84e876e 100644
--- a/src/test/java/org/jsoup/integration/servlets/SlowRider.java
+++ b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
@@ -16,7 +16,7 @@
     public static final String MaxTimeParam = "maxTime";
 
     @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         pause(1000);
         res.setContentType(TextHtml);
         res.setStatus(HttpServletResponse.SC_OK);