DirectoryTester.java

/*
 * Copyright 2013-2025 Brian Thomas Matthews
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.buralotech.oss.ldapunit;

import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPConnectionOptions;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.SearchResultEntry;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

/**
 * Utility that maintains a connection to the LDAP directory server and provides assert and verify methods to
 * test the LDAP directory contents.
 *
 * @author <a href="mailto:bmatthews68@gmail.com">Brian Matthews</a>
 * @since 1.0.0
 */
public final class DirectoryTester implements AutoCloseable {

    /**
     * The default for the maximum connection attempts.
     *
     * @since 1.0.1
     */
    private static final int DEFAULT_RETRIES = 3;
    /**
     * The default for the connection timeout.
     *
     * @since 1.0.1
     */
    private static final int DEFAULT_TIMEOUT = 5000;
    /**
     * The connection to the LDAP directory server.
     */
    private final LDAPConnection connection;

    /**
     * Initialise the LDAP directory tester using an existing LDAP connection.
     *
     * @param connection The LDAP connection.
     */
    public DirectoryTester(final LDAPConnection connection) {
        this.connection = connection;
    }

    /**
     * Initialise the LDAP directory tester using the default hostname of {@code localhost} and port number of
     * {@link DirectoryServerConfiguration#DEFAULT_PORT}.
     *
     * @since 1.0.1
     */
    public DirectoryTester() {
        this("localhost", DirectoryServerConfiguration.DEFAULT_PORT);
    }

    /**
     * Initialise the LDAP directory tester by connecting to the LDAP directory server using the {@code hostname} and
     * {@code port}.
     *
     * @param hostname The host name of the directory server.
     * @param port     The TCP port number of the directory server.
     * @throws DirectoryTesterException If there was a problem connecting to the LDAP directory server.
     */
    public DirectoryTester(final String hostname, final int port) {
        this(hostname, port, DEFAULT_RETRIES, DEFAULT_TIMEOUT);
    }

    /**
     * Initialise the LDAP directory tester by connecting to the LDAP directory server using the {@code hostname} and
     * {@code port}.The connection attempt is retried a maximum of {@code retries} times with a timeout of
     * {@code timeout} for each attempt.
     *
     * @param hostname The host name of the directory server.
     * @param port     The TCP port number of the directory server.
     * @param retries  The maximum number of connection attempts.
     * @param timeout  The timeout for each connection attempt.
     * @throws DirectoryTesterException If there was a problem connecting to the LDAP directory server.
     * @since 1.0.1
     */
    public DirectoryTester(final String hostname,
                           final int port,
                           final int retries,
                           final int timeout) {
        this(new LDAPConnection());
        final LDAPConnectionOptions options = new LDAPConnectionOptions();
        options.setConnectTimeoutMillis(timeout);
        int attempt = 0;
        while (true) {
            final long startTime = System.currentTimeMillis();
            try {
                connection.connect(hostname, port, timeout);
                break;
            } catch (final LDAPException e) {
                if (attempt++ >= retries) {
                    throw new DirectoryTesterException("Could not connect to LDAP directory server", e);
                } else {
                    long timeDifference = System.currentTimeMillis() - startTime;
                    if (timeDifference < timeout) {
                        try {
                            Thread.sleep(timeout - timeDifference);
                        } catch (final InterruptedException i) {
                            throw new DirectoryTesterException("Could not connect to LDAP directory server", e);
                        }
                    }
                }
            }
        }
    }

    /**
     * Initialise the LDAP directory tester by connecting to the LDAP directory server using the {@code hostname} and
     * {@code port} and bind to it using the {@code bindDN} and {@code password}.
     *
     * @param hostname The host name of the directory server.
     * @param port     The TCP port number of the directory server.
     * @param bindDN   The DN used to bind to the LDAP directory server.
     * @param password The password used to bind to the LDAP directory server.
     * @throws DirectoryTesterException If there was a problem connecting to the LDAP directory server.
     */
    public DirectoryTester(final String hostname,
                           final int port,
                           final String bindDN,
                           final String password) {
        this(hostname, port, bindDN, password, DEFAULT_RETRIES, DEFAULT_TIMEOUT);
    }

