| New file |
| | |
| | | /* |
| | | * The contents of this file are subject to the terms of the Common Development and |
| | | * Distribution License (the License). You may not use this file except in compliance with the |
| | | * License. |
| | | * |
| | | * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the |
| | | * specific language governing permission and limitations under the License. |
| | | * |
| | | * When distributing Covered Software, include this CDDL Header Notice in each file and include |
| | | * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL |
| | | * Header, with the fields enclosed by brackets [] replaced by your own identifying |
| | | * information: "Portions copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | | import static java.util.Arrays.asList; |
| | | import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_ABANDON_REQUEST; |
| | | import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_BIND_REQUEST; |
| | | import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_UNBIND_REQUEST; |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertFalse; |
| | | import static org.testng.Assert.assertNull; |
| | | import static org.testng.Assert.assertTrue; |
| | | |
| | | import java.io.ByteArrayInputStream; |
| | | import java.io.ByteArrayOutputStream; |
| | | import java.io.Closeable; |
| | | import java.io.IOException; |
| | | import java.lang.reflect.InvocationHandler; |
| | | import java.lang.reflect.Method; |
| | | import java.lang.reflect.Proxy; |
| | | import java.net.InetAddress; |
| | | import java.net.ServerSocket; |
| | | import java.net.Socket; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.ArrayList; |
| | | import java.util.Base64; |
| | | import java.util.Collections; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.concurrent.CopyOnWriteArrayList; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | import jakarta.servlet.ReadListener; |
| | | import jakarta.servlet.ServletConfig; |
| | | import jakarta.servlet.ServletContext; |
| | | import jakarta.servlet.ServletInputStream; |
| | | import jakarta.servlet.ServletOutputStream; |
| | | import jakarta.servlet.WriteListener; |
| | | import jakarta.servlet.http.HttpServletRequest; |
| | | import jakarta.servlet.http.HttpServletResponse; |
| | | |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.testng.ForgeRockTestCase; |
| | | import org.opends.server.protocols.ldap.BindRequestProtocolOp; |
| | | import org.opends.server.protocols.ldap.BindResponseProtocolOp; |
| | | import org.opends.server.protocols.ldap.LDAPMessage; |
| | | import org.opends.server.protocols.ldap.LDAPResultCode; |
| | | import org.opends.server.tools.LDAPReader; |
| | | import org.opends.server.tools.LDAPWriter; |
| | | import org.testng.annotations.Test; |
| | | |
| | | /** |
| | | * Tests the error handling of {@link DSMLServlet#doPost}: an abandon request |
| | | * used to trigger a {@code NullPointerException} which leaked the LDAP |
| | | * connection, a request without a usable Content-Type header used to trigger a |
| | | * {@code NullPointerException} as well, and the second batch request of a SOAP |
| | | * body used to be silently skipped. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | @Test(groups = { "precommit", "dsml" }) |
| | | public class DSMLServletTestCase extends ForgeRockTestCase |
| | | { |
| | | /** SOAP 1.1 content type. */ |
| | | private static final String SOAP_1_1_CONTENT_TYPE = "text/xml"; |
| | | /** SOAP 1.2 content type. */ |
| | | private static final String SOAP_1_2_CONTENT_TYPE = "application/soap+xml"; |
| | | /** SOAP 1.2 envelope namespace, as it appears in the reply. */ |
| | | private static final String SOAP_1_2_NAMESPACE = "http://www.w3.org/2003/05/soap-envelope"; |
| | | |
| | | private static final String ABANDON_BATCH = |
| | | soap11(abandonBatch("1", null)); |
| | | |
| | | /** Two batch requests in a single SOAP body, each carrying an abandon request. */ |
| | | private static final String TWO_ABANDON_BATCHES = |
| | | soap11(abandonBatch("1", null) + abandonBatch("2", null)); |
| | | |
| | | /** Same, with an authRequest which turns into a SASL authzid on each bind. */ |
| | | private static final String TWO_AUTHZ_BATCHES = |
| | | soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", "dn:cn=second")); |
| | | |
| | | /** Same, but only the first batch request asks for an authorization identity. */ |
| | | private static final String MIXED_AUTHZ_BATCHES = |
| | | soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", null)); |
| | | |
| | | private static String abandonBatch(String requestID, String authzPrincipal) |
| | | { |
| | | return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">" |
| | | + (authzPrincipal != null ? "<authRequest principal=\"" + authzPrincipal + "\"/>" : "") |
| | | + "<abandonRequest abandonID=\"1\"/>" |
| | | + "</batchRequest>"; |
| | | } |
| | | |
| | | private static String soap11(String body) |
| | | { |
| | | return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" |
| | | + "<se:Envelope xmlns:se=\"http://schemas.xmlsoap.org/soap/envelope/\">" |
| | | + "<se:Body>" + body + "</se:Body>" |
| | | + "</se:Envelope>"; |
| | | } |
| | | |
| | | private static String soap12(String body) |
| | | { |
| | | return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" |
| | | + "<se:Envelope xmlns:se=\"" + SOAP_1_2_NAMESPACE + "\">" |
| | | + "<se:Body>" + body + "</se:Body>" |
| | | + "</se:Envelope>"; |
| | | } |
| | | |
| | | /** |
| | | * An abandon request produces no response element: the servlet must neither |
| | | * fail nor leave the connection to the directory server open. |
| | | */ |
| | | @Test |
| | | public void testAbandonRequestIsProcessedAndConnectionIsClosed() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); |
| | | |
| | | String response = doPost(server.getPort(), headers, ABANDON_BATCH); |
| | | |
| | | assertTrue(response.contains("batchResponse"), response); |
| | | assertFalse(response.contains("errorResponse"), response); |
| | | // no response element is defined for an abandon request |
| | | assertFalse(response.contains("abandonResponse"), response); |
| | | |
| | | server.awaitDisconnect(); |
| | | assertEquals(server.getReceivedOpTypes(), |
| | | list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), |
| | | "the abandon request was not forwarded, or the connection was leaked"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A request without any Content-Type header must be rejected as malformed, |
| | | * keeping the requestID so that the client can correlate the reply. |
| | | */ |
| | | @Test |
| | | public void testMissingContentTypeIsRejectedAsMalformedRequest() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | String response = doPost(server.getPort(), new LinkedHashMap<String, String>(), ABANDON_BATCH); |
| | | |
| | | assertTrue(response.contains("malformedRequest"), response); |
| | | assertTrue(response.contains("requestID=\"1\""), response); |
| | | assertTrue(server.getReceivedOpTypes().isEmpty(), |
| | | "no connection to the directory server should have been opened"); |
| | | } |
| | | } |
| | | |
| | | /** A Content-Type header matching neither SOAP 1.1 nor SOAP 1.2 is malformed too. */ |
| | | @Test |
| | | public void testUnsupportedContentTypeIsRejectedAsMalformedRequest() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | headers.put("Content-Type", "application/json"); |
| | | |
| | | String response = doPost(server.getPort(), headers, ABANDON_BATCH); |
| | | |
| | | assertTrue(response.contains("malformedRequest"), response); |
| | | assertTrue(server.getReceivedOpTypes().isEmpty(), |
| | | "no connection to the directory server should have been opened"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * An error detected before the request is parsed must still reach the client |
| | | * when the Content-Type header is missing. |
| | | */ |
| | | @Test |
| | | public void testMissingContentTypeStillReportsCredentialsError() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | // credentials without the ':' separator: the password cannot be retrieved |
| | | headers.put("Authorization", "Basic " + Base64.getEncoder() |
| | | .encodeToString("cn=directory manager".getBytes(StandardCharsets.UTF_8))); |
| | | |
| | | String response = doPost(server.getPort(), headers, ABANDON_BATCH); |
| | | |
| | | assertTrue(response.contains("authenticationFailed"), response); |
| | | assertTrue(server.getReceivedOpTypes().isEmpty(), |
| | | "no connection to the directory server should have been opened"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A malformed Authorization header must not stop the header scan: the |
| | | * Content-Type still decides which SOAP version the error is reported with. |
| | | */ |
| | | @Test |
| | | public void testMalformedAuthorizationKeepsTheRequestSoapVersion() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | // credentials which are not valid Base64, read before the Content-Type |
| | | headers.put("Authorization", "Basic !!!"); |
| | | headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE); |
| | | |
| | | String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null))); |
| | | |
| | | assertTrue(response.contains("authenticationFailed"), response); |
| | | assertTrue(response.contains(SOAP_1_2_NAMESPACE), response); |
| | | } |
| | | } |
| | | |
| | | /** The SOAP 1.2 request path must work as the SOAP 1.1 one does. */ |
| | | @Test |
| | | public void testSoap12RequestIsProcessed() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE); |
| | | |
| | | String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null))); |
| | | |
| | | assertTrue(response.contains("batchResponse"), response); |
| | | assertFalse(response.contains("errorResponse"), response); |
| | | assertTrue(response.contains(SOAP_1_2_NAMESPACE), response); |
| | | |
| | | server.awaitDisconnect(); |
| | | assertEquals(server.getReceivedOpTypes(), |
| | | list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), |
| | | "the abandon request was not forwarded, or the connection was leaked"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Every batch request of a SOAP body gets its own connection: the second one |
| | | * used to be silently skipped because the first connection was left assigned. |
| | | */ |
| | | @Test |
| | | public void testEachBatchRequestGetsItsOwnConnection() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); |
| | | |
| | | String response = doPost(server.getPort(), headers, TWO_ABANDON_BATCHES); |
| | | |
| | | assertFalse(response.contains("errorResponse"), response); |
| | | |
| | | server.awaitDisconnect(2); |
| | | assertEquals(server.getReceivedOpTypes(), |
| | | list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST, |
| | | OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), |
| | | "the second batch request was not processed on its own connection"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The connection options are shared by all the batch requests of a SOAP body, |
| | | * and the SASL authzid they carry is single valued: the authzid of a batch |
| | | * request must not survive into the bind of the next one. |
| | | */ |
| | | @Test |
| | | public void testAuthzIdIsNotAccumulatedAcrossBatchRequests() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | String response = doAuthzPost(server, TWO_AUTHZ_BATCHES); |
| | | |
| | | assertFalse(response.contains("errorResponse"), response); |
| | | |
| | | server.awaitDisconnect(2); |
| | | assertEquals(server.getReceivedOpTypes(), |
| | | list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST, |
| | | OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), |
| | | "the bind of the second batch request did not happen"); |
| | | assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", "dn:cn=second"), |
| | | "each batch request must bind under the authzid of its own authRequest"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A batch request which carries no authRequest must not inherit the |
| | | * authorization identity of the previous one: the shared connection options |
| | | * have to be cleared whether or not this batch request sets an authzid. |
| | | */ |
| | | @Test |
| | | public void testAuthzIdDoesNotSurviveIntoBatchRequestWithoutAuthRequest() throws Exception |
| | | { |
| | | try (FakeLdapServer server = new FakeLdapServer()) |
| | | { |
| | | String response = doAuthzPost(server, MIXED_AUTHZ_BATCHES); |
| | | |
| | | assertFalse(response.contains("errorResponse"), response); |
| | | |
| | | server.awaitDisconnect(2); |
| | | assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", ""), |
| | | "the second batch request ran under the authorization identity of the first one"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Posts the given SOAP body with HTTP credentials turned into a SASL PLAIN |
| | | * authid, so that the authRequest of a batch request becomes an authzid. |
| | | */ |
| | | private String doAuthzPost(FakeLdapServer server, String body) throws Exception |
| | | { |
| | | Map<String, String> params = new LinkedHashMap<>(); |
| | | params.put("ldap.authzidtypeisid", "true"); |
| | | |
| | | Map<String, String> headers = new LinkedHashMap<>(); |
| | | headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); |
| | | headers.put("Authorization", "Basic " + Base64.getEncoder() |
| | | .encodeToString("user:password".getBytes(StandardCharsets.UTF_8))); |
| | | |
| | | return doPost(server.getPort(), params, headers, body); |
| | | } |
| | | |
| | | /** Runs {@code doPost} against a servlet configured to use the given LDAP port. */ |
| | | private String doPost(int ldapPort, Map<String, String> headers, String body) throws Exception |
| | | { |
| | | return doPost(ldapPort, Collections.<String, String> emptyMap(), headers, body); |
| | | } |
| | | |
| | | private String doPost(int ldapPort, Map<String, String> extraParams, |
| | | Map<String, String> headers, String body) throws Exception |
| | | { |
| | | Map<String, String> params = new LinkedHashMap<>(); |
| | | params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress()); |
| | | params.put("ldap.port", String.valueOf(ldapPort)); |
| | | params.putAll(extraParams); |
| | | |
| | | DSMLServlet servlet = new DSMLServlet(); |
| | | servlet.init(servletConfig(params)); |
| | | |
| | | ByteArrayOutputStream out = new ByteArrayOutputStream(); |
| | | servlet.doPost(httpRequest(headers, body.getBytes(StandardCharsets.UTF_8)), httpResponse(out)); |
| | | return new String(out.toByteArray(), StandardCharsets.UTF_8); |
| | | } |
| | | |
| | | private static List<Byte> list(byte... opTypes) |
| | | { |
| | | List<Byte> result = new ArrayList<>(opTypes.length); |
| | | for (byte opType : opTypes) |
| | | { |
| | | result.add(opType); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * A minimal LDAP endpoint which answers the bind request with a success |
| | | * result and records the type of every message it receives, as well as the |
| | | * authorization identity of every SASL bind. Connections are served one after |
| | | * the other, so that a SOAP body holding several batch requests can be |
| | | * exercised. |
| | | */ |
| | | private static final class FakeLdapServer implements Closeable |
| | | { |
| | | private final ServerSocket serverSocket; |
| | | private final List<Byte> receivedOpTypes = new CopyOnWriteArrayList<>(); |
| | | private final List<String> receivedAuthzIds = new CopyOnWriteArrayList<>(); |
| | | private final Object lock = new Object(); |
| | | private int closedConnections; |
| | | private volatile Exception failure; |
| | | private volatile boolean stopped; |
| | | |
| | | FakeLdapServer() throws IOException |
| | | { |
| | | serverSocket = new ServerSocket(0, 16, InetAddress.getLoopbackAddress()); |
| | | Thread thread = new Thread(this::serve, "fake-ldap-server"); |
| | | thread.setDaemon(true); |
| | | thread.start(); |
| | | } |
| | | |
| | | int getPort() |
| | | { |
| | | return serverSocket.getLocalPort(); |
| | | } |
| | | |
| | | List<Byte> getReceivedOpTypes() |
| | | { |
| | | return new ArrayList<>(receivedOpTypes); |
| | | } |
| | | |
| | | /** The authorization identity of every SASL bind, in the order received. */ |
| | | List<String> getReceivedAuthzIds() |
| | | { |
| | | return new ArrayList<>(receivedAuthzIds); |
| | | } |
| | | |
| | | void awaitDisconnect() throws InterruptedException |
| | | { |
| | | awaitDisconnect(1); |
| | | } |
| | | |
| | | /** Waits for the given number of connections to have been served. */ |
| | | void awaitDisconnect(int expectedConnections) throws InterruptedException |
| | | { |
| | | final long deadline = System.currentTimeMillis() |
| | | + TimeUnit.SECONDS.toMillis(30); |
| | | synchronized (lock) |
| | | { |
| | | while (closedConnections < expectedConnections) |
| | | { |
| | | final long remaining = deadline - System.currentTimeMillis(); |
| | | assertTrue(remaining > 0, "the client did not disconnect: " |
| | | + closedConnections + " connection(s) served out of " + expectedConnections); |
| | | lock.wait(remaining); |
| | | } |
| | | } |
| | | assertNull(failure, "the fake LDAP server failed: " + failure); |
| | | } |
| | | |
| | | private void serve() |
| | | { |
| | | while (!stopped) |
| | | { |
| | | final Socket socket; |
| | | try |
| | | { |
| | | socket = serverSocket.accept(); |
| | | } |
| | | catch (IOException e) |
| | | { |
| | | if (!stopped) |
| | | { |
| | | recordFailure(e); |
| | | } |
| | | return; |
| | | } |
| | | try (Socket connection = socket) |
| | | { |
| | | serveConnection(connection); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | recordFailure(e); |
| | | } |
| | | finally |
| | | { |
| | | synchronized (lock) |
| | | { |
| | | closedConnections++; |
| | | lock.notifyAll(); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private void serveConnection(Socket socket) throws Exception |
| | | { |
| | | LDAPReader reader = new LDAPReader(socket); |
| | | LDAPWriter writer = new LDAPWriter(socket); |
| | | LDAPMessage message; |
| | | while ((message = reader.readMessage()) != null) |
| | | { |
| | | receivedOpTypes.add(message.getProtocolOpType()); |
| | | if (message.getProtocolOpType() == OP_TYPE_BIND_REQUEST) |
| | | { |
| | | recordAuthzId(message.getBindRequestProtocolOp()); |
| | | writer.writeMessage(new LDAPMessage(message.getMessageID(), |
| | | new BindResponseProtocolOp(LDAPResultCode.SUCCESS))); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Records the authorization identity of a SASL bind. The credentials of |
| | | * SASL PLAIN are "authzid NUL authid NUL password", with an empty authzid |
| | | * when the client asked for none. |
| | | */ |
| | | private void recordAuthzId(BindRequestProtocolOp bindRequest) |
| | | { |
| | | ByteString credentials = bindRequest.getSASLCredentials(); |
| | | if (credentials == null) |
| | | { |
| | | return; |
| | | } |
| | | String plain = credentials.toString(); |
| | | int separator = plain.indexOf('\0'); |
| | | receivedAuthzIds.add(separator >= 0 ? plain.substring(0, separator) : plain); |
| | | } |
| | | |
| | | private void recordFailure(Exception e) |
| | | { |
| | | if (!stopped && failure == null) |
| | | { |
| | | failure = e; |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void close() throws IOException |
| | | { |
| | | stopped = true; |
| | | serverSocket.close(); |
| | | } |
| | | } |
| | | |
| | | private static ServletConfig servletConfig(final Map<String, String> params) |
| | | { |
| | | final ServletContext context = stub(ServletContext.class, (proxy, method, args) -> { |
| | | switch (method.getName()) |
| | | { |
| | | case "getInitParameter": |
| | | return params.get(args[0]); |
| | | case "getInitParameterNames": |
| | | return Collections.enumeration(params.keySet()); |
| | | default: |
| | | return defaultValue(method); |
| | | } |
| | | }); |
| | | return stub(ServletConfig.class, (proxy, method, args) -> |
| | | "getServletContext".equals(method.getName()) ? context : defaultValue(method)); |
| | | } |
| | | |
| | | private static HttpServletRequest httpRequest(final Map<String, String> headers, final byte[] body) |
| | | { |
| | | final ByteArrayInputStream content = new ByteArrayInputStream(body); |
| | | final ServletInputStream in = new ServletInputStream() |
| | | { |
| | | @Override |
| | | public int read() |
| | | { |
| | | return content.read(); |
| | | } |
| | | |
| | | @Override |
| | | public boolean isFinished() |
| | | { |
| | | return content.available() == 0; |
| | | } |
| | | |
| | | @Override |
| | | public boolean isReady() |
| | | { |
| | | return true; |
| | | } |
| | | |
| | | @Override |
| | | public void setReadListener(ReadListener readListener) |
| | | { |
| | | // not used |
| | | } |
| | | }; |
| | | return stub(HttpServletRequest.class, (proxy, method, args) -> { |
| | | switch (method.getName()) |
| | | { |
| | | case "getInputStream": |
| | | return in; |
| | | case "getHeaderNames": |
| | | return Collections.enumeration(headers.keySet()); |
| | | case "getHeader": |
| | | return headers.get(args[0]); |
| | | default: |
| | | return defaultValue(method); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | private static HttpServletResponse httpResponse(final ByteArrayOutputStream out) |
| | | { |
| | | final ServletOutputStream os = new ServletOutputStream() |
| | | { |
| | | @Override |
| | | public void write(int b) |
| | | { |
| | | out.write(b); |
| | | } |
| | | |
| | | @Override |
| | | public boolean isReady() |
| | | { |
| | | return true; |
| | | } |
| | | |
| | | @Override |
| | | public void setWriteListener(WriteListener writeListener) |
| | | { |
| | | // not used |
| | | } |
| | | }; |
| | | return stub(HttpServletResponse.class, (proxy, method, args) -> |
| | | "getOutputStream".equals(method.getName()) ? os : defaultValue(method)); |
| | | } |
| | | |
| | | private static <T> T stub(Class<T> type, InvocationHandler handler) |
| | | { |
| | | return type.cast(Proxy.newProxyInstance( |
| | | DSMLServletTestCase.class.getClassLoader(), new Class<?>[] { type }, handler)); |
| | | } |
| | | |
| | | /** |
| | | * A proxy must return a value assignable to the return type of the invoked |
| | | * method: {@code null} is only acceptable for a reference or {@code void} |
| | | * return type, so every primitive has to be covered here. |
| | | */ |
| | | private static Object defaultValue(Method method) |
| | | { |
| | | Class<?> returnType = method.getReturnType(); |
| | | if (returnType == boolean.class) |
| | | { |
| | | return Boolean.FALSE; |
| | | } |
| | | else if (returnType == char.class) |
| | | { |
| | | return (char) 0; |
| | | } |
| | | else if (returnType == byte.class) |
| | | { |
| | | return (byte) 0; |
| | | } |
| | | else if (returnType == short.class) |
| | | { |
| | | return (short) 0; |
| | | } |
| | | else if (returnType == int.class) |
| | | { |
| | | return 0; |
| | | } |
| | | else if (returnType == long.class) |
| | | { |
| | | return 0L; |
| | | } |
| | | else if (returnType == float.class) |
| | | { |
| | | return 0f; |
| | | } |
| | | else if (returnType == double.class) |
| | | { |
| | | return 0d; |
| | | } |
| | | else if ("toString".equals(method.getName())) |
| | | { |
| | | return "stub"; |
| | | } |
| | | return null; |
| | | } |
| | | } |