From f537b24349ffe4f4d49f2d3f32b52da6f64ee400 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Wed, 27 May 2026 16:45:08 +0200 Subject: [PATCH] refactor: replace Apache HttpClient with JDK java.net.http.HttpClient --- pom.xml | 5 - .../kopi/ebics/client/HttpRequestSender.java | 92 ++++----- .../ebics/client/HttpRequestSenderTest.java | 174 ++++++++++++++++++ .../java/org/kopi/ebics/client/StubProxy.java | 145 +++++++++++++++ 4 files changed, 367 insertions(+), 49 deletions(-) create mode 100644 src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java create mode 100644 src/test/java/org/kopi/ebics/client/StubProxy.java diff --git a/pom.xml b/pom.xml index ae2639cb..d8b5cb53 100644 --- a/pom.xml +++ b/pom.xml @@ -13,11 +13,6 @@ xmlbeans 5.3.0 - - org.apache.httpcomponents - httpclient - 4.5.14 - org.bouncycastle bcprov-jdk18on diff --git a/src/main/java/org/kopi/ebics/client/HttpRequestSender.java b/src/main/java/org/kopi/ebics/client/HttpRequestSender.java index 3f760b81..82abbf71 100644 --- a/src/main/java/org/kopi/ebics/client/HttpRequestSender.java +++ b/src/main/java/org/kopi/ebics/client/HttpRequestSender.java @@ -19,23 +19,16 @@ package org.kopi.ebics.client; import java.io.IOException; -import java.io.InputStream; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHeaders; -import org.apache.http.HttpHost; -import org.apache.http.auth.AuthScope; -import org.apache.http.auth.UsernamePasswordCredentials; -import org.apache.http.client.CredentialsProvider; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.EntityBuilder; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.ProxyAuthenticationStrategy; -import org.apache.http.util.EntityUtils; import org.kopi.ebics.interfaces.Configuration; import org.kopi.ebics.interfaces.ContentFactory; import org.kopi.ebics.io.ByteArrayContentFactory; @@ -48,9 +41,12 @@ */ public class HttpRequestSender { + private static final Duration TIMEOUT = Duration.ofSeconds(300); + private static final String CONTENT_TYPE = "text/xml; charset=ISO-8859-1"; + private final EbicsSession session; private ContentFactory response; - private final CloseableHttpClient httpClient; + private final HttpClient httpClient; /** * Constructs a new HttpRequestSender with a given ebics @@ -63,33 +59,32 @@ public HttpRequestSender(EbicsSession session) { this.httpClient = createClient(); } - private CloseableHttpClient createClient() { - RequestConfig.Builder configBuilder = RequestConfig.copy(RequestConfig.DEFAULT) - .setSocketTimeout(300_000).setConnectTimeout(300_000); + private HttpClient createClient() { + HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(TIMEOUT); Configuration conf = session.getConfiguration(); String proxyHost = conf.getProperty("http.proxy.host"); - CredentialsProvider credsProvider = null; if (proxyHost != null && !proxyHost.isEmpty()) { int proxyPort = Integer.parseInt(conf.getProperty("http.proxy.port").trim()); - HttpHost proxy = new HttpHost(proxyHost.trim(), proxyPort); - configBuilder.setProxy(proxy); + builder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost.trim(), proxyPort))); String user = conf.getProperty("http.proxy.user"); if (user != null && !user.isEmpty()) { - user = user.trim(); + String trimmedUser = user.trim(); String pwd = conf.getProperty("http.proxy.password").trim(); - credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(new AuthScope(proxyHost, proxyPort), - new UsernamePasswordCredentials(user, pwd)); + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + // Only answer proxy challenges — never leak proxy + // credentials to a server-side 401. + if (getRequestorType() != RequestorType.PROXY) { + return null; + } + return new PasswordAuthentication(trimmedUser, pwd.toCharArray()); + } + }); } } - HttpClientBuilder builder = HttpClientBuilder.create() - .setDefaultRequestConfig(configBuilder.build()); - if (credsProvider != null) { - builder.setDefaultCredentialsProvider(credsProvider); - builder.setProxyAuthenticationStrategy(new ProxyAuthenticationStrategy()); - } return builder.build(); } @@ -102,19 +97,28 @@ private CloseableHttpClient createClient() { * @return the HTTP return code */ public final int send(ContentFactory request) throws IOException { - InputStream input = request.getContent(); - HttpPost method = new HttpPost( - session.getUser().getPartner().getBank().getURL().toString()); - - HttpEntity requestEntity = EntityBuilder.create().setStream(input).build(); - method.setEntity(requestEntity); - method.setHeader(HttpHeaders.CONTENT_TYPE, "text/xml; charset=ISO-8859-1"); + URI uri = URI.create(session.getUser().getPartner().getBank().getURL().toString()); + HttpRequest httpRequest = HttpRequest.newBuilder(uri) + .timeout(TIMEOUT) + .header("Content-Type", CONTENT_TYPE) + .POST(HttpRequest.BodyPublishers.ofInputStream(() -> { + try { + return request.getContent(); + } catch (IOException e) { + throw new RuntimeException(e); + } + })) + .build(); - try (CloseableHttpResponse response = httpClient.execute(method)) { - this.response = new ByteArrayContentFactory( - EntityUtils.toByteArray(response.getEntity())); - return response.getStatusLine().getStatusCode(); + HttpResponse httpResponse; + try { + httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); } + this.response = new ByteArrayContentFactory(httpResponse.body()); + return httpResponse.statusCode(); } /** diff --git a/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java b/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java new file mode 100644 index 00000000..605642ad --- /dev/null +++ b/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java @@ -0,0 +1,174 @@ +package org.kopi.ebics.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.io.ByteArrayContentFactory; +import org.kopi.ebics.session.EbicsSession; +import org.mockito.Mockito; + +/** + * End-to-end test for {@link HttpRequestSender}: spins up an in-process + * {@link HttpServer}, captures what the sender posts, and asserts the + * sender returns the server's response body and status code unmodified. + * + *

Written before migrating from Apache HttpClient 4.x to + * {@link java.net.http.HttpClient} so the contract is pinned independently + * of the underlying client. + */ +class HttpRequestSenderTest { + + private HttpServer server; + private AtomicReference capturedMethod; + private AtomicReference capturedContentType; + private AtomicReference capturedBody; + private volatile int responseStatus; + private volatile byte[] responseBody; + + @BeforeEach + void startServer() throws Exception { + capturedMethod = new AtomicReference<>(); + capturedContentType = new AtomicReference<>(); + capturedBody = new AtomicReference<>(); + responseStatus = 200; + responseBody = "".getBytes(StandardCharsets.UTF_8); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + capturedMethod.set(exchange.getRequestMethod()); + capturedContentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + capturedBody.set(exchange.getRequestBody().readAllBytes()); + exchange.sendResponseHeaders(responseStatus, responseBody.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBody); + } + }); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void postsRequestBodyAndReturnsResponse() throws Exception { + byte[] requestBody = "hello".getBytes(StandardCharsets.UTF_8); + + HttpRequestSender sender = new HttpRequestSender(session(serverUrl())); + int status = sender.send(new ByteArrayContentFactory(requestBody)); + + assertEquals(200, status, "status code must be propagated from server"); + assertEquals("POST", capturedMethod.get(), "must use POST"); + assertEquals("text/xml; charset=ISO-8859-1", capturedContentType.get(), + "Content-Type header must match EBICS expectation"); + assertArrayEquals(requestBody, capturedBody.get(), + "request body bytes must reach the server unchanged"); + assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes(), + "response body must be exposed via getResponseBody()"); + } + + @Test + void propagatesNonSuccessStatusCodes() throws Exception { + responseStatus = 500; + responseBody = "".getBytes(StandardCharsets.UTF_8); + + HttpRequestSender sender = new HttpRequestSender(session(serverUrl())); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(500, status); + assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes()); + } + + @Test + void routesThroughConfiguredProxyWithoutAuth() throws Exception { + try (StubProxy proxy = new StubProxy()) { + proxy.enqueueResponse(200, Map.of(), "".getBytes(StandardCharsets.UTF_8)); + + EbicsSession session = proxiedSession(proxy.port(), null, null); + HttpRequestSender sender = new HttpRequestSender(session); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(200, status); + List reqs = proxy.recordedRequests(); + assertEquals(1, reqs.size(), "proxy must receive exactly one request"); + // Going through a proxy, the request line carries the absolute URI. + assertTrue(reqs.get(0).requestLine().contains("http://bank.example/ebics"), + "request line must contain the absolute target URI; got: " + reqs.get(0).requestLine()); + assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"), + "no Proxy-Authorization header expected when no credentials configured"); + } + } + + @Test + void retriesWithProxyAuthorizationAfter407Challenge() throws Exception { + try (StubProxy proxy = new StubProxy()) { + // First connection: challenge with 407 so the client invokes the Authenticator. + proxy.enqueueResponse(407, + Map.of("Proxy-Authenticate", "Basic realm=\"ebics\""), + new byte[0]); + // Second connection: the retry carrying Proxy-Authorization. + proxy.enqueueResponse(200, Map.of(), "".getBytes(StandardCharsets.UTF_8)); + + EbicsSession session = proxiedSession(proxy.port(), "alice", "s3cret"); + HttpRequestSender sender = new HttpRequestSender(session); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(200, status, "client must complete the request after auth retry"); + List reqs = proxy.recordedRequests(); + assertEquals(2, reqs.size(), "proxy must see the original request plus the auth retry"); + assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"), + "first request must go without credentials (challenge-response flow)"); + + String expectedAuth = "Basic " + + Base64.getEncoder().encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8)); + assertEquals(expectedAuth, reqs.get(1).header("Proxy-Authorization"), + "retry must carry Basic Proxy-Authorization derived from configured credentials"); + } + } + + private URL serverUrl() throws Exception { + return new URL("http://127.0.0.1:" + server.getAddress().getPort() + "/"); + } + + private static EbicsSession session(URL url) { + EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(session.getConfiguration().getProperty(Mockito.anyString())).thenReturn(null); + Mockito.when(session.getUser().getPartner().getBank().getURL()).thenReturn(url); + return session; + } + + private static EbicsSession proxiedSession(int proxyPort, String user, String pass) throws Exception { + EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS); + var conf = session.getConfiguration(); + Mockito.when(conf.getProperty(Mockito.anyString())).thenReturn(null); + Mockito.when(conf.getProperty("http.proxy.host")).thenReturn("127.0.0.1"); + Mockito.when(conf.getProperty("http.proxy.port")).thenReturn(String.valueOf(proxyPort)); + if (user != null) { + Mockito.when(conf.getProperty("http.proxy.user")).thenReturn(user); + Mockito.when(conf.getProperty("http.proxy.password")).thenReturn(pass); + } + // Target host is arbitrary — the stub proxy intercepts everything and never forwards. + Mockito.when(session.getUser().getPartner().getBank().getURL()) + .thenReturn(new URL("http://bank.example/ebics")); + return session; + } +} diff --git a/src/test/java/org/kopi/ebics/client/StubProxy.java b/src/test/java/org/kopi/ebics/client/StubProxy.java new file mode 100644 index 00000000..cbf2ac3c --- /dev/null +++ b/src/test/java/org/kopi/ebics/client/StubProxy.java @@ -0,0 +1,145 @@ +package org.kopi.ebics.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Minimal stub HTTP/1.1 proxy backed by a raw {@link ServerSocket}: records + * each incoming request (request line + headers) and serves canned responses + * from a queue. Used to exercise {@link HttpRequestSender}'s proxy + proxy-auth + * paths without depending on a real proxy implementation. + * + *

Does not forward to any upstream server — every request is "answered" + * locally, which is enough for asserting that the sender contacted the proxy + * and supplied the right {@code Proxy-Authorization} header. + */ +final class StubProxy implements AutoCloseable { + + private final ServerSocket socket; + private final Thread acceptor; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + private final BlockingQueue responses = new LinkedBlockingQueue<>(); + + StubProxy() throws IOException { + this.socket = new ServerSocket(0, 16, InetAddress.getLoopbackAddress()); + this.acceptor = new Thread(this::acceptLoop, "stub-proxy-accept"); + this.acceptor.setDaemon(true); + this.acceptor.start(); + } + + int port() { + return socket.getLocalPort(); + } + + void enqueueResponse(int status, Map headers, byte[] body) { + StringBuilder head = new StringBuilder(); + head.append("HTTP/1.1 ").append(status).append(" Status\r\n"); + for (Map.Entry e : headers.entrySet()) { + head.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); + } + head.append("Content-Length: ").append(body.length).append("\r\n"); + head.append("Connection: close\r\n"); + head.append("\r\n"); + byte[] headBytes = head.toString().getBytes(StandardCharsets.ISO_8859_1); + byte[] full = new byte[headBytes.length + body.length]; + System.arraycopy(headBytes, 0, full, 0, headBytes.length); + System.arraycopy(body, 0, full, headBytes.length, body.length); + responses.add(full); + } + + List recordedRequests() { + synchronized (requests) { + return new ArrayList<>(requests); + } + } + + @Override + public void close() throws IOException { + socket.close(); + } + + private void acceptLoop() { + while (!socket.isClosed()) { + try { + Socket client = socket.accept(); + Thread handler = new Thread(() -> handle(client), "stub-proxy-handle"); + handler.setDaemon(true); + handler.start(); + } catch (IOException e) { + // socket closed → loop exits + return; + } + } + } + + private void handle(Socket client) { + try (client; InputStream in = client.getInputStream(); OutputStream out = client.getOutputStream()) { + RecordedRequest req = parseRequest(in); + requests.add(req); + byte[] response = responses.poll(5, TimeUnit.SECONDS); + if (response == null) { + response = "HTTP/1.1 500 No canned response\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.ISO_8859_1); + } + out.write(response); + out.flush(); + } catch (Exception ignored) { + // Test failure will surface via assertions on the recorded requests. + } + } + + private static RecordedRequest parseRequest(InputStream in) throws IOException { + String requestLine = readLine(in); + Map headers = new LinkedHashMap<>(); + String line; + while (!(line = readLine(in)).isEmpty()) { + int colon = line.indexOf(':'); + if (colon < 0) { + continue; + } + String name = line.substring(0, colon).trim(); + String value = line.substring(colon + 1).trim(); + headers.put(name.toLowerCase(Locale.ROOT), value); + } + // Body is intentionally not drained: for our assertions only the + // request line + headers matter, and HttpClient is happy as long as + // it eventually sees a complete response on this connection. + return new RecordedRequest(requestLine, headers); + } + + private static String readLine(InputStream in) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + int prev = -1; + int ch; + while ((ch = in.read()) != -1) { + if (prev == '\r' && ch == '\n') { + byte[] b = buf.toByteArray(); + return new String(b, 0, b.length - 1, StandardCharsets.ISO_8859_1); + } + buf.write(ch); + prev = ch; + } + return new String(buf.toByteArray(), StandardCharsets.ISO_8859_1); + } + + record RecordedRequest(String requestLine, Map headers) { + String header(String name) { + return headers.get(name.toLowerCase(Locale.ROOT)); + } + } +}