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));
+ }
+ }
+}