    /**
     * Initialise the LDAP directory tester by connecting to the LDAP directory server using the {@code hostname} and
     * {@code port} and bind to it using the {@code bindDN} and {@code password}. The connection attempt is retried
     * a maximum of {@code retries} times with a timeout of {@code timeout} for each attempt.
     *
     * @param hostname The host name of the directory server.
     * @param port     The TCP port number of the directory server.
     * @param bindDN   The DN used to bind to the LDAP directory server.
     * @param password The password used to bind to the LDAP directory server.
     * @param retries  The maximum number of connection attempts.
     * @param timeout  The timeout for each connection attempt.
     * @throws DirectoryTesterException If there was a problem connecting to the LDAP directory server.
     * @since 1.0.1
     */
    public DirectoryTester(final String hostname,
                           final int port,
                           final String bindDN,
                           final String password,
                           final int retries,
                           final int timeout) {
        this(hostname, port, retries, timeout);
        try {
            connection.bind(bindDN, password);
        } catch (final LDAPException e) {
            throw new DirectoryTesterException("Could not bind to LDAP directory server", e);
        }
    }

    /**
     * Verify that an entry identified by {@code dn} exists.
     *
     * @param dn The distinguished name.
     * @return {@code true} if an entry identified by {@code dn} exists. Otherwise, {@code false} is returned.
     */
    public boolean verifyDNExists(final String dn) {
        try {
            final SearchResultEntry entry = connection.getEntry(dn);
            return entry != null;
        } catch (final LDAPException e) {
            throw new DirectoryTesterException("Error communicating with LDAP directory server", e);
        }
    }

    /**
     * Verify that the entry identified by {@code dn} is of type {@code objectclass}.
     *
     * @param dn          The distinguished name.
     * @param objectclass The type name.
     * @return {@code true} if an entry identified by {@code dn} exists and has attribute named {@code objectclass}.
     * Otherwise, {@code false} is returned.
     */
    public boolean verifyDNIsA(final String dn,
                               final String objectclass) {
        try {
            final SearchResultEntry entry = connection.getEntry(dn, "objectclass");
            return entry != null
                    && entry.hasAttribute("objectclass")
                    && arrayContains(entry.getAttributeValues("objectclass"), objectclass);
        } catch (final LDAPException e) {
            throw new DirectoryTesterException("Error communicating with LDAP directory server", e);
        }
    }

    /**
     * Verify that the entry identified by {@code dn} has an attribute named {@code attributeName}.
     *
     * @param dn            The distinguished name.
     * @param attributeName The attribute name.
     * @return {@code true} if an entry identified by {@code dn} exists and has an attributed named
     * {@code attributeName}. Otherwise, {@code false} is returned.
     */
    public boolean verifyDNHasAttribute(final String dn,
                                        final String attributeName) {
        try {
            final SearchResultEntry entry = connection.getEntry(dn, attributeName);
            return entry != null && entry.hasAttribute(attributeName);
        } catch (final LDAPException e) {
            throw new DirectoryTesterException("Error communicating with LDAP directory server", e);
        }
    }

    /**
     * Verify that the entry identified by {@code dn} has an attribute named {@code attributeName} with
     * the attribute value(s) {@code attributeName}.
     *
     * @param dn             The distinguished name.
     * @param attributeName  The attribute name.
     * @param attributeValue The attribute value(s).
     * @return {@code true} if an antry identified by {@code dn} exists with an an attribute named {@code attributeName}
     * that has value(s) {@code attributeValue}. Otherwise, {@code false} is returned.
     */
    public boolean verifyDNHasAttributeValue(final String dn,
                                             final String attributeName,
                                             final String... attributeValue) {
        try {
            final SearchResultEntry entry = connection.getEntry(dn, attributeName);
            if (entry != null && entry.hasAttribute(attributeName)) {
                final Set<String> expectedValues = new HashSet<>(Arrays.asList(attributeValue));
                final Set<String> actualValues = new HashSet<>(Arrays.asList(entry.getAttributeValues(attributeName)));
                if (actualValues.containsAll(expectedValues)) {
                    actualValues.removeAll(expectedValues);
                    if (actualValues.size() == 0) {
                        return true;
                    }
                }
            }
        } catch (final LDAPException e) {
            throw new DirectoryTesterException("Error communicating with LDAP directory server", e);
        }
        return false;
    }

