diff --git a/OpenICF-dbcommon/src/main/java/org/identityconnectors/dbcommon/SQLUtil.java b/OpenICF-dbcommon/src/main/java/org/identityconnectors/dbcommon/SQLUtil.java index 8b9d5d35f..08de42bab 100644 --- a/OpenICF-dbcommon/src/main/java/org/identityconnectors/dbcommon/SQLUtil.java +++ b/OpenICF-dbcommon/src/main/java/org/identityconnectors/dbcommon/SQLUtil.java @@ -822,8 +822,6 @@ public static void setSQLParam(final PreparedStatement stmt, final int idx, SQLP stmt.setLong(idx, ((BigInteger) val).longValue()); } else if (val instanceof Byte) { stmt.setByte(idx, (Byte) val); - } else if (val instanceof Integer) { - stmt.setInt(idx, (Integer) val); } else if (val instanceof InputStream) { stmt.setBinaryStream(idx, (InputStream) val, 10000); } else if (val instanceof Blob) { diff --git a/OpenICF-groovy-connector/src/main/groovy/org/forgerock/openicf/connectors/scriptedcrest/ScriptedCRESTConfiguration.groovy b/OpenICF-groovy-connector/src/main/groovy/org/forgerock/openicf/connectors/scriptedcrest/ScriptedCRESTConfiguration.groovy index 921f9c849..fd0fd5b02 100644 --- a/OpenICF-groovy-connector/src/main/groovy/org/forgerock/openicf/connectors/scriptedcrest/ScriptedCRESTConfiguration.groovy +++ b/OpenICF-groovy-connector/src/main/groovy/org/forgerock/openicf/connectors/scriptedcrest/ScriptedCRESTConfiguration.groovy @@ -20,6 +20,8 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openicf.connectors.scriptedcrest @@ -198,7 +200,6 @@ class ScriptedCRESTConfiguration extends ScriptedConfiguration { } private Closure init = null; - private Closure release = null; private Closure beforeRequest = null; private Closure onComplete = null; private Closure onFail = null; @@ -223,7 +224,7 @@ class ScriptedCRESTConfiguration extends ScriptedConfiguration { customizerClass.metaClass.customize << { Closure cl -> init = null - release = null + setReleaseClosure(null) beforeRequest = null onComplete = null onFail = null diff --git a/OpenICF-groovy-connector/src/main/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfiguration.java b/OpenICF-groovy-connector/src/main/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfiguration.java index a918990dd..6f10156e1 100644 --- a/OpenICF-groovy-connector/src/main/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfiguration.java +++ b/OpenICF-groovy-connector/src/main/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfiguration.java @@ -666,6 +666,12 @@ public void release() { clone.call(); releaseClosure = null; } + if (null != publishedCustomizerClass) { + // Drops the metaclass createCustomizerScript() may have put on the class, + // which would otherwise keep this engine's loader alive. + InvokerHelper.removeClass(publishedCustomizerClass); + publishedCustomizerClass = null; + } groovyScriptEngine = null; propertyBag.clear(); loggerCache.clear(); @@ -782,8 +788,18 @@ protected Log getLogger(final Class clazz) { return logger; } + /** The engine, published only once its customizer has run. Guarded by {@code this}. */ private GroovyScriptEngine groovyScriptEngine = null; + /** + * The engine whose customizer is running, for the calls the customizer makes back into + * {@link #getGroovyScriptEngine()} on the same thread. Guarded by {@code this}. + */ + private GroovyScriptEngine customizingGroovyScriptEngine = null; + + /** The customizer class of the published engine; {@link #release()} unregisters it. Guarded by {@code this}. */ + private Class publishedCustomizerClass = null; + /** * Synchronised for the whole initialisation, not double-checked: the * customizer script runs against the half-initialised engine (it may call @@ -792,6 +808,10 @@ protected Log getLogger(final Class clazz) { */ protected synchronized GroovyScriptEngine getGroovyScriptEngine() { if (null == groovyScriptEngine) { + if (null != customizingGroovyScriptEngine) { + return customizingGroovyScriptEngine; + } + final CompilerConfiguration compilerConfiguration = new CompilerConfiguration(config); compilerConfiguration.addCompilationCustomizers(getImportCustomizer(null)); @@ -799,28 +819,47 @@ protected synchronized GroovyScriptEngine getGroovyScriptEngine() { final GroovyClassLoader loader = new GroovyClassLoader(getParentLoader(), compilerConfiguration, true); - groovyScriptEngine = + final GroovyScriptEngine engine = new GroovyScriptEngine(getRoots(compilerConfiguration, loader), loader); - initializeCustomizer(); + // If the customizer fails, the engine is dropped and the next call retries. + final Class customizerClass; + customizingGroovyScriptEngine = engine; + try { + customizerClass = initializeCustomizer(); + } finally { + customizingGroovyScriptEngine = null; + } + publishedCustomizerClass = customizerClass; + groovyScriptEngine = engine; } return groovyScriptEngine; } /* * This must be called once from thread-safe location and inside the - * synchronized to avoid deadlock. + * synchronized to avoid deadlock. The customizer runs while this + * configuration's monitor is held: it must not wait for another thread + * that calls getGroovyScriptEngine(), evaluate() or loadScript(). + * Returns the customizer class, or null if there is none. */ - private void initializeCustomizer() { + private Class initializeCustomizer() { + Class customizerClass = null; try { - Class customizerClass = getCustomizerClass(); + customizerClass = getCustomizerClass(); if (null != customizerClass) { Binding binding = new Binding(); binding.setVariable(LOGGER, getLogger(customizerClass)); createCustomizerScript(customizerClass, binding).run(); } + return customizerClass; } catch (Throwable t) { + if (null != customizerClass) { + // The retry compiles a new class; unregister this one, together with any + // metaclass createCustomizerScript() put on it, so its loader can be collected. + InvokerHelper.removeClass(customizerClass); + } logger.error(t, "Failed to customize the connector"); throw ConnectorException.wrap(t); } diff --git a/OpenICF-groovy-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfigurationTest.java b/OpenICF-groovy-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfigurationTest.java new file mode 100644 index 000000000..b4586192f --- /dev/null +++ b/OpenICF-groovy-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/ScriptedConfigurationTest.java @@ -0,0 +1,237 @@ +/* + * 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.forgerock.openicf.misc.scriptedcommon; + +import static org.fest.assertions.api.Assertions.assertThat; +import static org.fest.assertions.api.Assertions.fail; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.codehaus.groovy.reflection.ClassInfo; +import org.forgerock.openicf.connectors.scriptedcrest.ScriptedCRESTConfiguration; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import groovy.lang.Binding; +import groovy.lang.ExpandoMetaClass; +import groovy.lang.GroovySystem; +import groovy.lang.Script; +import groovy.util.GroovyScriptEngine; + +/** + * Tests that {@link ScriptedConfiguration#getGroovyScriptEngine()} only hands out an engine whose + * customizer script has run. + */ +public class ScriptedConfigurationTest { + + /** What the customizer script does; set by each test. */ + private static volatile Runnable customizer; + + /** Called from the customizer script written by {@link #setUp()}. */ + public static void customize() { + customizer.run(); + } + + /** How often {@link #isFirstAttempt()} was called; reset by {@link #setUp()}. */ + private static final AtomicInteger attempts = new AtomicInteger(); + + /** Whether the release closure of a customizer script ran; reset by {@link #setUp()}. */ + private static volatile boolean releaseClosureRan; + + /** Called from a customizer script to behave differently on its first attempt. */ + public static boolean isFirstAttempt() { + return attempts.incrementAndGet() == 1; + } + + /** Called from the release closure of a customizer script. */ + public static void releaseClosureRan() { + releaseClosureRan = true; + } + + private File scriptRoot; + + private ScriptedConfiguration configuration; + + private ExecutorService executor; + + @BeforeMethod + public void setUp() throws Exception { + attempts.set(0); + releaseClosureRan = false; + scriptRoot = Files.createTempDirectory("scripted-configuration").toFile(); + Files.write(new File(scriptRoot, "Customizer.groovy").toPath(), + (ScriptedConfigurationTest.class.getName() + ".customize()\n") + .getBytes(StandardCharsets.UTF_8)); + configuration = new ScriptedConfiguration(); + configuration.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() }); + configuration.setCustomizerScriptFileName("Customizer.groovy"); + executor = Executors.newCachedThreadPool(); + } + + @AfterMethod + public void tearDown() throws Exception { + customizer = null; + executor.shutdownNow(); + new File(scriptRoot, "Customizer.groovy").delete(); + scriptRoot.delete(); + } + + @Test + public void testFailedCustomizerIsRetriedOnTheNextCall() { + final AtomicInteger calls = new AtomicInteger(); + customizer = () -> { + if (calls.incrementAndGet() == 1) { + throw new IllegalStateException("customizer failed"); + } + }; + + try { + configuration.getGroovyScriptEngine(); + fail("The customizer failure must reach the caller"); + } catch (IllegalStateException e) { + assertThat(e).hasMessage("customizer failed"); + } + + assertThat(configuration.getGroovyScriptEngine()).isNotNull(); + assertThat(calls.get()).isEqualTo(2); + } + + @Test + public void testFailedCustomizerClassIsUnregistered() { + customizer = () -> { + throw new IllegalStateException("customizer failed"); + }; + final AtomicReference customizerClass = new AtomicReference<>(); + configuration = registeringMetaClassOnCustomizer(customizerClass); + + try { + configuration.getGroovyScriptEngine(); + fail("The customizer failure must reach the caller"); + } catch (IllegalStateException e) { + assertThat(e).hasMessage("customizer failed"); + } + + assertThat(customizerClass.get()).isNotNull(); + assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass()).isNull(); + } + + @Test + public void testReleaseUnregistersThePublishedCustomizerClass() { + customizer = () -> { + }; + final AtomicReference customizerClass = new AtomicReference<>(); + configuration = registeringMetaClassOnCustomizer(customizerClass); + + assertThat(configuration.getGroovyScriptEngine()).isNotNull(); + assertThat(customizerClass.get()).isNotNull(); + assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass()) + .as("metaclass of the published customizer before release()").isNotNull(); + + configuration.release(); + assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass()).isNull(); + } + + /** + * A configuration that registers a metaclass on its customizer class, as the REST, CREST and + * SSH configurations do; a registered metaclass keeps the class's loader alive. + */ + private ScriptedConfiguration registeringMetaClassOnCustomizer( + final AtomicReference customizerClass) { + ScriptedConfiguration result = new ScriptedConfiguration() { + @Override + protected Script createCustomizerScript(Class clazz, Binding binding) { + customizerClass.set(clazz); + ExpandoMetaClass metaClass = new ExpandoMetaClass(clazz, false, true); + metaClass.initialize(); + GroovySystem.getMetaClassRegistry().setMetaClass(clazz, metaClass); + return super.createCustomizerScript(clazz, binding); + } + }; + result.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() }); + result.setCustomizerScriptFileName("Customizer.groovy"); + return result; + } + + @Test + public void testRetriedCRESTCustomizerDropsTheReleaseClosureOfTheFailedAttempt() throws Exception { + final String test = ScriptedConfigurationTest.class.getName(); + Files.write(new File(scriptRoot, "Customizer.groovy").toPath(), + ("if (" + test + ".isFirstAttempt()) {\n" + + " customize {\n" + + " release { " + test + ".releaseClosureRan() }\n" + + " }\n" + + " throw new IllegalStateException('customizer failed')\n" + + "}\n" + + "customize {\n" + + "}\n").getBytes(StandardCharsets.UTF_8)); + configuration = new ScriptedCRESTConfiguration(); + configuration.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() }); + configuration.setCustomizerScriptFileName("Customizer.groovy"); + + try { + configuration.getGroovyScriptEngine(); + fail("The customizer failure must reach the caller"); + } catch (IllegalStateException e) { + assertThat(e).hasMessage("customizer failed"); + } + assertThat(configuration.getGroovyScriptEngine()).isNotNull(); + + configuration.release(); + assertThat(releaseClosureRan).as("release closure of the failed attempt ran").isFalse(); + } + + @Test(timeOut = 30000) + public void testEngineIsHiddenFromOtherThreadsUntilCustomized() throws Exception { + final CountDownLatch customizing = new CountDownLatch(1); + final CountDownLatch finishCustomizing = new CountDownLatch(1); + customizer = () -> { + customizing.countDown(); + try { + finishCustomizing.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + + Future first = executor.submit(configuration::getGroovyScriptEngine); + customizing.await(); + final AtomicReference secondThread = new AtomicReference<>(); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + return configuration.getGroovyScriptEngine(); + }); + // The second caller either returns early or blocks on the configuration's monitor. + while (!second.isDone() && (secondThread.get() == null + || secondThread.get().getState() != Thread.State.BLOCKED)) { + Thread.sleep(10); + } + assertThat(second.isDone()).as("second caller returned while the customizer ran").isFalse(); + + finishCustomizing.countDown(); + assertThat(first.get()).isNotNull(); + assertThat(second.get()).isSameAs(first.get()); + } +} diff --git a/OpenICF-java-framework/connector-framework-contract/src/main/java/org/identityconnectors/contract/test/ContractITCase.java b/OpenICF-java-framework/connector-framework-contract/src/main/java/org/identityconnectors/contract/test/ContractITCase.java index 398cef046..b2431a346 100644 --- a/OpenICF-java-framework/connector-framework-contract/src/main/java/org/identityconnectors/contract/test/ContractITCase.java +++ b/OpenICF-java-framework/connector-framework-contract/src/main/java/org/identityconnectors/contract/test/ContractITCase.java @@ -20,11 +20,11 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.contract.test; -import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; @@ -32,12 +32,8 @@ import org.identityconnectors.common.StringUtil; import org.identityconnectors.contract.data.DataProvider; -import org.identityconnectors.framework.api.ConnectorFacade; -import org.identityconnectors.framework.common.objects.Schema; -import org.testng.IObjectFactory; import org.testng.ITestContext; import org.testng.annotations.Factory; -import org.testng.internal.ObjectFactoryImpl; import com.google.inject.Guice; import com.google.inject.Injector; @@ -70,17 +66,16 @@ public Object[] createInstances(ITestContext context) { Injector injector = getInjector(context); List result = new ArrayList(); - IObjectFactory objectFactory = null; for (Class testClass: getContractTestClasses(context)) { - Constructor constructor = null; try { - constructor = testClass.getConstructor(String.class); - Object test = objectFactory.newInstance(constructor, ""); + Object test = testClass.getConstructor(String.class).newInstance(""); injector.injectMembers(test); result.add(test); } catch (NoSuchMethodException e) { result.add(injector.getInstance(testClass)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot instantiate " + testClass.getName(), e); } } return result.toArray(); @@ -111,14 +106,4 @@ public Injector getInjector(ITestContext context) { public DataProvider getDataProvider(ITestContext context) { return ConnectorHelper.createDataProvider(); } - - private static class ContractTestFactory { - - private ConnectorFacade connectorFacade = null; - - private Schema schema = null; - - private IObjectFactory objectFactory = new ObjectFactoryImpl(); - - } } diff --git a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/rpc/WebSocketConnectionGroup.java b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/rpc/WebSocketConnectionGroup.java index d08f6addf..3c692afbe 100644 --- a/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/rpc/WebSocketConnectionGroup.java +++ b/OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/rpc/WebSocketConnectionGroup.java @@ -71,7 +71,7 @@ public class WebSocketConnectionGroup private Encryptor encryptor = null; - private RemoteOperationContext operationContext = null; + private volatile RemoteOperationContext operationContext = null; private final AtomicBoolean isRunning = new AtomicBoolean(Boolean.TRUE); private final Set principals = new TreeSet(String.CASE_INSENSITIVE_ORDER); diff --git a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/common/FrameworkUtil.java b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/common/FrameworkUtil.java index 03f42dd12..ace6fb229 100644 --- a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/common/FrameworkUtil.java +++ b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/common/FrameworkUtil.java @@ -20,6 +20,7 @@ * "Portions Copyrighted [year] [name of copyright owner]" * ==================== * Portions Copyrighted 2010-2016 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.framework.common; @@ -485,14 +486,14 @@ public static Uid getUidIfGetOperation(Filter filter) { * * @return the framework version; never null. */ - public static Version getFrameworkVersion() { + public static synchronized Version getFrameworkVersion() { if (frameworkVersion == null) { frameworkVersion = Version.create(1, 5); } return frameworkVersion; } - static Version getFrameworkVersion(ClassLoader loader) throws IOException { + static Version readFrameworkVersion(ClassLoader loader) throws IOException { InputStream stream = loader.getResourceAsStream("connectors-framework.properties"); try { Properties props = new Properties(); diff --git a/OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/framework/common/FrameworkUtilTests.java b/OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/framework/common/FrameworkUtilTests.java index ff2186a62..d7052a7f3 100644 --- a/OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/framework/common/FrameworkUtilTests.java +++ b/OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/framework/common/FrameworkUtilTests.java @@ -19,6 +19,7 @@ * enclosed by brackets [] replaced by your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * ==================== + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.framework.common; @@ -41,13 +42,13 @@ public class FrameworkUtilTests { @Test public void testFrameworkVersion() throws Exception { ClassLoader loader = new VersionClassLoader(this.getClass().getClassLoader(), "1.2.3-alpha"); - assertEquals(FrameworkUtil.getFrameworkVersion(loader), Version.parse("1.2.3")); + assertEquals(FrameworkUtil.readFrameworkVersion(loader), Version.parse("1.2.3")); } @Test public void testFrameworkVersionCannotBeBlank() throws Exception { try { - FrameworkUtil.getFrameworkVersion(new VersionClassLoader(this.getClass().getClassLoader(), " ")); + FrameworkUtil.readFrameworkVersion(new VersionClassLoader(this.getClass().getClassLoader(), " ")); Assert.fail(); } catch (IllegalStateException e) { // OK. diff --git a/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/BatchRemoteCache.java b/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/BatchRemoteCache.java index 848ec7485..62b2f024b 100644 --- a/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/BatchRemoteCache.java +++ b/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/BatchRemoteCache.java @@ -20,6 +20,7 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.testconnector; @@ -61,7 +62,7 @@ public CachedBatchResult(Boolean complete, Boolean error, String resultId, Objec private final Map> tasks = new HashMap>(); private final Map> results = new HashMap>(); private final Map complete = new HashMap(); - private final String resultLock = "resultLock"; + private final Object resultLock = new Object(); public static void addTasks(String token, List tasklist) { singleton.tasks.put(token, new ArrayList(tasklist)); diff --git a/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/TstStatefulConnectorConfig.java b/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/TstStatefulConnectorConfig.java index d50ba0110..67b2459f5 100755 --- a/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/TstStatefulConnectorConfig.java +++ b/OpenICF-java-framework/testbundlev1/src/main/java/org/identityconnectors/testconnector/TstStatefulConnectorConfig.java @@ -123,7 +123,7 @@ public void setRandomString(String randomString) { private UUID guid; - private ScheduledExecutorService executorService = null; + private volatile ScheduledExecutorService executorService = null; public synchronized UUID getGuid() { if (null == guid) { diff --git a/OpenICF-ssh-connector/src/main/groovy/org/forgerock/openicf/connectors/ssh/SSHConfiguration.groovy b/OpenICF-ssh-connector/src/main/groovy/org/forgerock/openicf/connectors/ssh/SSHConfiguration.groovy index adc913b6c..39ace1e63 100644 --- a/OpenICF-ssh-connector/src/main/groovy/org/forgerock/openicf/connectors/ssh/SSHConfiguration.groovy +++ b/OpenICF-ssh-connector/src/main/groovy/org/forgerock/openicf/connectors/ssh/SSHConfiguration.groovy @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openicf.connectors.ssh @@ -388,6 +389,7 @@ public class SSHConfiguration extends ScriptedConfiguration { init = null onCreateConnection = null onCloseConnection = null + setReleaseClosure(null) def delegate = [ init : { Closure paramClosure -> diff --git a/OpenICF-ssh-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/SSHConfigurationCustomizerTest.java b/OpenICF-ssh-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/SSHConfigurationCustomizerTest.java new file mode 100644 index 000000000..a130914f4 --- /dev/null +++ b/OpenICF-ssh-connector/src/test/java/org/forgerock/openicf/misc/scriptedcommon/SSHConfigurationCustomizerTest.java @@ -0,0 +1,99 @@ +/* + * 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.forgerock.openicf.misc.scriptedcommon; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.fail; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicInteger; + +import org.forgerock.openicf.connectors.ssh.SSHConfiguration; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * Tests the {@code customize} DSL of {@link SSHConfiguration} when its customizer is retried. It + * lives in this package to reach {@link ScriptedConfiguration#getGroovyScriptEngine()}: a subclass + * would break the customizer's closures, which set private fields of {@link SSHConfiguration}. + */ +public class SSHConfigurationCustomizerTest { + + /** How often {@link #isFirstAttempt()} was called; reset by {@link #setUp()}. */ + private static final AtomicInteger attempts = new AtomicInteger(); + + /** Whether the release closure of the customizer script ran; reset by {@link #setUp()}. */ + private static volatile boolean releaseClosureRan; + + /** Called from the customizer script to behave differently on its first attempt. */ + public static boolean isFirstAttempt() { + return attempts.incrementAndGet() == 1; + } + + /** Called from the release closure of the customizer script. */ + public static void releaseClosureRan() { + releaseClosureRan = true; + } + + private File scriptRoot; + + @BeforeMethod + public void setUp() throws Exception { + attempts.set(0); + releaseClosureRan = false; + scriptRoot = Files.createTempDirectory("ssh-configuration").toFile(); + } + + @AfterMethod + public void tearDown() { + new File(scriptRoot, "Customizer.groovy").delete(); + scriptRoot.delete(); + } + + @Test + public void testRetriedCustomizerDropsTheReleaseClosureOfTheFailedAttempt() throws Exception { + final String test = SSHConfigurationCustomizerTest.class.getName(); + Files.write(new File(scriptRoot, "Customizer.groovy").toPath(), + ("if (" + test + ".isFirstAttempt()) {\n" + + " customize {\n" + + " release { " + test + ".releaseClosureRan() }\n" + + " }\n" + + " throw new IllegalStateException('customizer failed')\n" + + "}\n" + + "customize {\n" + + "}\n").getBytes(StandardCharsets.UTF_8)); + final SSHConfiguration configuration = new SSHConfiguration(); + configuration.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() }); + configuration.setCustomizerScriptFileName("Customizer.groovy"); + + try { + configuration.getGroovyScriptEngine(); + fail("The customizer failure must reach the caller"); + } catch (IllegalStateException e) { + assertEquals(e.getMessage(), "customizer failed"); + } + assertNotNull(configuration.getGroovyScriptEngine()); + + configuration.release(); + assertFalse(releaseClosureRan, "release closure of the failed attempt ran"); + } +}