From 5ca8aecfe6b44e4b7e481bc0ffe175c14f9ef64e Mon Sep 17 00:00:00 2001 From: Cory Nathe Date: Thu, 28 May 2026 14:14:41 -0500 Subject: [PATCH 01/11] Safe redirect action (#3023) - selenium test to go to domain designer with external returnUrl and click cancel to verify redirect as expected --- .../labkey/test/tests/DomainDesignerTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/org/labkey/test/tests/DomainDesignerTest.java b/src/org/labkey/test/tests/DomainDesignerTest.java index 0e71c2b0d4..655744f6e8 100644 --- a/src/org/labkey/test/tests/DomainDesignerTest.java +++ b/src/org/labkey/test/tests/DomainDesignerTest.java @@ -25,6 +25,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; @@ -1902,6 +1903,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() { From 14acad3d576c999dc2173c825db60775a88e6c5b Mon Sep 17 00:00:00 2001 From: Dan Duffek Date: Fri, 29 May 2026 13:05:26 -0700 Subject: [PATCH 02/11] Add TestFileUtils.deleteDirWithRetry method. (#2946) --- src/org/labkey/test/TestFileUtils.java | 84 +++++++++++++++++++ .../AssayTransformMissingParentDirTest.java | 28 +------ 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/src/org/labkey/test/TestFileUtils.java b/src/org/labkey/test/TestFileUtils.java index d73ad973a1..6bd3476529 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/assay/AssayTransformMissingParentDirTest.java b/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java index d0979ba985..48763c9eb8 100644 --- a/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java +++ b/src/org/labkey/test/tests/assay/AssayTransformMissingParentDirTest.java @@ -1,6 +1,5 @@ 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; @@ -15,8 +14,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; @@ -48,29 +45,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 = """ From 20053a75907b7daabc46d3b56339f8bb9ac1aa8e Mon Sep 17 00:00:00 2001 From: Trey Chadick Date: Tue, 2 Jun 2026 13:56:55 -0700 Subject: [PATCH 03/11] Fix intermittent R report timeouts (#3027) --- src/org/labkey/test/pages/reports/ScriptReportPage.java | 2 +- src/org/labkey/test/tests/AbstractKnitrReportTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/org/labkey/test/pages/reports/ScriptReportPage.java b/src/org/labkey/test/pages/reports/ScriptReportPage.java index a037747028..10679cd9ef 100644 --- a/src/org/labkey/test/pages/reports/ScriptReportPage.java +++ b/src/org/labkey/test/pages/reports/ScriptReportPage.java @@ -214,7 +214,7 @@ private void _clickReportTab() scrollToTop(); // Clicking report tab can scroll such that the cursor hovers over and opens the project menu waitAndClick(Ext4Helper.Locators.tab("Report")); // Report view should appear quickly - shortWait().until(ExpectedConditions.visibilityOfElementLocated(Locator.tagWithClass("div", "reportView"))); + longWait().until(ExpectedConditions.visibilityOfElementLocated(Locator.tagWithClass("div", "reportView"))); // Actual report might take a while to load _ext4Helper.waitForMaskToDisappear(BaseWebDriverTest.WAIT_FOR_PAGE); } diff --git a/src/org/labkey/test/tests/AbstractKnitrReportTest.java b/src/org/labkey/test/tests/AbstractKnitrReportTest.java index 518707fd5f..6a0d46acc5 100644 --- a/src/org/labkey/test/tests/AbstractKnitrReportTest.java +++ b/src/org/labkey/test/tests/AbstractKnitrReportTest.java @@ -234,7 +234,7 @@ protected void moduleReportDependencies() clickProject(getProjectName()); _ext4Helper.waitForMaskToDisappear(); waitAndClickAndWait(Locator.linkWithText("kable")); - _ext4Helper.waitForMaskToDisappear(3 * BaseWebDriverTest.WAIT_FOR_JAVASCRIPT); + _ext4Helper.waitForMaskToDisappear(60_000); waitForElement(Locator.id("mtcars_table")); } From fbf47ff0bc079c21a65396c25c41797963f11c23 Mon Sep 17 00:00:00 2001 From: Dan Duffek Date: Thu, 4 Jun 2026 08:24:38 -0700 Subject: [PATCH 04/11] 26.3 fb increase time out (#3032) --- src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java b/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java index ea2f3b964c..a9c7daacd2 100644 --- a/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java +++ b/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java @@ -103,7 +103,7 @@ public void testUpload200kRows() throws Exception // wait for import complete var assayJobsPage1 = new AssayUploadJobsPage(getDriver()); - var pipelineDetailsPage1 = assayJobsPage1.clickJobStatus("200k", 3 * WebDriverWrapper.WAIT_FOR_PAGE); + var pipelineDetailsPage1 = assayJobsPage1.clickJobStatus("200k", 6 * WebDriverWrapper.WAIT_FOR_PAGE); pipelineDetailsPage1.waitForComplete(12 * WebDriverWrapper.WAIT_FOR_PAGE); // export assay1 data to excel From 06216048baa12118005a68a8d568a4fa63439774 Mon Sep 17 00:00:00 2001 From: Dan Duffek Date: Thu, 4 Jun 2026 17:46:38 -0700 Subject: [PATCH 05/11] Change locator to look for a span and not an icon. (#3033) --- src/org/labkey/test/pages/ReactAssayDesignerPage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/labkey/test/pages/ReactAssayDesignerPage.java b/src/org/labkey/test/pages/ReactAssayDesignerPage.java index 824ed87e27..ca79c67760 100644 --- a/src/org/labkey/test/pages/ReactAssayDesignerPage.java +++ b/src/org/labkey/test/pages/ReactAssayDesignerPage.java @@ -298,7 +298,7 @@ private ReactAssayDesignerPage setTransformScript(File transformScript, boolean { getWrapper().waitFor(()-> Locator.tagWithClass("div", "alert-danger").withText(expectedError).isDisplayed(this), "Transform script expected error not found", WAIT_FOR_JAVASCRIPT); - getWrapper().click(Locator.tagWithClass("i", "container--removal-icon")); + getWrapper().click(Locator.tagWithClass("span", "container--removal-icon")); } return this; From 0e88cbb151af61e53780dd7ca19745e8f4063b25 Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:23:17 -0700 Subject: [PATCH 06/11] Calculated Column Expression Assistant - Test Automation (#3028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale [Calculated Column Expression Assistant - Test Automation](https://github.com/LabKey/kanban/issues/1847) #### Related Pull Requests - https://github.com/LabKey/premiumModules/pull/589 - https://github.com/LabKey/limsModules/pull/2233 #### Changes - created CalculatedColumnAssistantDialog component --------- Co-authored-by: Trey Chadick --- .../CalculatedColumnAssistantDialog.java | 245 ++++++++++++++++++ .../components/domain/DomainFieldRow.java | 23 ++ 2 files changed, 268 insertions(+) create mode 100644 src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java new file mode 100644 index 0000000000..cc8a6c1ab6 --- /dev/null +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -0,0 +1,245 @@ +package org.labkey.test.components.domain; + +import org.labkey.test.Locator; +import org.labkey.test.WebDriverWrapper; +import org.labkey.test.components.bootstrap.ModalDialog; +import org.openqa.selenium.WebElement; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Modal that opens when the user clicks the "AI Assistant" button inside the Calculation field options. + */ +public class CalculatedColumnAssistantDialog extends ModalDialog +{ + public static final String TITLE = "Expression AI Assistant"; + + private final DomainFieldRow _row; + + public CalculatedColumnAssistantDialog(DomainFieldRow row, ModalDialogFinder finder) + { + super(finder); + _row = row; + } + + public CalculatedColumnAssistantDialog(DomainFieldRow row) + { + this(row, new ModalDialogFinder(row.getDriver()).withTitle(TITLE)); + } + + /** + * Type the prompt into the textarea. The submit button stays disabled until non-empty text is present. + */ + public CalculatedColumnAssistantDialog setPrompt(String prompt) + { + getWrapper().setFormElement(elementCache().promptInput, prompt); + WebDriverWrapper.waitFor(() -> elementCache().promptSubmitButton.isEnabled(), + "Prompt submit button did not become enabled.", 2_000); + return this; + } + + public String getPrompt() + { + return getWrapper().getFormElement(elementCache().promptInput); + } + + /** + * Click the submit (arrow) button. First waits for the "Thinking..." spinner to disappear (up to 60s) + * and then for a new assistant response to render (up to 10s). + */ + public CalculatedColumnAssistantDialog submitPrompt() + { + int previousCount = getAssistantResponses().size(); + elementCache().promptSubmitButton.click(); + waitForThinkingSpinnerToDisappear(); + WebDriverWrapper.waitFor(() -> getAssistantResponses().size() > previousCount, + "No new assistant response appeared in chat history.", 10_000); + return this; + } + + private void waitForThinkingSpinnerToDisappear() + { + WebDriverWrapper.waitFor(() -> !Locators.thinkingSpinner.existsIn(this), 60_000); + } + + /** + * Convenience: type the prompt and submit it. + */ + public CalculatedColumnAssistantDialog sendPrompt(String prompt) + { + return setPrompt(prompt).submitPrompt(); + } + + /** + * @return one entry per assistant response bubble (concatenated text of all its {@code .assistant-text} blocks), + * in chat order. Suggested-expression SQL is not included here — see {@link #getSuggestedExpressions()}. + */ + public List getAssistantResponses() + { + return Locators.assistantResponse.findElements(this).stream() + .map(WebElement::getText) + .collect(Collectors.toList()); + } + + /** + * @return text of the most recent assistant response, or empty string if there are none. + */ + public String getLastAssistantResponse() + { + List responses = getAssistantResponses(); + return responses.isEmpty() ? "" : responses.get(responses.size() - 1); + } + + /** + * @return every applicable SQL expression suggested in the most recent assistant response, in display + * order. Only counts {@code .assistant-expression} blocks that include an "Apply Expression" button — read-only + * SQL the assistant shows for illustration (e.g. an alternative custom-query example) is excluded, since the user + * can't accept it as the field's calculation. + */ + public List getSuggestedExpressions() + { + WebElement lastResponse = lastAssistantResponseElement(); + if (lastResponse == null) + return List.of(); + return Locators.applicableSqlCode.findElements(lastResponse).stream() + .map(WebElement::getText) + .collect(Collectors.toList()); + } + + /** + * @return the first SQL expression in the most recent assistant response, or empty string if none. + */ + public String getFirstSuggestedExpression() + { + List expressions = getSuggestedExpressions(); + return expressions.isEmpty() ? "" : expressions.get(0); + } + + /** + * Click "Apply Expression" on the first suggestion in the most recent assistant response. + * Returns the underlying field row (the dialog stays open; call {@link #clickEndChat()} to close it). + */ + public DomainFieldRow applyFirstSuggestedExpression() + { + return applySuggestedExpression(0); + } + + /** + * Click "Apply Expression" on the suggestion at the given index in the most recent assistant response. Waits + * up to 5 seconds for at least one applicable expression to render — the spinner disappears as soon as the + * bubble exists, but the inner {@code assistant-expression} block sometimes finishes rendering a moment later. + */ + public DomainFieldRow applySuggestedExpression(int index) + { + List buttons = new ArrayList<>(); + WebDriverWrapper.waitFor(() -> { + buttons.clear(); + WebElement last = lastAssistantResponseElement(); + if (last != null) + buttons.addAll(Locators.applyButton.findElements(last)); + return !buttons.isEmpty(); + }, + "No applicable expression rendered in the assistant response.", + 5_000); + + if (index >= buttons.size()) + throw new IndexOutOfBoundsException( + "Requested expression index " + index + " but only " + buttons.size() + " expression(s) available."); + buttons.get(index).click(); + return _row; + } + + /** + * @return text of the first assistant response in the chat history, or empty string if there are none. Useful + * for asserting the intro message in NEW / CHANGE / VALIDATE entry modes. + */ + public String getFirstAssistantResponse() + { + List responses = getAssistantResponses(); + return responses.isEmpty() ? "" : responses.get(0); + } + + /** + * @return true while the dialog is waiting for an AI response (the "Thinking..." pending bubble is shown). + */ + public boolean isPending() + { + return Locators.pendingBubble.existsIn(this); + } + + /** + * Click the stop button to abort an in-flight AI request. The submit button toggles to a stop button (fa-stop) + * while the dialog is in the pending state; calling this method when no request is pending will fail. + */ + public void clickStop() + { + Locators.stopButton.findElement(this).click(); + } + + /** + * Click submit without waiting for the response. Useful for tests that need to interrupt or otherwise observe + * the pending state before the response arrives. Prefer {@link #submitPrompt()} when the caller wants to wait. + */ + public void clickSubmitWithoutWaiting() + { + elementCache().promptSubmitButton.click(); + } + + private WebElement lastAssistantResponseElement() + { + List responses = Locators.assistantResponse.findElements(this); + return responses.isEmpty() ? null : responses.get(responses.size() - 1); + } + + /** + * Click "End Chat" to close the dialog. + */ + public DomainFieldRow clickEndChat() + { + elementCache().endChatButton.click(); + waitForClose(); + return _row; + } + + @Override + protected ElementCache newElementCache() + { + return new ElementCache(); + } + + @Override + protected ElementCache elementCache() + { + return (ElementCache) super.elementCache(); + } + + public static class Locators + { + public static final Locator.XPathLocator assistantResponse = Locator.tagWithClass("div", "chat-item").withClass("assistant-response"); + + public static final Locator.XPathLocator pendingBubble = Locator.tagWithClass("div", "chat-item").withClass("pending"); + + public static final Locator.XPathLocator thinkingSpinner = Locator.tagWithClass("i", "fa-spinner"); + + public static final Locator.XPathLocator applyButton = Locator.tagWithClass("div", "assistant-expression") + .descendant(Locator.tagWithClass("button", "clickable-text")); + + public static final Locator.XPathLocator applicableSqlCode = Locator.tagWithClass("div", "assistant-expression") + .withDescendant(Locator.tagWithClass("button", "clickable-text")) + .descendant(Locator.tag("code")); + + public static final Locator.XPathLocator stopButton = Locator.tagWithClass("button", "prompt-button") + .withDescendant(Locator.tagWithClass("i", "fa-stop")); + } + + protected class ElementCache extends ModalDialog.ElementCache + { + final WebElement endChatButton = Locator.tagWithClass("button", "btn").withText("End Chat").findWhenNeeded(this); + + final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").findWhenNeeded(this); + + final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").refindWhenNeeded(this); + } +} diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index bf3487677a..be6ed3774d 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -1085,6 +1085,28 @@ public String getValueExpression() return getWrapper().getFormElement(elementCache().expressionInput); } + /** + * Click the "AI Assistant" button in the expanded Calculation field options and return the resulting dialog. + */ + public CalculatedColumnAssistantDialog openAIAssistant() + { + expand(); + elementCache().aiAssistantButton.click(); + return new CalculatedColumnAssistantDialog(this); + } + + /** + * @return true if the "AI Assistant" button is present in the expanded Calculation field options. + * The button is only available when the {@code professional} module is enabled. + */ + public boolean hasAIAssistantButton() + { + expand(); + return Locator.tagWithClass("button", "btn") + .withText("AI Assistant") + .findElementOrNull(this) != null; + } + // advanced settings public DomainFieldRow showFieldOnDefaultView(boolean checked) @@ -1778,6 +1800,7 @@ protected class ElementCache extends WebDriverComponent.ElementCache public final WebElement expressionStatusError = expressionStatusMsgLoc.descendant(Locator.tagWithClass("span", "error")).refindWhenNeeded(this); public final WebElement expressionStatusMsg = expressionStatusMsgLoc.childTag("div").refindWhenNeeded(this); public final WebElement expressionValidateLink = expressionStatusMsgLoc.child(Locator.tagWithClass("div", "validate-link")).refindWhenNeeded(this); + public final WebElement aiAssistantButton = Locator.tagWithClass("button", "btn").withText("AI Assistant").refindWhenNeeded(this); Locator.XPathLocator aliquotWarningAlert = Locator.tagWithClassContaining("div", "aliquot-alert-warning"); From 3fbde0307e1b8376f4c65dbc82a28fae02fcdaa6 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Sun, 7 Jun 2026 09:18:25 -0700 Subject: [PATCH 07/11] Give more helpful feedback when remote connection fails (#3036) #### Rationale We give very opaque error feedback if an attempted ETL remote connection fails. #### Related Pull Requests - https://github.com/LabKey/platform/pull/7729 #### Changes - Ensure errors are shown to user --- .../test/util/RemoteConnectionHelper.java | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/org/labkey/test/util/RemoteConnectionHelper.java b/src/org/labkey/test/util/RemoteConnectionHelper.java index 101218b60c..0eb6d9b41d 100644 --- a/src/org/labkey/test/util/RemoteConnectionHelper.java +++ b/src/org/labkey/test/util/RemoteConnectionHelper.java @@ -53,6 +53,18 @@ public boolean testConnection(String name) return success; } + /** Test the named connection, expecting failure with an error message starting with the given prefix. The full message may include environment-specific exception text. */ + public void testConnectionExpectFailure(String name, String expectedErrorPrefix) + { + WebElement target = findConnectionStrict(name, "test"); + _test.clickAndWait(target, _test.getDefaultWaitForPage()); + _test.assertTextPresent("not successful"); + String error = getDisplayedError(); + Assert.assertNotNull("No error message shown on connection test failure page", error); + Assert.assertTrue("Expected error message to start with '" + expectedErrorPrefix + "' but was: " + error, error.startsWith(expectedErrorPrefix)); + _test.clickAndWait(Locator.linkWithText("Manage Remote Connections")); + } + public void createConnection(String name, String url, String container, String user, String password, String expectedError) { _test.clickAndWait(Locator.linkWithText("Create New Connection")); @@ -61,6 +73,18 @@ public void createConnection(String name, String url, String container, String u verifyExpectedError(expectedError); } + /** Expect the save to fail with an error message starting with the given prefix. The full message may include environment-specific exception text. */ + public void createConnectionExpectErrorPrefix(String name, String url, String container, String user, String password, String expectedErrorPrefix) + { + _test.clickAndWait(Locator.linkWithText("Create New Connection")); + setConnectionProperties(name, url, container, user, password); + _test.clickButton("save"); + String error = getDisplayedError(); + Assert.assertNotNull("No error message shown after attempting to save connection", error); + Assert.assertTrue("Expected error message to start with '" + expectedErrorPrefix + "' but was: " + error, error.startsWith(expectedErrorPrefix)); + _test.clickButton("cancel"); + } + public void editConnection(String name, String newName, String newUrl, String newContainer, String newUser, String newPassword) { editConnection(name, newName, newUrl, newContainer, newUser, newPassword, null); @@ -137,12 +161,17 @@ private void verifyExpectedError(String expectedError) { if (null != expectedError) { - String error = Locators.labkeyError.findOptionalElement(_test.getDriver()).map(WebElement::getText).orElse(null); + String error = getDisplayedError(); Assert.assertEquals("Remote connection error.", expectedError, error); _test.clickButton("cancel"); } } + private String getDisplayedError() + { + return Locators.labkeyError.findOptionalElement(_test.getDriver()).map(WebElement::getText).orElse(null); + } + private WebElement findConnection(String name, String action) { List items = Locator.xpath("//a[contains(text(), '" + action + "')]").findElements(_test.getDriver()); From 02c2eb95d7b9bdd61fd9c2f395f02126668337eb Mon Sep 17 00:00:00 2001 From: Karl Lum Date: Mon, 15 Jun 2026 11:08:49 -0700 Subject: [PATCH 08/11] Container scoping for NAb including automation (#3042) #### Rationale Regression tests for the NAb container scoping updates. #### Related Pull Requests - https://github.com/LabKey/platform/pull/7747 - https://github.com/LabKey/commonAssays/pull/1022 - https://github.com/LabKey/testAutomation/pull/3042 --------- Co-authored-by: cnathe --- .../tests/elispotassay/ElispotAssayTest.java | 60 +++++ .../labkey/test/tests/nab/NabAssayTest.java | 232 ++++++++++++++++++ 2 files changed, 292 insertions(+) diff --git a/src/org/labkey/test/tests/elispotassay/ElispotAssayTest.java b/src/org/labkey/test/tests/elispotassay/ElispotAssayTest.java index 0cda0ff4fd..c26f630f0d 100644 --- a/src/org/labkey/test/tests/elispotassay/ElispotAssayTest.java +++ b/src/org/labkey/test/tests/elispotassay/ElispotAssayTest.java @@ -21,11 +21,16 @@ import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; +import org.labkey.api.query.QueryKey; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.remoteapi.query.SelectRowsResponse; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.SortDirection; import org.labkey.test.TestFileUtils; import org.labkey.test.TestTimeoutException; +import org.labkey.test.WebTestHelper; import org.labkey.test.categories.Assays; import org.labkey.test.categories.Daily; import org.labkey.test.components.CrosstabDataRegion; @@ -39,15 +44,19 @@ import org.labkey.test.util.PipelineStatusTable; import org.labkey.test.util.PortalHelper; import org.labkey.test.util.QCAssayScriptHelper; +import org.labkey.test.util.SimpleHttpRequest; +import org.labkey.test.util.SimpleHttpResponse; import org.openqa.selenium.NoSuchElementException; import java.io.File; +import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.labkey.test.components.PlateSummary.Row.A; import static org.labkey.test.components.PlateSummary.Row.C; import static org.labkey.test.components.PlateSummary.Row.E; @@ -519,10 +528,61 @@ protected void runTransformTest() protected void doBackgroundSubtractionTest() { removeTransformScript(); + verifyCrossContainerBackgroundSubtractionDenied(); verifyBackgroundSubtractionOnExistingRun(); verifyBackgroundSubtractionOnNewRun(); } + // GitHub Kanban #1892: verify BackgroundSubtractionAction selected run in the current container. + @LogMethod + protected void verifyCrossContainerBackgroundSubtractionDenied() + { + final String crossFolder = "BackgroundSubtractionAuth"; + _containerHelper.createSubfolder(getProjectName(), crossFolder, "Assay"); + + // A run RowId from the assay in the project - "foreign" from the subfolder's perspective. + long foreignRunId = getFirstElispotRunId(); + + log("POST background subtraction for the project's run (RowId " + foreignRunId + ") from the sibling subfolder - must be denied"); + String url = WebTestHelper.buildURL("elispot-assay", getProjectName() + "/" + crossFolder, "backgroundSubtraction", + Map.of(".select", String.valueOf(foreignRunId))); + SimpleHttpRequest request = new SimpleHttpRequest(url, "POST"); + request.copySession(getDriver()); + SimpleHttpResponse response; + try + { + response = request.getResponse(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + // The run is not in the subfolder, so the action should report it as not found (HTTP 404) rather than queuing the job. + assertEquals("Cross-container background subtraction should be denied for a run in another folder", 404, response.getResponseCode()); + assertTrue("Cross-container background subtraction error message not as expected", response.getResponseBody().contains("Run " + foreignRunId + " does not exist")); + + log("Verify the project's run was not modified by the cross-container request"); + clickProject(TEST_ASSAY_PRJ_ELISPOT); + clickAndWait(Locator.linkWithText(TEST_ASSAY_ELISPOT)); + DataRegionTable runTable = new DataRegionTable("Runs", this); + for (String item : runTable.getColumnDataAsText("Background Subtraction")) + assertEquals("Cross-container request must not change background subtraction", "false", item); + } + + private long getFirstElispotRunId() + { + SelectRowsCommand select = new SelectRowsCommand("assay.ELISpot." + QueryKey.encodePart(TEST_ASSAY_ELISPOT), "Runs"); + try + { + SelectRowsResponse response = select.execute(createDefaultConnection(), getProjectName()); + return ((Number) response.getRowset().iterator().next().getValue("RowId")).longValue(); + } + catch (IOException | CommandException e) + { + throw new RuntimeException(e); + } + } + // Unable to apply background substitution to runs imported with a transform script. protected void removeTransformScript() { diff --git a/src/org/labkey/test/tests/nab/NabAssayTest.java b/src/org/labkey/test/tests/nab/NabAssayTest.java index 1585551864..248b635252 100644 --- a/src/org/labkey/test/tests/nab/NabAssayTest.java +++ b/src/org/labkey/test/tests/nab/NabAssayTest.java @@ -16,9 +16,16 @@ package org.labkey.test.tests.nab; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.SimplePostCommand; +import org.labkey.remoteapi.query.Filter; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.remoteapi.query.SelectRowsResponse; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.Locators; @@ -37,13 +44,18 @@ import org.labkey.test.pages.query.NewQueryPage; import org.labkey.test.pages.query.SourceQueryPage; import org.labkey.test.tests.AbstractAssayTest; +import org.labkey.test.util.APIAssayHelper; +import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.AssayImportOptions; import org.labkey.test.util.AssayImporter; import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.DilutionAssayHelper; import org.labkey.test.util.LogMethod; +import org.labkey.test.util.PermissionsHelper; import org.labkey.test.util.PortalHelper; import org.labkey.test.util.QCAssayScriptHelper; +import org.labkey.test.util.SimpleHttpRequest; +import org.labkey.test.util.SimpleHttpResponse; import org.labkey.test.util.TestLogger; import org.labkey.test.util.WikiHelper; import org.openqa.selenium.WebDriverException; @@ -51,13 +63,16 @@ import org.openqa.selenium.support.ui.ExpectedConditions; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; @Category({Daily.class, Assays.class}) @BaseWebDriverTest.ClassTimeout(minutes = 15) @@ -73,6 +88,10 @@ public class NabAssayTest extends AbstractAssayTest protected final static String TEST_ASSAY_USR_NAB_READER = "nabreader1@security.test"; private final static String TEST_ASSAY_GRP_NAB_READER = "Nab Dataset Reader"; + // Container-scoping fixtures (GitHub Kanban #1892, NAB-1/2/8/9): a "bystander" folder and a user privileged only there. + private final static String TEST_ASSAY_FLDR_NAB_SCOPE = "NabScopeBystanderFolder"; + private final static String TEST_ASSAY_USR_NAB_SCOPE = "nabscope@security.test"; + private static final String NAB_FILENAME2 = "m0902053;3999.xls"; protected final File TEST_ASSAY_NAB_FILE1 = TestFileUtils.getSampleData("Nab/m0902051;3997.xls"); protected final File TEST_ASSAY_NAB_FILE2 = TestFileUtils.getSampleData("Nab/" + NAB_FILENAME2); @@ -171,6 +190,8 @@ protected void doCleanup(boolean afterTest) throws TestTimeoutException { super.doCleanup(afterTest); + _userHelper.deleteUsers(false, TEST_ASSAY_USR_NAB_SCOPE); + try { new QCAssayScriptHelper(this).deleteEngine(); @@ -347,6 +368,13 @@ public void runUITests() build()).doImport(); verifyRunDetails(); + + verifyCrossContainerExcludedWellsDenied(); + verifyCrossContainerQCControlInfoDenied(); + verifyCrossContainerQCDataDenied(); + verifyCrossContainerSaveQCControlInfoDenied(); + verifyCrossContainerGraphDenied(); + // Test editing runs // Set the design to allow editing clickAndWait(Locator.linkWithText("View Runs")); @@ -383,6 +411,9 @@ public void runUITests() startSystemMaintenance("Database"); waitForSystemMaintenanceCompletion(); + // Verify cross-container access control for the run/specimen-resolving actions (NAB-1/2/8/9) while the imported runs are still present. + verifyContainerScopedAccessControl(); + // Return to the run list navigateToFolder(getProjectName(), TEST_ASSAY_FLDR_NAB); clickAndWait(Locator.linkWithText(TEST_ASSAY_NAB)); @@ -489,6 +520,207 @@ public void runUITests() runNabQCTest(); } + /** + * GitHub Issue #1892: Selenium coverage for the NAb container-scoping fixes. Each of these + * actions resolves a run (by global rowId) or a NAb specimen object id (resolved to its run by a global, cross-container + * lookup) without an intrinsic container check. A user privileged only in a bystander folder must not be able to reach a + * run living in the (foreign) assay folder by pointing one of these actions at its row/object id while scoping the request + * to the bystander folder. We capture the ids as the admin, then issue the requests as an impersonated bystander Editor. + */ + @LogMethod + private void verifyContainerScopedAccessControl() + { + // Capture a protocol id, a run rowId, and a NAb specimen object id from the (foreign) assay folder — done as the admin, before impersonating. + int protocolId = ((APIAssayHelper) _assayHelper).getIdFromAssayName(TEST_ASSAY_NAB, "/" + getProjectName()); + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + int objectId = firstRowId("Data", runFolderPath, null); + assertTrue("Expected an imported NAb run and specimen to scope against", runId > 0 && objectId > 0); + + // A user who is an Editor (read + delete) in the bystander folder only — no access to the assay folder where the run lives. + _containerHelper.createSubfolder(getProjectName(), TEST_ASSAY_FLDR_NAB_SCOPE); + String bystanderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB_SCOPE; + _userHelper.createUser(TEST_ASSAY_USR_NAB_SCOPE); + new ApiPermissionsHelper(this).addMemberToRole(TEST_ASSAY_USR_NAB_SCOPE, "Editor", PermissionsHelper.MemberType.user, "/" + bystanderPath); + + impersonate(TEST_ASSAY_USR_NAB_SCOPE); + try + { + // NAB-2: DownloadDatafileAction resolves the run by global rowId. + assertForeignContainerRejected("downloadDatafile (NAB-2)", + WebTestHelper.buildURL("nabassay", bystanderPath, "downloadDatafile", Map.of("rowId", String.valueOf(runId))), "GET"); + + // NAB-8: NabMultiGraphAction -> MultiGraphAction.getView resolves the object ids to runs. + assertForeignContainerRejected("nabMultiGraph (NAB-8)", + WebTestHelper.buildURL("nabassay", bystanderPath, "nabMultiGraph", Map.of("protocolId", String.valueOf(protocolId), "id", String.valueOf(objectId))), "GET"); + + // NAB-9: NabGraphSelectedAction -> GraphSelectedAction.getView resolves the object ids to runs. + assertForeignContainerRejected("nabGraphSelected (NAB-9)", + WebTestHelper.buildURL("nabassay", bystanderPath, "nabGraphSelected", Map.of("protocolId", String.valueOf(protocolId), "id", String.valueOf(objectId))), "GET"); + + // NAB-1: DeleteRunAction resolves the run by global rowId; this action is a POST. + assertForeignContainerRejected("deleteRun (NAB-1)", + WebTestHelper.buildURL("nabassay", bystanderPath, "deleteRun", Map.of("rowId", String.valueOf(runId))), "POST"); + } + finally + { + stopImpersonating(); + } + + // The run must survive the rejected cross-container delete attempt. + assertEquals("Foreign-container delete must not remove the run", runId, firstRowId("Runs", runFolderPath, List.of(new Filter("RowId", runId)))); + } + + /** Fetch the RowId of the first row of an assay.NAb query across the project's subfolders, as the admin. Returns -1 if none. */ + private int firstRowId(String queryName, String containerPath, @Nullable List filters) + { + SelectRowsCommand command = new SelectRowsCommand("assay.NAb." + TEST_ASSAY_NAB, queryName); + command.setColumns(List.of("RowId")); + if (filters != null) + command.setFilters(filters); + try + { + SelectRowsResponse response = command.execute(createDefaultConnection(), containerPath); + return response.getRows().isEmpty() ? -1 : ((Number) response.getRows().get(0).get("RowId")).intValue(); + } + catch (IOException | CommandException e) + { + throw new RuntimeException(e); + } + } + + private void assertForeignContainerRejected(String description, String url, String requestMethod) + { + SimpleHttpRequest request = new SimpleHttpRequest(url, requestMethod); + request.copySession(getDriver()); // execute as the impersonated bystander user (carries CSRF token for the POST) + request.clearLogin(); // rely solely on the impersonated session, not admin basic-auth + SimpleHttpResponse response; + try + { + response = request.getResponse(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + assertEquals("Foreign-container request should be rejected with 404: " + description, 404, response.getResponseCode()); + assertTrue("Foreign-container rejection for " + description + " should report the resource does not exist, was: " + response.getResponseBody(), + response.getResponseBody().contains("exist")); + } + + // GitHub Kanban #1892: verify GetExcludedWellsAction resolves run by RowId and container + @LogMethod + private void verifyCrossContainerExcludedWellsDenied() + { + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + + log("Excluded wells request from the run's own folder should succeed"); + SimpleHttpResponse ownFolder = getNabRunApi("getExcludedWells.api", runFolderPath, runId); + assertEquals("Excluded wells request in the run's own folder should succeed", 200, ownFolder.getResponseCode()); + assertTrue("Excluded wells response should include the excluded payload", ownFolder.getResponseBody().contains("\"excluded\"")); + + log("The same run requested from a different container (the project) must be denied, not disclosed"); + SimpleHttpResponse crossContainer = getNabRunApi("getExcludedWells.api", getProjectName(), runId); + assertEquals("Cross-container excluded wells request should be denied", 400, crossContainer.getResponseCode()); + assertFalse("Cross-container request must not disclose excluded well data", crossContainer.getResponseBody().contains("\"excluded\"")); + assertTrue("Cross-container error message not as expected", crossContainer.getResponseBody().contains("NAb Run " + runId + " does not exist.")); + } + + // GitHub Kanban #1892: verify GetQCControlInfoAction resolves run by RowId and container + @LogMethod + private void verifyCrossContainerQCControlInfoDenied() + { + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + + log("QC control info request from the run's own folder should succeed"); + SimpleHttpResponse ownFolder = getNabRunApi("getQCControlInfo.api", runFolderPath, runId); + assertEquals("QC control info request in the run's own folder should succeed", 200, ownFolder.getResponseCode()); + assertTrue("QC control info response should include the plates payload", ownFolder.getResponseBody().contains("\"plates\"")); + + log("The same run requested from a different container (the project) must be denied, not disclosed"); + SimpleHttpResponse crossContainer = getNabRunApi("getQCControlInfo.api", getProjectName(), runId); + assertEquals("Cross-container QC control info request should be denied", 404, crossContainer.getResponseCode()); + assertFalse("Cross-container request must not disclose plate data", crossContainer.getResponseBody().contains("\"plates\"")); + assertTrue("Cross-container error message not as expected", crossContainer.getResponseBody().contains("Run " + runId + " does not exist.")); + } + + // GitHub Kanban #1892: verify QCDataAction resolves run by RowId and container + @LogMethod + private void verifyCrossContainerQCDataDenied() + { + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + + log("QC data view from the run's own folder should render"); + SimpleHttpResponse ownFolder = getNabRunApi("qcData.view", runFolderPath, runId); + assertEquals("QC data view in the run's own folder should render", 200, ownFolder.getResponseCode()); + + log("The same run requested from a different container (the project) must be denied, not disclosed"); + SimpleHttpResponse crossContainer = getNabRunApi("qcData.view", getProjectName(), runId); + assertEquals("Cross-container QC data view should be denied", 404, crossContainer.getResponseCode()); + assertTrue("Cross-container error message not as expected", crossContainer.getResponseBody().contains("Run " + runId + " does not exist.")); + } + + // GitHub Kanban #1892: verify SaveQCControlInfoAction resolves run by RunId and container (write path) + @LogMethod + private void verifyCrossContainerSaveQCControlInfoDenied() + { + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + + log("Saving QC control info for the run from a different container (the project) must be denied"); + SimplePostCommand command = new SimplePostCommand("nabassay", "saveQCControlInfo"); + command.setJsonObject(new JSONObject().put("runId", runId)); + try + { + command.execute(createDefaultConnection(), getProjectName()); + fail("Cross-container QC control info save should have been denied"); + } + catch (CommandException e) + { + assertEquals("Cross-container QC control info save should be denied", 400, e.getStatusCode()); + assertTrue("Cross-container error message not as expected", e.getMessage().contains("NAb Run " + runId + " does not exist.")); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + // GitHub Kanban #1892: verify DilutionGraphAction (NAb GraphAction) resolves run by RowId and container + @LogMethod + private void verifyCrossContainerGraphDenied() + { + String runFolderPath = getProjectName() + "/" + TEST_ASSAY_FLDR_NAB; + int runId = firstRowId("Runs", runFolderPath, null); + + log("NAb graph from the run's own folder should render"); + SimpleHttpResponse ownFolder = getNabRunApi("graph.view", runFolderPath, runId); + assertEquals("NAb graph in the run's own folder should render", 200, ownFolder.getResponseCode()); + + log("The same run requested from a different container (the project) must be denied, not disclosed"); + SimpleHttpResponse crossContainer = getNabRunApi("graph.view", getProjectName(), runId); + assertEquals("Cross-container NAb graph should be denied", 404, crossContainer.getResponseCode()); + assertTrue("Cross-container error message not as expected", crossContainer.getResponseBody().contains("Run " + runId + " does not exist.")); + } + + private SimpleHttpResponse getNabRunApi(String action, String containerPath, long runId) + { + String url = WebTestHelper.buildURL("nabassay", containerPath, action, Map.of("rowId", String.valueOf(runId))); + SimpleHttpRequest request = new SimpleHttpRequest(url); + request.copySession(getDriver()); + try + { + return request.getResponse(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + //Issue 17050: UnsupportedOperationException from org.labkey.nab.query.NabProtocolSchema$NabResultsQueryView.createDataView private void directBrowserQueryTest() { From f1706f38785f01fdbb4386dbb67755ca9d86a6ed Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Tue, 16 Jun 2026 15:30:33 -0700 Subject: [PATCH 09/11] Kanban Issue #1924: Additional container scoping updates (#3046) --- .../test/tests/AttachmentFieldTest.java | 79 +++++++++++ .../security/GetContainerInfoAPITest.java | 102 +++++++++++++ .../experiment/GetEntitySequenceAPITest.java | 134 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/org/labkey/test/tests/core/security/GetContainerInfoAPITest.java create mode 100644 src/org/labkey/test/tests/experiment/GetEntitySequenceAPITest.java diff --git a/src/org/labkey/test/tests/AttachmentFieldTest.java b/src/org/labkey/test/tests/AttachmentFieldTest.java index 48e14a7046..4e39f4e2e2 100644 --- a/src/org/labkey/test/tests/AttachmentFieldTest.java +++ b/src/org/labkey/test/tests/AttachmentFieldTest.java @@ -1,5 +1,6 @@ package org.labkey.test.tests; +import org.apache.hc.core5.http.HttpStatus; import org.assertj.core.api.Assertions; import org.jetbrains.annotations.Nullable; import org.junit.Assert; @@ -9,25 +10,36 @@ import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.TestFileUtils; +import org.labkey.test.WebTestHelper; import org.labkey.test.categories.Daily; import org.labkey.test.components.DomainDesignerPage; import org.labkey.test.components.domain.DomainFieldRow; import org.labkey.test.components.domain.DomainFormPanel; +import org.labkey.test.pages.ReactAssayDesignerPage; import org.labkey.test.pages.experiment.UpdateSampleTypePage; import org.labkey.test.params.FieldDefinition; import org.labkey.test.params.experiment.SampleTypeDefinition; +import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.DataRegionTable; +import org.labkey.test.util.PasswordUtil; +import org.labkey.test.util.PermissionsHelper; import org.labkey.test.util.PortalHelper; import org.labkey.test.util.SampleTypeHelper; import org.labkey.test.util.TestDataGenerator; +import org.openqa.selenium.By; +import org.openqa.selenium.support.ui.ExpectedConditions; + import java.io.File; +import java.net.URI; import java.util.List; @Category({Daily.class}) @BaseWebDriverTest.ClassTimeout(minutes = 2) public class AttachmentFieldTest extends BaseWebDriverTest { + private static final String RESTRICTED_PROJECT = "AttachmentFieldTest Restricted Project"; + private static final String RESTRICTED_USER = "restrictedreader@attachmentfieldtest.test"; private final File SAMPLE_FILE = new File(TestFileUtils.getSampleData("fileTypes"), "jpg_sample.jpg"); @BeforeClass @@ -57,6 +69,14 @@ private void doSetup() portalHelper.addBodyWebPart("Lists"); } + @Override + protected void doCleanup(boolean afterTest) + { + super.doCleanup(afterTest); + _containerHelper.deleteProject(RESTRICTED_PROJECT, false); + _userHelper.deleteUsers(false, RESTRICTED_USER); + } + @Test public void testFileFieldInSampleType() { @@ -138,4 +158,63 @@ public void testAttachmentFieldInLists() File downloadedFile = doAndWaitForDownload(() -> Locator.tagWithAttributeContaining("img", "title", SAMPLE_FILE.getName()).findElement(getDriver()).click()); Assert.assertTrue("Downloaded file is empty", downloadedFile.length() > 0); } + + // Kanban #1924 + @Test + public void testDownloadFileLinkCrossContainerPermission() + { + final String assayName = "CrossContainerAssay"; + final String runFieldName = "runFile"; + + log("Create restricted project with Assay folder type to provide a pipeline root for file storage"); + _containerHelper.createProject(RESTRICTED_PROJECT, "Assay"); + + log("Create a General assay with a run-level file link field"); + goToProjectHome(RESTRICTED_PROJECT); + goToManageAssays(); + ReactAssayDesignerPage assayDesigner = _assayHelper.createAssayDesign("General", assayName); + assayDesigner.setEditableRuns(true); + assayDesigner.goToRunFields().addField(runFieldName).setType(FieldDefinition.ColumnType.File); + assayDesigner.clickFinish(); + + log("Import a minimal assay run"); + clickAndWait(Locator.linkWithText(assayName)); + clickButton("Import Data"); + clickButton("Next"); + setFormElement(Locator.name("name"), "TestRun"); + setFormElement(Locator.name("TextAreaDataCollector.textArea"), + "Specimen ID\tParticipant ID\tVisit ID\n100\t1A2B\t1"); + clickButton("Save and Finish"); + + log("Edit the run to set the file field"); + clickAndWait(Locator.linkWithText("view runs")); + new DataRegionTable("Runs", getDriver()).clickEditRow(0); + setFormElement(Locator.name("quf_" + runFieldName), SAMPLE_FILE); + clickButton("Submit"); + waitForElement(DataRegionTable.updateLinkLocator()); + + log("Hover over the run file thumbnail to reveal the popup and get the objectURI-based downloadFileLink URL"); + mouseOver(Locator.xpath("//img[contains(@title, '" + SAMPLE_FILE.getName() + "')]")); + longWait().until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("#helpDiv"))); + String restrictedDownloadUrl = Locator.xpath("//div[@id='helpDiv']//img[contains(@src, 'downloadFileLink')]") + .findElement(getDriver()).getAttribute("src"); + Assertions.assertThat(restrictedDownloadUrl).as("Expected downloadFileLink URL with objectURI parameter") + .contains("downloadFileLink") + .contains("objectURI"); + + // Build a cross-container URL: keep the same objectURI (run LSID) and propertyId but use the main project's + // container. + String crossContainerUrl = WebTestHelper.buildURL("core", getProjectName(), "downloadFileLink") + + "?" + URI.create(restrictedDownloadUrl).getRawQuery(); + + log("Create a reader user with access to the main project only, not to the restricted project"); + _userHelper.createUser(RESTRICTED_USER); + _userHelper.setInitialPassword(RESTRICTED_USER); + new ApiPermissionsHelper(this).addMemberToRole(RESTRICTED_USER, "Reader", PermissionsHelper.MemberType.user, getProjectName()); + + log("Verify cross-container download is rejected with 403 when user lacks read permission on the object's container"); + int status = WebTestHelper.getHttpResponse(crossContainerUrl, RESTRICTED_USER, PasswordUtil.getPassword()).getResponseCode(); + Assert.assertEquals("Expected 403 Forbidden when user lacks read permission on the object's container", + HttpStatus.SC_FORBIDDEN, status); + } } diff --git a/src/org/labkey/test/tests/core/security/GetContainerInfoAPITest.java b/src/org/labkey/test/tests/core/security/GetContainerInfoAPITest.java new file mode 100644 index 0000000000..1951dfd07b --- /dev/null +++ b/src/org/labkey/test/tests/core/security/GetContainerInfoAPITest.java @@ -0,0 +1,102 @@ +package org.labkey.test.tests.core.security; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.TestTimeoutException; +import org.labkey.test.WebTestHelper; +import org.labkey.test.categories.Daily; +import org.labkey.test.util.APIContainerHelper; +import org.labkey.test.util.ApiPermissionsHelper; +import org.labkey.test.util.PasswordUtil; +import org.labkey.test.util.PermissionsHelper; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * Tests cross-container permission enforcement in CoreController.GetContainerInfoAction (Kanban #1924). + * + * The action accepts an optional {@code containerPath} parameter. Prior to the fix, a user who had + * ReadPermission on the request container could supply any container path and receive information about + * it — even containers they had no access to. The fix adds a check that the user also has ReadPermission + * on the resolved container before returning any data. + */ +@Category({Daily.class}) +public class GetContainerInfoAPITest extends BaseWebDriverTest +{ + private static final String READABLE_PROJECT = "GetContainerInfoAPITest Readable"; + private static final String RESTRICTED_PROJECT = "GetContainerInfoAPITest Restricted"; + private static final String READER_USER = "reader@getcontainerinfoapi.test"; + + private final ApiPermissionsHelper _permissions = new ApiPermissionsHelper(this); + + public GetContainerInfoAPITest() + { + ((APIContainerHelper) _containerHelper).setNavigateToCreatedFolders(false); + } + + @BeforeClass + public static void setupProject() + { + GetContainerInfoAPITest init = getCurrentTest(); + init.doSetup(); + } + + private void doSetup() + { + _containerHelper.createProject(READABLE_PROJECT, "Collaboration"); + _containerHelper.createProject(RESTRICTED_PROJECT, "Collaboration"); + + _userHelper.createUser(READER_USER); + _userHelper.setInitialPassword(READER_USER); + _permissions.addMemberToRole(READER_USER, "Reader", PermissionsHelper.MemberType.user, READABLE_PROJECT); + // Intentionally not granting the user any role in RESTRICTED_PROJECT + } + + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + _containerHelper.deleteProject(READABLE_PROJECT, afterTest); + _containerHelper.deleteProject(RESTRICTED_PROJECT, afterTest); + _userHelper.deleteUsers(false, READER_USER); + } + + @Override + protected String getProjectName() + { + return null; + } + + @Override + public List getAssociatedModules() + { + return null; + } + + // Kanban #1924 + @Test + public void testGetContainerInfoAccessControl() + { + // Cross-container denial: user makes the request from READABLE_PROJECT (passing @RequiresPermission), + // but containerPath resolves to RESTRICTED_PROJECT where the user has no ReadPermission. Expect 403. + String restrictedUrl = WebTestHelper.buildURL("core", READABLE_PROJECT, "getContainerInfo", + Map.of("containerPath", RESTRICTED_PROJECT, "newFolderType", "Collaboration")); + int restrictedStatus = WebTestHelper.getHttpResponse(restrictedUrl, READER_USER, PasswordUtil.getPassword()) + .getResponseCode(); + assertEquals("Expected 403 when user lacks ReadPermission on the containerPath container", + HttpStatus.SC_FORBIDDEN, restrictedStatus); + + // Same-container success: containerPath resolves to READABLE_PROJECT where the user is a Reader. Expect 200. + String readableUrl = WebTestHelper.buildURL("core", READABLE_PROJECT, "getContainerInfo", + Map.of("containerPath", READABLE_PROJECT, "newFolderType", "Collaboration")); + int readableStatus = WebTestHelper.getHttpResponse(readableUrl, READER_USER, PasswordUtil.getPassword()) + .getResponseCode(); + assertEquals("Expected 200 when user has ReadPermission on the containerPath container", + HttpStatus.SC_OK, readableStatus); + } +} diff --git a/src/org/labkey/test/tests/experiment/GetEntitySequenceAPITest.java b/src/org/labkey/test/tests/experiment/GetEntitySequenceAPITest.java new file mode 100644 index 0000000000..6832c123c4 --- /dev/null +++ b/src/org/labkey/test/tests/experiment/GetEntitySequenceAPITest.java @@ -0,0 +1,134 @@ +package org.labkey.test.tests.experiment; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.Filter; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.TestTimeoutException; +import org.labkey.test.WebTestHelper; +import org.labkey.test.categories.Daily; +import org.labkey.test.params.experiment.DataClassDefinition; +import org.labkey.test.params.experiment.SampleTypeDefinition; +import org.labkey.test.util.APIContainerHelper; +import org.labkey.test.util.ApiPermissionsHelper; +import org.labkey.test.util.PasswordUtil; +import org.labkey.test.util.PermissionsHelper; +import org.labkey.test.util.exp.DataClassAPIHelper; +import org.labkey.test.util.exp.SampleTypeAPIHelper; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +@Category({Daily.class}) +public class GetEntitySequenceAPITest extends BaseWebDriverTest +{ + private static final String READABLE_PROJECT = "GetEntitySequenceAPITest Readable"; + private static final String RESTRICTED_PROJECT = "GetEntitySequenceAPITest Restricted"; + private static final String READER_USER = "reader@getentitysequenceapi.test"; + private static final String SAMPLE_TYPE_NAME = "GetEntitySequenceTest_SampleType"; + private static final String DATA_CLASS_NAME = "GetEntitySequenceTest_DataClass"; + + private final ApiPermissionsHelper _permissions = new ApiPermissionsHelper(this); + + public GetEntitySequenceAPITest() + { + ((APIContainerHelper) _containerHelper).setNavigateToCreatedFolders(false); + } + + @BeforeClass + public static void setupProject() + { + GetEntitySequenceAPITest init = getCurrentTest(); + init.doSetup(); + } + + private void doSetup() + { + _containerHelper.createProject(READABLE_PROJECT, null); + _containerHelper.createProject(RESTRICTED_PROJECT, null); + + SampleTypeAPIHelper.createEmptySampleType(RESTRICTED_PROJECT, new SampleTypeDefinition(SAMPLE_TYPE_NAME)); + DataClassAPIHelper.createEmptyDataClass(RESTRICTED_PROJECT, new DataClassDefinition(DATA_CLASS_NAME)); + + _userHelper.createUser(READER_USER); + _userHelper.setInitialPassword(READER_USER); + _permissions.addMemberToRole(READER_USER, "Reader", PermissionsHelper.MemberType.user, READABLE_PROJECT); + // Intentionally not granting the user any role in RESTRICTED_PROJECT + } + + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + _containerHelper.deleteProject(READABLE_PROJECT, afterTest); + _containerHelper.deleteProject(RESTRICTED_PROJECT, afterTest); + _userHelper.deleteUsers(false, READER_USER); + } + + @Override + protected String getProjectName() + { + return null; + } + + @Override + public List getAssociatedModules() + { + return List.of("experiment"); + } + + // Kanban #1924 Verify we prevent getting the sequence value from a container the user does not have access to + @Test + public void testGetEntitySequenceSampleTypeAccessControl() throws IOException, CommandException + { + // The sample type lives in RESTRICTED_PROJECT. getSampleType(rowId) fetches globally so the lookup + // succeeds; the fix then checks the user has ReadPermission on the sample type's container. + SelectRowsCommand cmd = new SelectRowsCommand("exp", "SampleSets"); + cmd.addFilter("Name", SAMPLE_TYPE_NAME, Filter.Operator.EQUAL); + cmd.setMaxRows(1); + int sampleTypeRowId = ((Number) cmd.execute(createDefaultConnection(), RESTRICTED_PROJECT) + .getRows().get(0).get("RowId")).intValue(); + + // User has ReadPermission on READABLE_PROJECT (passes @RequiresPermission), but the sample type + // belongs to RESTRICTED_PROJECT where the user has no access. seqType=genId is the only path + // that triggers the cross-container check for sample types. + String url = WebTestHelper.buildURL("experiment", READABLE_PROJECT, "getEntitySequence", + Map.of("kindName", SampleTypeAPIHelper.SAMPLE_TYPE_DOMAIN_KIND, + "seqType", "genId", + "rowId", String.valueOf(sampleTypeRowId))); + int status = WebTestHelper.getHttpResponse(url, READER_USER, PasswordUtil.getPassword()).getResponseCode(); + assertEquals("Expected 403 when user lacks ReadPermission on the sample type's container", + HttpStatus.SC_FORBIDDEN, status); + } + + // Kanban #1924 Verify we prevent getting the sequence value from a container the user does not have access to + + @Test + public void testGetEntitySequenceDataClassAccessControl() throws IOException, CommandException + { + // The data class lives in RESTRICTED_PROJECT. getDataClass(rowId) fetches globally so the lookup + // succeeds; the fix then checks the user has ReadPermission on the data class's container. + // seqType=genId is the only value accepted when kindName=DataClass. + SelectRowsCommand cmd = new SelectRowsCommand("exp", "DataClasses"); + cmd.addFilter("Name", DATA_CLASS_NAME, Filter.Operator.EQUAL); + cmd.setMaxRows(1); + int dataClassRowId = ((Number) cmd.execute(createDefaultConnection(), RESTRICTED_PROJECT) + .getRows().get(0).get("RowId")).intValue(); + + // User has ReadPermission on READABLE_PROJECT (passes @RequiresPermission), but the data class + // belongs to RESTRICTED_PROJECT where the user has no access. + String url = WebTestHelper.buildURL("experiment", READABLE_PROJECT, "getEntitySequence", + Map.of("kindName", "DataClass", + "seqType", "genId", + "rowId", String.valueOf(dataClassRowId))); + int status = WebTestHelper.getHttpResponse(url, READER_USER, PasswordUtil.getPassword()).getResponseCode(); + assertEquals("Expected 403 when user lacks ReadPermission on the data class's container", + HttpStatus.SC_FORBIDDEN, status); + } +} From e22dcada6c60d52f89ce174f2fa0c6ab6b8308a0 Mon Sep 17 00:00:00 2001 From: Dan Duffek Date: Wed, 17 Jun 2026 11:13:04 -0700 Subject: [PATCH 10/11] Back porting test fix (#3051) --- src/org/labkey/test/tests/AbstractKnitrReportTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/labkey/test/tests/AbstractKnitrReportTest.java b/src/org/labkey/test/tests/AbstractKnitrReportTest.java index 5236ed6dce..518707fd5f 100644 --- a/src/org/labkey/test/tests/AbstractKnitrReportTest.java +++ b/src/org/labkey/test/tests/AbstractKnitrReportTest.java @@ -175,7 +175,7 @@ protected void htmlFormat() Locator.tag("img").withAttribute("alt", "plot of chunk blood-pressure-scatter")), // new Locator.tag("pre").containing("## \"1\",249318596,\"2008-05-17\",86,36,129,76,64"), Locator.tag("pre").withText("## knitr says hello to HTML!"), - Locator.tag("pre").startsWith("## Error").containing(": non-numeric argument to binary operator"), + Locator.tag("pre").startsWith("## Error").containing("non-numeric argument to binary operator"), Locator.tag("p").startsWith("Well, everything seems to be working. Let's ask R what is the value of \u03C0? Of course it is 3.141"), nonceCheckSuccessLoc // Inline script should run }; From a8c9d8201c4a8fb8218c8dd14976398e1111e5c5 Mon Sep 17 00:00:00 2001 From: Karl Lum Date: Wed, 17 Jun 2026 17:29:25 -0700 Subject: [PATCH 11/11] Container scoping test automation (#3054) --- src/org/labkey/test/tests/SpecimenTest.java | 35 ++++- .../UpdateFilePropsContainerScopeTest.java | 143 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/org/labkey/test/tests/filecontent/UpdateFilePropsContainerScopeTest.java diff --git a/src/org/labkey/test/tests/SpecimenTest.java b/src/org/labkey/test/tests/SpecimenTest.java index 0277fc6e05..b91c164d34 100644 --- a/src/org/labkey/test/tests/SpecimenTest.java +++ b/src/org/labkey/test/tests/SpecimenTest.java @@ -31,6 +31,7 @@ import org.labkey.test.components.html.BootstrapMenu; import org.labkey.test.pages.ImportDataPage; import org.labkey.test.pages.study.specimen.ManageNotificationsPage; +import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.LogMethod; import org.labkey.test.util.LoggedParam; @@ -132,6 +133,7 @@ protected void setupRequestabilityRules() protected void doVerifySteps() throws IOException { verifyActorDetails(); + verifySpecimenEventsRedirect(); createRequest(); verifyViews(); verifyAdditionalRequestFields(); @@ -325,6 +327,37 @@ private void verifyActorDetails() DataRegion(getDriver()).withName("SpecimenRequest").waitFor(); } + // Simulate SpecimenForeignKey redirect behavior + @LogMethod (quiet = true) + private void verifySpecimenEventsRedirect() + { + String targetStudyId = getContainerId(); + + // Create an empty second folder (doesn't need to be a study) with guest read. We'll attempt to invoke the + // redirect action from this folder. + String folderName = "Another Study"; + _containerHelper.createSubfolder(getProjectName(), folderName, "Study"); + new ApiPermissionsHelper(this).setSiteGroupPermissions("Guests", "Reader"); + + // Happy path - admin should redirect + String baseUrl = WebTestHelper.getBaseURL() + "/" + getProjectName() + "/" + folderName + "/specimen-specimenEventsRedirect.view?targetStudy=" + targetStudyId + "&id="; + String url = baseUrl + "AAA07XK5-01"; + beginAt(url); + assertTextPresent("Vial History", "999320812", "350V0600294A"); + + // Guest has access in Another Study but not in the target study (My Study), so should not redirect + signOut(); + beginAt(baseUrl + "abcdefg_123456"); // Bogus ID and user doesn't have read permission + assertTextPresent("Unable to resolve the Specimen ID and target Study"); + beginAt(url); // Valid ID, but user doesn't have read permission to target study, so same error + assertTextPresent("Unable to resolve the Specimen ID and target Study"); + + // Sign in and back to the main study + signIn(); + goToProjectHome(); + clickFolder(getFolderName()); + } + @LogMethod private void createRequest() { @@ -1048,7 +1081,7 @@ private void verifyDrawTimestampConflict(String qcControl, String timestamp, Str { if (StringUtils.isBlank(qcControl)) { - // no conflict, so all three fields shold be valid + // no conflict, so all three fields should be valid assertTrue(StringUtils.isNotBlank(timestamp)); assertTrue(StringUtils.isNotBlank(time)); assertTrue(StringUtils.isNotBlank(date)); diff --git a/src/org/labkey/test/tests/filecontent/UpdateFilePropsContainerScopeTest.java b/src/org/labkey/test/tests/filecontent/UpdateFilePropsContainerScopeTest.java new file mode 100644 index 0000000000..a6c8732293 --- /dev/null +++ b/src/org/labkey/test/tests/filecontent/UpdateFilePropsContainerScopeTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 org.labkey.test.tests.filecontent; + +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.SimplePostCommand; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.TestFileUtils; +import org.labkey.test.WebTestHelper; +import org.labkey.test.categories.Daily; +import org.labkey.test.categories.FileBrowser; +import org.labkey.test.components.DomainDesignerPage; +import org.labkey.test.util.PortalHelper; +import org.labkey.test.util.core.webdav.WebDavUtils; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@Category({Daily.class, FileBrowser.class}) +@BaseWebDriverTest.ClassTimeout(minutes = 4) +public class UpdateFilePropsContainerScopeTest extends BaseWebDriverTest +{ + private static final String FOLDER_A = "FolderA"; + private static final String FOLDER_B = "FolderB"; + private static final String CUSTOM_PROPERTY = "CustomProp"; + + @Override + protected @Nullable String getProjectName() + { + return getClass().getSimpleName() + "Project"; + } + + @Override + public List getAssociatedModules() + { + return Arrays.asList("filecontent"); + } + + @BeforeClass + public static void doSetup() + { + UpdateFilePropsContainerScopeTest init = getCurrentTest(); + init.doSetupSteps(); + } + + @Override + protected void doCleanup(boolean afterTest) + { + _containerHelper.deleteProject(getProjectName(), afterTest); + } + + private void doSetupSteps() + { + _containerHelper.createProject(getProjectName(), null); + _containerHelper.createSubfolder(getProjectName(), FOLDER_A); + _containerHelper.createSubfolder(getProjectName(), FOLDER_B); + + // FolderA needs a file properties domain so UpdateFilePropsAction's validation block runs. + navigateToFolder(getProjectName(), FOLDER_A); + new PortalHelper(this).doInAdminMode(p -> p.addWebPart("Files")); + DomainDesignerPage designer = _fileBrowserHelper.goToEditProperties(); + designer.fieldsPanel().addField(CUSTOM_PROPERTY); + designer.clickFinish(); + + navigateToFolder(getProjectName(), FOLDER_B); + new PortalHelper(this).doInAdminMode(p -> p.addWebPart("Files")); + } + + @Test + public void testForeignContainerFileRejected() throws Exception + { + final File localFile = TestFileUtils.getSampleData("security/InlineFile.html"); + navigateToFolder(getProjectName(), FOLDER_A); + _fileBrowserHelper.uploadFile(localFile); + String localFileUrl = WebDavUtils.buildBaseWebDavUrl(getProjectName() + "/" + FOLDER_A) + localFile.getName(); + + goToProjectHome(); + final File foreignFile = TestFileUtils.getSampleData("security/InlineFile2.html"); + navigateToFolder(getProjectName(), FOLDER_B); + _fileBrowserHelper.uploadFile(foreignFile); + String foreignFileUrl = WebDavUtils.buildBaseWebDavUrl(getProjectName() + "/" + FOLDER_B) + foreignFile.getName(); + + log("Same-container file id should be accepted."); + updateFileProps(FOLDER_A, localFileUrl, localFile.getName()); + + log("Foreign-container file id should be rejected."); + try + { + updateFileProps(FOLDER_A, foreignFileUrl, foreignFile.getName()); + fail("Expected rejection: UpdateFilePropsAction must refuse a file id resolving outside the current folder."); + } + catch (CommandException ex) + { + assertTrue("Expected 'Invalid file' in error message, got: " + ex.getMessage(), + ex.getMessage().contains("Invalid file")); + } + } + + private void updateFileProps(String folder, String fileId, String fileName) throws Exception + { + JSONObject entry = new JSONObject(); + entry.put("id", fileId); + entry.put("name", fileName); + entry.put(CUSTOM_PROPERTY, "value"); + JSONArray files = new JSONArray(); + files.put(entry); + JSONObject body = new JSONObject(); + body.put("files", files); + + SimplePostCommand cmd = new SimplePostCommand("filecontent", "updateFileProps"); + cmd.setJsonObject(body); + cmd.execute(WebTestHelper.getRemoteApiConnection(), getProjectName() + "/" + folder); + } + + @Override + public BrowserType bestBrowser() + { + return BrowserType.CHROME; + } +}