opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java
@@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2024 3A Systems LLC. * Copyright 2024-2026 3A Systems LLC. */ package org.openidentityplatform.opendj.embedded; @@ -23,9 +23,9 @@ public class Config { private final String CONFIG_PREFIX = Config.class.getPackage().getName(); private int port = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".port", "1389")); private int port = intProperty(CONFIG_PREFIX + ".port", "1389"); private int adminPort = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".admin_port", "4444")); private int adminPort = intProperty(CONFIG_PREFIX + ".admin_port", "4444"); private String adminPassword = System.getProperty(CONFIG_PREFIX + ".password", "passw0rd"); @@ -33,7 +33,7 @@ private String backendType = System.getProperty(CONFIG_PREFIX + ".backend", "je"); private int jmxPort = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".jmx_port", "1689")); private int jmxPort = intProperty(CONFIG_PREFIX + ".jmx_port", "1689"); private String ldifSchema = System.getProperty(CONFIG_PREFIX + ".ldif.schema"); @@ -41,6 +41,64 @@ private Set<String> skipSet = new HashSet<>(Arrays.asList(System.getProperty(CONFIG_PREFIX + ".skip", ",ou=sample-skip-group,").toLowerCase().split(";"))); private long deleteTimeout = timeoutProperty(CONFIG_PREFIX + ".delete_timeout", "10000"); /** * Returns the value of a system property holding a number. * <p> * These fields are initialized when the instance is created, so an unhandled * {@link NumberFormatException} would surface from the constructor of * {@link EmbeddedOpenDJ} saying only which string was rejected, without naming the * property that carried it. * * @param name * the name of the system property * @param defaultValue * the value used when the property is not set * @return the value of the property, or {@code defaultValue} when it is not set * @throws IllegalArgumentException * If the value of the property is not a number. */ private static int intProperty(String name, String defaultValue) { final String value = System.getProperty(name, defaultValue); try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw invalidProperty(name, value, e); } } /** * Returns the value of a system property holding a duration in milliseconds. * * @param name * the name of the system property * @param defaultValue * the value used when the property is not set * @return the value of the property, or {@code defaultValue} when it is not set * @throws IllegalArgumentException * If the value of the property is not a number, or is negative. */ private static long timeoutProperty(String name, String defaultValue) { final String value = System.getProperty(name, defaultValue); final long timeout; try { timeout = Long.parseLong(value); } catch (NumberFormatException e) { throw invalidProperty(name, value, e); } if (timeout < 0) { throw new IllegalArgumentException("Invalid value \"" + value + "\" for the system property " + name + ": a timeout in milliseconds cannot be negative"); } return timeout; } private static IllegalArgumentException invalidProperty(String name, String value, NumberFormatException cause) { return new IllegalArgumentException("Invalid value \"" + value + "\" for the system property " + name + ": expected a number", cause); } public int getPort() { return port; } @@ -115,6 +173,37 @@ this.skipSet = skipSet; } /** * Returns how long {@link EmbeddedOpenDJ#close()} retries the deletion of the temporary * directory of the instance, in milliseconds. * <p> * The deletion has to be retried because a file cannot be deleted on Windows while a * handle to it is still open, and the server threads release their handles shortly after * the server has been stopped. Whatever is still locked when this expires is scheduled for * deletion on JVM exit. Set it to {@code 0} to delete once and never wait. * * @return the deletion timeout in milliseconds */ public long getDeleteTimeout() { return deleteTimeout; } /** * Sets how long {@link EmbeddedOpenDJ#close()} retries the deletion of the temporary * directory of the instance. * * @param deleteTimeout * the deletion timeout in milliseconds, {@code 0} to delete once and never wait * @throws IllegalArgumentException * If the timeout is negative. */ public void setDeleteTimeout(long deleteTimeout) { if (deleteTimeout < 0) { throw new IllegalArgumentException("The delete timeout cannot be negative, but was " + deleteTimeout); } this.deleteTimeout = deleteTimeout; } @Override public String toString() { return "Config {" + @@ -127,6 +216,7 @@ ", ldifSchema='" + ldifSchema + '\'' + ", file='" + file + '\'' + ", skipSet=" + skipSet + ", deleteTimeout=" + deleteTimeout + '}'; } } opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java
@@ -51,10 +51,30 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; public class EmbeddedOpenDJ implements Runnable, Closeable { private static final String JAR_SCHEMA_DIRECTORY = "opendj/config/schema/"; private static final String ARCHIVE_NAME = "opendj.zip"; /** * How long the deletion of the instance directory is retried when there is no point in * waiting for the full {@link Config#getDeleteTimeout() configured timeout}: from the * shutdown hook, where a longer wait only makes Control-C look hung, and from the * constructor, where an initialization failure has to be reported promptly. What is still * locked when this expires is deleted on JVM exit anyway. */ private static final long SHORT_DELETE_TIMEOUT_MS = 1_000L; private static final long INITIAL_DELETE_RETRY_DELAY_MS = 50L; private static final long MAX_DELETE_RETRY_DELAY_MS = 500L; /** Upper bound on the number of leftover paths reported when the deletion fails. */ private static final int MAX_REPORTED_REMAINING_PATHS = 20; final static Logger logger = LoggerFactory.getLogger(EmbeddedOpenDJ.class.getName()); final EmbeddedDirectoryServer server; @@ -62,6 +82,10 @@ private final File instanceDirectory; private final File rootDirectory; private final Thread shutdownHook; /** Written under {@code this}, read without locking so that a running {@link #close()} blocks nothing. */ private volatile boolean closed; public EmbeddedOpenDJ() { this(new Config()); @@ -82,7 +106,10 @@ // deleted on close(). instanceDirectory = Files.createTempDirectory("opendj").toFile(); File rootDirectory = new File(instanceDirectory, "opendj"); rootDirectory.mkdir(); if (!rootDirectory.mkdir()) { // the parent has just been created, so this only fails on a real filesystem error throw new IOException("Cannot create the server root directory " + rootDirectory); } logger.info("OpenDJ server root: {}", rootDirectory); File configDirectory = new File(rootDirectory, "config"); @@ -100,8 +127,15 @@ System.out, System.err); copyFilesFromJar(Collections.singletonList("opendj.zip"),"embedded-opendj/",rootDirectory); server.extractArchiveForSetup(new File(rootDirectory,"opendj.zip")); copyFilesFromJar(Collections.singletonList(ARCHIVE_NAME),"embedded-opendj/",rootDirectory); final File archive = new File(rootDirectory, ARCHIVE_NAME); server.extractArchiveForSetup(archive); // The archive is only needed for the extraction above. Keeping it would leave a // full copy of the distribution in the temporary directory and one more file to // delete on close(). if (!archive.delete()) { logger.warn("Cannot delete {} after extracting it", archive); } server.setup( SetupParameters.setupParams() @@ -121,32 +155,35 @@ this.rootDirectory = rootDirectory; }catch (Exception e) { logger.error("Error initializing OpenDJ"); FileUtils.deleteQuietly(instanceDirectory); deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config)); throw new RuntimeException(e); } Runtime.getRuntime().addShutdownHook(new Thread(this::close)); shutdownHook = new Thread(this::close, "EmbeddedOpenDJ shutdown hook"); Runtime.getRuntime().addShutdownHook(shutdownHook); } /** * Returns the server root directory of this embedded instance. * * @return the server root directory * @throws IllegalStateException * If this instance has been closed, and the directory therefore deleted. */ public File getServerRootDirectory() { checkNotClosed(); return rootDirectory; } @Override public void run() { checkNotClosed(); try { final DN baseDN = DN.valueOf(config.getBaseDN()); try { ManagementContext config = server.getConfiguration(); BackendCfgClient userRoot = config.getRootConfiguration().getBackend("userRoot"); try (ManagementContext managementContext = server.getConfiguration()) { BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot"); userRoot.setBaseDN((Collections.singletonList(baseDN))); userRoot.setEnabled(true); userRoot.commit(); config.close(); } catch (Exception e) { throw new RuntimeException(e); } @@ -163,18 +200,167 @@ } } /** * Stops this instance, if it is still running, and deletes its temporary directory. * <p> * This method is idempotent: it is also registered as a JVM shutdown hook. * <p> * When the server cannot be stopped, this instance stays open and keeps its shutdown hook * registered, so that a later call - or the hook at JVM exit - retries the stop. */ @Override public void close() { public synchronized void close() { if (closed) { return; } final boolean fromShutdownHook = Thread.currentThread() == shutdownHook; if (server.isRunning()) { try { logger.info("Shutting down OpenDJ ..."); server.stop(this.getClass().getName(), LocalizableMessage.raw("Stopped after receiving Control-C")); }catch (Throwable e) { logger.error("Error stopping OpenDJ", e); if (!fromShutdownHook) { // The server is still running: deleting its directory now would destroy a // live installation. Leave this instance open, with its shutdown hook still // registered, so that the stop can be retried. return; } // The JVM is going down and there will be no later attempt, so the temporary // directory is removed even though the server has not stopped cleanly. } } // close() is also registered as a shutdown hook, so deletion must stay idempotent FileUtils.deleteQuietly(instanceDirectory); closed = true; unregisterShutdownHook(); deleteInstanceDirectory(instanceDirectory, fromShutdownHook ? shortDeleteTimeout(config) : config.getDeleteTimeout()); } private void checkNotClosed() { if (closed) { throw new IllegalStateException("this embedded OpenDJ instance is closed"); } } private static long shortDeleteTimeout(Config config) { return Math.min(config.getDeleteTimeout(), SHORT_DELETE_TIMEOUT_MS); } private void unregisterShutdownHook() { try { Runtime.getRuntime().removeShutdownHook(shutdownHook); } catch (IllegalStateException e) { // close() was reached from the shutdown hook itself: nothing to unregister } } /** * Deletes the temporary directory of an instance, retrying for a bounded period and * falling back to a deletion on JVM exit. * <p> * Deleting once is not enough. On Windows a file cannot be deleted while a handle to it * is still open, and {@code server.stop()} does not wait for the server threads to * terminate: an embedded server runs them as daemon threads, which the shutdown monitor * of the directory server ignores. Some handles are therefore released shortly after * {@code stop()} has returned, and a single best-effort deletion loses that race and * silently leaks the whole directory, backend data included. Retrying is a way to wait * for those handles to be released - a single attempt already removes everything that is * not in use at that moment. * * @param directory * the directory to delete, may be {@code null} when the instance failed to * initialize before creating it * @param timeoutMs * how long the deletion is retried, in milliseconds */ static void deleteInstanceDirectory(File directory, long timeoutMs) { deleteInstanceDirectory(directory, timeoutMs, FileUtils::deleteQuietly); } /** * Implements {@link #deleteInstanceDirectory(File, long)} with an injectable deletion, so * that tests can drive the retries without depending on a genuinely undeletable file. * * @param deleteAttempt * performs one deletion attempt and reports whether the directory is gone */ static void deleteInstanceDirectory(File directory, long timeoutMs, Predicate<File> deleteAttempt) { if (directory == null || !directory.exists()) { return; } final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); long retryDelay = INITIAL_DELETE_RETRY_DELAY_MS; while (true) { // The second test covers a directory removed by someone else in the meantime: // deleteQuietly() reports a failure for a directory that is already gone. if (deleteAttempt.test(directory) || !directory.exists()) { return; } if (System.nanoTime() >= deadline) { logger.warn("Cannot delete {} within {} ms, some files are still in use. " + "They are now scheduled for deletion on JVM exit:{}", directory, timeoutMs, remainingPaths(directory)); break; } try { Thread.sleep(retryDelay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.warn("Interrupted while deleting {}. What is left is now scheduled " + "for deletion on JVM exit:{}", directory, remainingPaths(directory)); break; } retryDelay = Math.min(retryDelay * 2, MAX_DELETE_RETRY_DELAY_MS); } deleteTreeOnExit(directory); } /** * Describes what is left in the given directory: one path per line, at most * {@link #MAX_REPORTED_REMAINING_PATHS} of them, followed by the number of paths left out. */ static String remainingPaths(File directory) { final List<String> reported = new ArrayList<>(); final int total = collectRemainingPaths(directory, reported); final StringBuilder description = new StringBuilder(); for (String path : reported) { description.append("\n ").append(path); } if (total > reported.size()) { description.append("\n ... and ").append(total - reported.size()).append(" more"); } return description.toString(); } /** * Adds at most {@link #MAX_REPORTED_REMAINING_PATHS} paths of the given tree to * {@code reported} and returns how many paths it holds in total. */ private static int collectRemainingPaths(File file, List<String> reported) { final File[] children = file.listFiles(); if (children == null || children.length == 0) { if (reported.size() < MAX_REPORTED_REMAINING_PATHS) { reported.add(file.getPath()); } return 1; } int total = 0; for (File child : children) { total += collectRemainingPaths(child, reported); } return total; } private static void deleteTreeOnExit(File file) { // File.deleteOnExit() deletes in reverse order of registration, so a directory has to // be registered before its content for the content to be removed first. file.deleteOnExit(); final File[] children = file.listFiles(); if (children != null) { for (File child : children) { deleteTreeOnExit(child); } } } private void copyFilesFromJar(List<String> jarFiles, String jarDirectory, File outputDirectory) throws IOException{ @@ -183,65 +369,80 @@ final String resourcePath = !jarFile.contains("/") ? "/"+jarDirectory + jarFile : jarFile; InputStream in = new File(jarFile).exists() try (InputStream in = new File(jarFile).exists() ? Files.newInputStream(new File(jarFile).toPath()) : MemoryBackend.class.getResourceAsStream(resourcePath); if (in == null) { throw new IOException("cannot find " + resourcePath); : MemoryBackend.class.getResourceAsStream(resourcePath)) { if (in == null) { throw new IOException("cannot find " + resourcePath); } FileUtils.copyInputStreamToFile(in, outputFile); } FileUtils.copyInputStreamToFile(in, outputFile); in.close(); } } /** * Imports the LDIF read from the given stream. * <p> * The stream is closed by this method, whether the import succeeds or not. * * @param inputStream * the LDIF to import * @throws IllegalStateException * If this instance has been closed. */ public void importData(InputStream inputStream) throws EmbeddedDirectoryServerException, IOException { checkNotClosed(); logger.info("start import ldif from stream"); EntryReader reader; try { BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); reader = new LDIFEntryReader(bufferedReader); } catch (Exception e) { logger.error("import ldif : {}", e, e); throw e; } org.forgerock.opendj.ldap.Entry entryBefore; final Connection connection = server.getInternalConnection(); long recordCount = 0; while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) { recordCount++; try { connection.add(entryBefore); logger.info("import ldif : {}",entryBefore.getName()); }catch (LdapException e) { logger.error("import ldif : {} {}",entryBefore.getName(),e.toString()); try (EntryReader reader = new LDIFEntryReader(new BufferedReader(new InputStreamReader(inputStream))); Connection connection = server.getInternalConnection()) { while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) { recordCount++; try { connection.add(entryBefore); logger.info("import ldif : {}",entryBefore.getName()); }catch (LdapException e) { logger.error("import ldif : {} {}",entryBefore.getName(),e.toString()); } } } if(recordCount == 0) { logger.error("no records were imported, check file contents and permissions"); throw new RuntimeException("no records were imported"); } reader.close(); connection.close(); } /** * Writes the entries below the given base DN to the given stream, as LDIF. * <p> * The stream is flushed and closed by this method, whether the export succeeds or not. * * @param baseDN * the base DN of the subtree to export * @param out * where the LDIF is written * @throws IllegalStateException * If this instance has been closed. */ public void getData(String baseDN, OutputStream out) throws IOException, EmbeddedDirectoryServerException { LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out); final Connection connection = server.getInternalConnection(); ConnectionEntryReader reader = connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)"); while(reader.hasNext()) { if (!reader.isReference()) { SearchResultEntry se = reader.readEntry(); if (!skipEntry(se)) { ldifWriter.writeEntry(se); logger.info("export {}", se.toString()); checkNotClosed(); // resources are closed in reverse order, so the writer is flushed and closed last try (LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out); Connection connection = server.getInternalConnection(); ConnectionEntryReader reader = connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)")) { while (reader.hasNext()) { if (!reader.isReference()) { SearchResultEntry se = reader.readEntry(); if (!skipEntry(se)) { ldifWriter.writeEntry(se); logger.info("export {}", se.toString()); } } } } reader.close(); ldifWriter.close(); connection.close(); } private boolean skipEntry(SearchResultEntry se) { opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/ConfigTest.java
New file @@ -0,0 +1,124 @@ /* * 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.openidentityplatform.opendj.embedded; import org.testng.annotations.AfterMethod; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.util.ArrayList; import java.util.List; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; /** * Tests how the numeric configuration properties are read: they are parsed while the instance * is being created, so a value that cannot be parsed has to name the property that carried it * instead of surfacing as a bare {@link NumberFormatException} from a constructor. */ public class ConfigTest { private static final String PREFIX = Config.class.getPackage().getName(); private final List<String> propertiesSet = new ArrayList<>(); @AfterMethod public void clearProperties() { for (String name : propertiesSet) { System.clearProperty(name); } propertiesSet.clear(); } @DataProvider public Object[][] numericProperties() { return new Object[][] { { ".port" }, { ".admin_port" }, { ".jmx_port" }, { ".delete_timeout" } }; } @Test public void readsTheDefaultsWhenNoPropertyIsSet() { final Config config = new Config(); assertEquals(config.getPort(), 1389); assertEquals(config.getAdminPort(), 4444); assertEquals(config.getJmxPort(), 1689); assertEquals(config.getDeleteTimeout(), 10_000L); } @Test public void readsTheNumbersFromTheSystemProperties() { setProperty(".port", "11389"); setProperty(".admin_port", "14444"); setProperty(".jmx_port", "11689"); setProperty(".delete_timeout", "0"); final Config config = new Config(); assertEquals(config.getPort(), 11389); assertEquals(config.getAdminPort(), 14444); assertEquals(config.getJmxPort(), 11689); assertEquals(config.getDeleteTimeout(), 0L); } @Test(dataProvider = "numericProperties") public void namesThePropertyWhoseValueIsNotANumber(String property) { setProperty(property, "not a number"); try { new Config(); fail("a value that is not a number should have been rejected"); } catch (IllegalArgumentException e) { assertTrue(e.getMessage().contains(PREFIX + property), e.getMessage()); assertTrue(e.getMessage().contains("not a number"), e.getMessage()); assertTrue(e.getCause() instanceof NumberFormatException, "unexpected cause: " + e.getCause()); } } @Test public void rejectsANegativeDeleteTimeoutProperty() { setProperty(".delete_timeout", "-1"); try { new Config(); fail("a negative timeout should have been rejected"); } catch (IllegalArgumentException e) { assertTrue(e.getMessage().contains(PREFIX + ".delete_timeout"), e.getMessage()); assertTrue(e.getMessage().contains("negative"), e.getMessage()); } } @Test public void rejectsANegativeDeleteTimeout() { final Config config = new Config(); try { config.setDeleteTimeout(-1); fail("a negative timeout should have been rejected"); } catch (IllegalArgumentException e) { assertTrue(e.getMessage().contains("-1"), e.getMessage()); } assertEquals(config.getDeleteTimeout(), 10_000L, "the timeout has been changed"); } private void setProperty(String property, String value) { final String name = PREFIX + property; System.setProperty(name, value); propertiesSet.add(name); } } opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJCleanupTest.java
New file @@ -0,0 +1,181 @@ /* * 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.openidentityplatform.opendj.embedded; import org.apache.commons.io.FileUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.Test; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; /** * Tests the deletion of the temporary directory of an embedded instance, which has to survive * the files that the server threads are still holding when {@code stop()} returns. * <p> * The deletion itself is injected, so that the retries can be driven without depending on a * genuinely undeletable file, which no platform offers in a portable way. */ public class EmbeddedOpenDJCleanupTest { private final List<File> temporaryDirectories = new ArrayList<>(); @AfterMethod public void deleteTemporaryDirectories() { for (File directory : temporaryDirectories) { FileUtils.deleteQuietly(directory); } temporaryDirectories.clear(); } @Test public void deletesTheWholeTree() throws Exception { final File directory = createTree(3); EmbeddedOpenDJ.deleteInstanceDirectory(directory, 0); assertFalse(directory.exists(), directory + " has not been deleted"); } @Test public void ignoresADirectoryThatDoesNotExist() throws Exception { final File directory = createTree(0); assertTrue(FileUtils.deleteQuietly(directory)); //neither of these must throw EmbeddedOpenDJ.deleteInstanceDirectory(directory, 10_000); EmbeddedOpenDJ.deleteInstanceDirectory(null, 10_000); } @Test public void stopsRetryingAsSoonAsTheDeletionSucceeds() throws Exception { final File directory = createTree(1); final AtomicInteger attempts = new AtomicInteger(); final long elapsedMs = timed(() -> EmbeddedOpenDJ.deleteInstanceDirectory(directory, 10_000, file -> attempts.incrementAndGet() >= 3)); assertEquals(attempts.get(), 3, "the deletion should have been retried until it succeeded"); assertTrue(elapsedMs < 10_000, "it waited " + elapsedMs + " ms instead of returning on success"); } @Test public void triesOnlyOnceWithoutATimeout() throws Exception { final File directory = createTree(1); final AtomicInteger attempts = new AtomicInteger(); EmbeddedOpenDJ.deleteInstanceDirectory(directory, 0, file -> { attempts.incrementAndGet(); return false; }); assertEquals(attempts.get(), 1, "a zero timeout must not retry"); assertTrue(directory.exists(), "the injected deletion did not delete anything"); } @Test public void givesUpWhenTheTimeoutExpires() throws Exception { final File directory = createTree(1); final AtomicInteger attempts = new AtomicInteger(); final long timeoutMs = 300; final long elapsedMs = timed(() -> EmbeddedOpenDJ.deleteInstanceDirectory(directory, timeoutMs, file -> { attempts.incrementAndGet(); return false; })); assertTrue(attempts.get() > 1, "the deletion should have been retried, attempts: " + attempts.get()); assertTrue(elapsedMs >= timeoutMs, "it gave up after " + elapsedMs + " ms, before the timeout"); assertTrue(elapsedMs < TimeUnit.SECONDS.toMillis(30), "it did not give up, " + elapsedMs + " ms elapsed"); //the leftovers are registered for deletion on JVM exit, so this must return normally assertTrue(directory.exists()); } @Test public void givesUpWhenTheThreadIsInterrupted() throws Exception { final File directory = createTree(1); final AtomicInteger attempts = new AtomicInteger(); final long elapsedMs; Thread.currentThread().interrupt(); try { elapsedMs = timed(() -> EmbeddedOpenDJ.deleteInstanceDirectory(directory, 60_000, file -> { attempts.incrementAndGet(); return false; })); assertTrue(Thread.currentThread().isInterrupted(), "the interrupted status has been swallowed"); } finally { Thread.interrupted(); } assertEquals(attempts.get(), 1, "an interrupted deletion must not be retried"); assertTrue(elapsedMs < TimeUnit.SECONDS.toMillis(30), "it kept retrying for " + elapsedMs + " ms"); } @Test public void reportsTheRemainingPathsAndHowManyAreLeftOut() throws Exception { final File directory = createTree(25); final String remaining = EmbeddedOpenDJ.remainingPaths(directory); final String[] lines = remaining.split("\n"); //one empty leading token before the first line separator, 20 paths and the summary assertEquals(lines.length, 22, "unexpected report: " + remaining); for (int i = 1; i < lines.length - 1; i++) { assertTrue(lines[i].startsWith(" " + directory.getPath()), "unexpected line: " + lines[i]); } assertTrue(remaining.endsWith("... and 5 more"), remaining); } @Test public void reportsAllTheRemainingPathsWhenThereAreFewOfThem() throws Exception { final File directory = createTree(2); final String remaining = EmbeddedOpenDJ.remainingPaths(directory); assertEquals(remaining.split("\n").length, 3, "unexpected report: " + remaining); assertFalse(remaining.contains("more"), remaining); } /** Creates a temporary directory holding the given number of files, one of them nested. */ private File createTree(int fileCount) throws IOException { final File directory = Files.createTempDirectory("opendj-cleanup-test").toFile(); temporaryDirectories.add(directory); final File subDirectory = new File(directory, "nested"); assertTrue(subDirectory.mkdir()); for (int i = 0; i < fileCount; i++) { final File parent = i == 0 ? subDirectory : directory; Files.write(new File(parent, "file" + i + ".txt").toPath(), new byte[] { 'x' }); } return directory; } private static long timed(Runnable runnable) { final long start = System.nanoTime(); runnable.run(); return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); } } opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java
@@ -26,16 +26,19 @@ import org.forgerock.opendj.ldif.ConnectionEntryReader; import org.testng.annotations.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.stream.Stream; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; public class EmbeddedOpenDJTest { @@ -51,44 +54,71 @@ //start embedded OpenDJ server EmbeddedOpenDJ embeddedOpenDJ = new EmbeddedOpenDJ(config); embeddedOpenDJ.run(); assertTrue(embeddedOpenDJ.isRunning()); File serverRoot = embeddedOpenDJ.getServerRootDirectory(); assertTrue(serverRoot.isDirectory()); try { embeddedOpenDJ.run(); assertTrue(embeddedOpenDJ.isRunning()); assertTrue(serverRoot.isDirectory()); //import ldif data from an input stream URI resUri = getClass().getClassLoader().getResource("opendj/data.ldif").toURI(); byte[] bytes = Files.readAllBytes(Paths.get(resUri)); String newBytes = new String(bytes); InputStream is = new ByteArrayInputStream(newBytes.getBytes(StandardCharsets.UTF_8)); embeddedOpenDJ.importData(is); //import ldif data from an input stream URI resUri = getClass().getClassLoader().getResource("opendj/data.ldif").toURI(); try (InputStream is = Files.newInputStream(Paths.get(resUri))) { embeddedOpenDJ.importData(is); } //export OpenDJ data ByteArrayOutputStream bos = new ByteArrayOutputStream(); embeddedOpenDJ.getData("dc=openidentityplatform,dc=org", bos); String imported = bos.toString(); assertTrue(imported.contains("dn: uid=jdoe,ou=people,dc=openidentityplatform,dc=org")); //export OpenDJ data ByteArrayOutputStream bos = new ByteArrayOutputStream(); embeddedOpenDJ.getData(config.getBaseDN(), bos); //getData() writes LDIF in the platform default charset, so it has to be read back //in the same one: LDIFEntryWriter(OutputStream) wraps it in a plain OutputStreamWriter String imported = bos.toString(); assertTrue(imported.contains("dn: uid=jdoe,ou=people," + config.getBaseDN())); //test search in the imported data try(LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", 1389); Connection connection = factory.getConnection()) { BindResult result = connection.bind("cn=Directory Manager", "passw0rd".toCharArray()); assertTrue(result.isSuccess()); //test search in the imported data try (LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", config.getPort()); Connection connection = factory.getConnection()) { BindResult result = connection.bind("cn=Directory Manager", config.getAdminPassword().toCharArray()); assertTrue(result.isSuccess()); SearchRequest request = Requests.newSearchRequest("dc=openidentityplatform,dc=org", SearchScope.WHOLE_SUBTREE, "(uid=jdoe)", "uid"); ConnectionEntryReader reader = connection.search(request); SearchResultEntry entry = reader.readEntry(); entry.getAllAttributes(); SearchRequest request = Requests.newSearchRequest(config.getBaseDN(), SearchScope.WHOLE_SUBTREE, "(uid=jdoe)", "uid"); try (ConnectionEntryReader reader = connection.search(request)) { SearchResultEntry entry = reader.readEntry(); assertEquals(entry.getName().toString(), "uid=jdoe,ou=people," + config.getBaseDN()); assertEquals(entry.parseAttribute("uid").asString(), "jdoe"); assertFalse(reader.hasNext(), "the search returned more than one entry"); } } } finally { //stop OpenDJ embeddedOpenDJ.close(); } //stop OpenDJ embeddedOpenDJ.close(); assertFalse(embeddedOpenDJ.isRunning()); //the per-instance temporary directory is deleted on close assertFalse(serverRoot.exists()); assertFalse(serverRoot.getParentFile().exists()); assertFalse(serverRoot.exists(), leftovers(serverRoot)); assertFalse(serverRoot.getParentFile().exists(), leftovers(serverRoot.getParentFile())); //a closed instance cannot be used any more: its directory is gone assertThrows(IllegalStateException.class, embeddedOpenDJ::run); assertThrows(IllegalStateException.class, embeddedOpenDJ::getServerRootDirectory); assertThrows(IllegalStateException.class, () -> embeddedOpenDJ.getData(config.getBaseDN(), new ByteArrayOutputStream())); //close() is idempotent, it is also registered as a shutdown hook embeddedOpenDJ.close(); } /** Describes what is left in the given directory, to make an assertion failure diagnosable. */ private static String leftovers(File directory) { if (!directory.exists()) { return ""; } final StringBuilder message = new StringBuilder(directory + " still exists and contains:"); try (Stream<Path> paths = Files.walk(directory.toPath())) { paths.forEach(path -> message.append("\n ").append(path)); } catch (IOException e) { message.append(" <cannot be listed: ").append(e).append('>'); } return message.toString(); } }