/*
* 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 ""
+ (authzPrincipal != null ? "" : "")
+ ""
+ "";
}
private static String soap11(String body)
{
return ""
+ ""
+ "" + body + ""
+ "";
}
private static String soap12(String body)
{
return ""
+ ""
+ "" + body + ""
+ "";
}
/**
* 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 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(), 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 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 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 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 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 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 params = new LinkedHashMap<>();
params.put("ldap.authzidtypeisid", "true");
Map 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 headers, String body) throws Exception
{
return doPost(ldapPort, Collections. emptyMap(), headers, body);
}
private String doPost(int ldapPort, Map extraParams,
Map headers, String body) throws Exception
{
Map 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 list(byte... opTypes)
{
List 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 receivedOpTypes = new CopyOnWriteArrayList<>();
private final List 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 getReceivedOpTypes()
{
return new ArrayList<>(receivedOpTypes);
}
/** The authorization identity of every SASL bind, in the order received. */
List 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 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 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 stub(Class 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;
}
}