From 3fbde0307e1b8376f4c65dbc82a28fae02fcdaa6 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Sun, 7 Jun 2026 09:18:25 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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; + } +}