    /**
     * Assert that an entry identified by {@code dn} exists.
     *
     * @param dn The distinguished name.
     */
    public void assertDNExists(final String dn) {
        if (!verifyDNExists(dn)) {
            final StringBuilder message = new StringBuilder("Entry for DN: ");
            message.append(dn);
            message.append(" does not exist");
            throw new AssertionError(message);
        }
    }

    /**
     * Assert that the entry identified by {@code dn} is of type {@code objectclass}.
     *
     * @param dn          The distinguished name.
     * @param objectclass The type name.
     */
    public void assertDNIsA(final String dn,
                            final String objectclass) {
        if (!verifyDNIsA(dn, objectclass)) {
            final StringBuilder message = new StringBuilder("Entry for DN: ");
            message.append(dn);
            message.append(" is not of type: ");
            message.append(objectclass);
            throw new AssertionError(message);
        }
    }

    /**
     * Assert that the entry identified by {@code dn} has an attribute named {@code attributeName}.
     *
     * @param dn            The distinguished name.
     * @param attributeName The attribute name.
     */
    public void assertDNHasAttribute(final String dn,
                                     final String attributeName) {
        if (!verifyDNHasAttribute(dn, attributeName)) {
            final StringBuilder message = new StringBuilder("Entry for DN: ");
            message.append(dn);
            message.append(" does not have attribute: ");
            message.append(attributeName);
            throw new AssertionError(message);
        }
    }

    /**
     * Assert that the entry identified by {@code dn} has an attribute named {@code attributeName} with
     * the attribute value(s) {@code attributeName}.
     *
     * @param dn             The distinguished name.
     * @param attributeName  The attribute name.
     * @param attributeValue The attribute value(s).
     */
    public void assertDNHasAttributeValue(final String dn,
                                          final String attributeName,
                                          final String... attributeValue) {
        if (!verifyDNHasAttributeValue(dn, attributeName, attributeValue)) {
            final StringBuilder message = new StringBuilder("Attribute named: ");
            message.append(attributeName);
            message.append(" for entry for DN: ");
            message.append(dn);
            message.append(" is does not match: ");
            message.append(arrayToString(attributeValue));
            throw new AssertionError(message);
        }
    }

    /**
     * Disconnect from the LDAP directory server.
     */
    public void disconnect() {
        connection.close();
    }

    /**
     * Close The directory tester by disconnecting from the LDAP directory server.
     *
     * @since 2.0.0
     */
    public void close() {
        disconnect();
    }

    /**
     * Check if {@code item} is present in {@code items} ignoring case.
     *
     * @param items An array of items.
     * @param item  The item being searched for.
     * @return {@code true} if {@code item} is present in {@code items}. Otherwise, {@code false}.
     */
    private boolean arrayContains(final String[] items,
                                  final String item) {
        for (final String value : items) {
            if (item.equalsIgnoreCase(value)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Convert an array of strings to string.
     *
     * @param items The array of strings.
     * @return The formatted string.
     */
    private String arrayToString(final String[] items) {
        final StringBuilder builder = new StringBuilder("[");
        if (items.length > 0) {
            builder.append(items[0]);
            for (int i = 1; i < items.length; i++) {
                builder.append(',');
                builder.append(items[i]);
            }
        }
        builder.append(']');
        return builder.toString();
    }
}