diff --git a/src/org/labkey/test/TestFileUtils.java b/src/org/labkey/test/TestFileUtils.java
index 1797f95b81..3efbbc6774 100644
--- a/src/org/labkey/test/TestFileUtils.java
+++ b/src/org/labkey/test/TestFileUtils.java
@@ -21,6 +21,7 @@
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.SystemUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.pdfbox.Loader;
@@ -40,6 +41,7 @@
import org.jetbrains.annotations.NotNull;
import org.labkey.api.util.FileUtil;
import org.jetbrains.annotations.Nullable;
+import org.labkey.api.util.StringUtilsLabKey;
import org.openqa.selenium.NotFoundException;
import java.io.BufferedInputStream;
@@ -54,9 +56,11 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
+import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -71,10 +75,13 @@
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
+import static org.labkey.api.util.DebugInfoDumper.dumpHeap;
+import static org.labkey.test.WebDriverWrapper.sleep;
import static org.labkey.test.util.TestDataGenerator.CHARSET_STRING;
import static org.labkey.test.util.TestDataGenerator.randomInt;
import static org.labkey.test.util.TestDataGenerator.randomName;
@@ -445,6 +452,83 @@ public static void deleteDir(File dir)
}
}
+ /**
+ * Deletes a directory and all its contents, retrying up to 10 times with a 10-second delay between attempts.
+ *
+ * Before each attempt, the directory and all its children are marked writable to handle read-only files or
+ * directories. This is primarily intended to work around Windows file-locking issues where an external process
+ * may hold a lock on the directory or its contents.
+ *
+ * On the final failed attempt, a heap dump is captured for diagnostics if running on TeamCity. The list of running
+ * processes is also logged to help identify what may be holding the lock.
+ *
+ * @param dir the directory to delete
+ * @throws Exception if an unexpected error occurs
+ */
+ public static void deleteDirWithRetry(File dir) throws Exception
+ {
+ // Sometimes on Windows the directory could be locked, maybe by an external process, or the child directory is
+ // readonly. Use a retry mechanism to set the writeable flag and then try to delete the parent directory.
+ for (int attempt = 1; attempt <= 10; attempt++) {
+ try
+ {
+ dir.setWritable(true, false);
+
+ // Wrap in a try to close the stream.
+ try (Stream files = Files.walk(dir.toPath()))
+ {
+ files.forEach(p -> p.toFile().setWritable(true, false));
+ }
+
+ FileUtils.deleteDirectory(dir);
+ LOG.info(String.format("Deletion of directory %s was successful.", dir));
+ break;
+ } catch (AccessDeniedException e)
+ {
+ throw e;
+ } catch (IOException | UncheckedIOException ioException) {
+ LOG.warn(String.format("IOException trying to delete directory %s. Error: %s. Waiting 10s and retrying. Attempt %d of 10.",
+ dir, ioException.getMessage(), attempt));
+ if (attempt == 10) {
+
+ if (TestProperties.isTestRunningOnTeamCity()) {
+ LOG.info("Dump the heap.");
+ dumpHeap();
+ }
+
+ ProcessBuilder pb;
+ if (SystemUtils.IS_OS_WINDOWS) {
+ pb = new ProcessBuilder("tasklist");
+ }
+ else {
+ pb = new ProcessBuilder("ps", "-ef");
+ }
+
+ try {
+ LOG.info("Lock diagnostic...");
+ pb.redirectErrorStream(true);
+
+ Process p = pb.start();
+ try (InputStream is = p.getInputStream()) {
+ String output = new String(is.readAllBytes(), StringUtilsLabKey.DEFAULT_CHARSET);
+ LOG.info("Running processes:\n" + output);
+ }
+ finally {
+ // Don't leak the process resource.
+ p.destroy();
+ }
+
+ } catch (IOException diagnosticException) {
+ LOG.warn("Failed to run lock diagnostic: " + diagnosticException.getMessage(), diagnosticException);
+ }
+ throw ioException;
+ }
+ sleep(10_000);
+ }
+ }
+
+ }
+
private static void checkFileLocation(File file)
{
try
diff --git a/src/org/labkey/test/tests/DomainDesignerTest.java b/src/org/labkey/test/tests/DomainDesignerTest.java
index f1cbee47e6..f43c32dd5d 100644
--- a/src/org/labkey/test/tests/DomainDesignerTest.java
+++ b/src/org/labkey/test/tests/DomainDesignerTest.java
@@ -40,6 +40,7 @@
import org.labkey.serverapi.collections.ArrayListMap;
import org.labkey.test.BaseWebDriverTest;
import org.labkey.test.Locator;
+import org.labkey.test.WebTestHelper;
import org.labkey.test.SortDirection;
import org.labkey.test.TestFileUtils;
import org.labkey.test.TestTimeoutException;
@@ -1917,6 +1918,47 @@ public Map getConditionalFormats(PropertyDescriptor column, Stri
return conditionalFormat;
}
+ @Test // GitHub Issue #1023
+ public void testNoExternalReturnUrlRedirect() throws Exception
+ {
+ String listName = "ExternalRedirectTestList";
+ TestDataGenerator dgen = new TestDataGenerator("lists", listName, getProjectName())
+ .withColumns(List.of(new FieldDefinition("testField", FieldDefinition.ColumnType.String)));
+ dgen.createDomain(createDefaultConnection(), "IntList", Map.of("keyName", "id"));
+
+ // Verify a valid local returnUrl is used as expected
+ String localReturnUrl = WebTestHelper.buildURL("query", getProjectName(), "begin");
+ beginAt(WebTestHelper.buildURL("core", getProjectName(), "domainDesigner",
+ Map.of("schemaName", "lists", "queryName", listName, "returnUrl", localReturnUrl)));
+ DomainDesignerPage domainDesignerPage = new DomainDesignerPage(getDriver());
+ domainDesignerPage.fieldsPanel();
+ domainDesignerPage.clickCancel();
+ String postCancelUrl = getDriver().getCurrentUrl();
+ assertTrue("Cancel with a local returnUrl should redirect to the specified local page",
+ postCancelUrl.contains("query-begin.view"));
+
+ // Navigate to domain designer with an external returnUrl. The safeRedirect action
+ // should prevent external redirects, falling back to the local home page instead.
+ List domainDesignerUrls = new ArrayList<>();
+ domainDesignerUrls.add(WebTestHelper.buildURL("core", getProjectName(), "domainDesigner",
+ Map.of("schemaName", "lists", "queryName", listName, "returnUrl", "https://labkey.com")));
+ domainDesignerUrls.add(WebTestHelper.buildURL("list", getProjectName(), "editListDefinition", Map.of("returnUrl", "https://labkey.com")));
+ domainDesignerUrls.add(WebTestHelper.buildURL("experiment", getProjectName(), "editSampleType", Map.of("returnUrl", "https://labkey.com")));
+ domainDesignerUrls.add(WebTestHelper.buildURL("experiment", getProjectName(), "editDataClass", Map.of("returnUrl", "https://labkey.com")));
+ for (String domainDesignerUrl : domainDesignerUrls)
+ {
+ beginAt(domainDesignerUrl);
+ domainDesignerPage = new DomainDesignerPage(getDriver());
+ domainDesignerPage.fieldsPanel();
+ domainDesignerPage.clickCancel();
+ postCancelUrl = getDriver().getCurrentUrl();
+ assertFalse("Cancel with an external returnUrl should not navigate to an external site",
+ postCancelUrl.contains("labkey.com"));
+ assertTrue("Cancel with an external returnUrl should redirect to a local LabKey page instead of: " + postCancelUrl,
+ WebTestHelper.isTestServerUrl(postCancelUrl));
+ }
+ }
+
@Override
protected BrowserType bestBrowser()
{
diff --git a/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java b/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java
index 05a2040338..4e673a859a 100644
--- a/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java
+++ b/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java
@@ -15,7 +15,6 @@
*/
package org.labkey.test.tests.assay;
-import org.apache.commons.io.FileUtils;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.labkey.api.util.FileUtil;
@@ -30,8 +29,6 @@
import org.labkey.test.params.assay.GeneralAssayDesign;
import java.io.File;
-import java.io.IOException;
-import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -63,29 +60,8 @@ public void testMissingParentDirectoryRegression() throws Exception
assayDesignerPage.addTransformScript(transformFile);
assayDesignerPage.clickSave();
- // Now delete the parent dir to ensure we handle it reasonably. On Windows something locks the directory, maybe
- // an external process. If that happens sleep for a second and try again.
- for (int attempt = 1; attempt <= 10; attempt++) {
- try
- {
- FileUtils.deleteDirectory(parentDir.toFile());
- log(String.format("Deletion of directory %s was successful.", parentDir));
- break;
- } catch (AccessDeniedException deniedException) {
- // Yes I know AccessDeniedException is a subset of an IOException, but I wanted to log explicitly a
- // failure and retry because of an AccessDeniedException from some other IOException.
- log(String.format("Access denied trying to delete directory %s. Error: %s. Waiting 10s and retrying. Attempt %d of 10.",
- parentDir, deniedException.getMessage(), attempt));
- if (attempt == 10) throw deniedException;
- sleep(10_000);
- }
- catch (IOException ioException) {
- log(String.format("IOException trying to delete directory %s. Error: %s. Waiting 10s and retrying. Attempt %d of 10.",
- parentDir, ioException.getMessage(), attempt));
- if (attempt == 10) throw ioException;
- sleep(10_000);
- }
- }
+ // Now delete the parent dir to ensure we handle it reasonably
+ TestFileUtils.deleteDirWithRetry(parentDir.toFile());
// Attempt to import data and verify a reasonable error message is shown
String importData = """