1616
1717package org .labkey .test .tests .nab ;
1818
19+ import org .jetbrains .annotations .Nullable ;
1920import org .junit .BeforeClass ;
2021import org .junit .Test ;
2122import org .junit .experimental .categories .Category ;
23+ import org .labkey .remoteapi .CommandException ;
24+ import org .labkey .remoteapi .query .ContainerFilter ;
25+ import org .labkey .remoteapi .query .Filter ;
26+ import org .labkey .remoteapi .query .SelectRowsCommand ;
27+ import org .labkey .remoteapi .query .SelectRowsResponse ;
2228import org .labkey .test .BaseWebDriverTest ;
2329import org .labkey .test .Locator ;
2430import org .labkey .test .Locators ;
3743import org .labkey .test .pages .query .NewQueryPage ;
3844import org .labkey .test .pages .query .SourceQueryPage ;
3945import org .labkey .test .tests .AbstractAssayTest ;
46+ import org .labkey .test .util .APIAssayHelper ;
47+ import org .labkey .test .util .ApiPermissionsHelper ;
4048import org .labkey .test .util .AssayImportOptions ;
4149import org .labkey .test .util .AssayImporter ;
4250import org .labkey .test .util .DataRegionTable ;
4351import org .labkey .test .util .DilutionAssayHelper ;
4452import org .labkey .test .util .LogMethod ;
53+ import org .labkey .test .util .PermissionsHelper ;
4554import org .labkey .test .util .PortalHelper ;
4655import org .labkey .test .util .QCAssayScriptHelper ;
56+ import org .labkey .test .util .SimpleHttpRequest ;
57+ import org .labkey .test .util .SimpleHttpResponse ;
4758import org .labkey .test .util .TestLogger ;
4859import org .labkey .test .util .WikiHelper ;
4960import org .openqa .selenium .WebDriverException ;
5061import org .openqa .selenium .WebElement ;
5162import org .openqa .selenium .support .ui .ExpectedConditions ;
5263
5364import java .io .File ;
65+ import java .io .IOException ;
5466import java .util .ArrayList ;
5567import java .util .Arrays ;
5668import java .util .List ;
69+ import java .util .Map ;
5770
5871import static org .junit .Assert .assertEquals ;
5972import static org .junit .Assert .assertFalse ;
@@ -73,6 +86,10 @@ public class NabAssayTest extends AbstractAssayTest
7386 protected final static String TEST_ASSAY_USR_NAB_READER =
"[email protected] " ;
7487 private final static String TEST_ASSAY_GRP_NAB_READER = "Nab Dataset Reader" ;
7588
89+ // Container-scoping fixtures (GitHub Issue #1892, NAB-1/2/8/9): a "bystander" folder and a user privileged only there.
90+ private final static String TEST_ASSAY_FLDR_NAB_SCOPE = "NabScopeBystanderFolder" ;
91+ private final static String TEST_ASSAY_USR_NAB_SCOPE =
"[email protected] " ;
92+
7693 private static final String NAB_FILENAME2 = "m0902053;3999.xls" ;
7794 protected final File TEST_ASSAY_NAB_FILE1 = TestFileUtils .getSampleData ("Nab/m0902051;3997.xls" );
7895 protected final File TEST_ASSAY_NAB_FILE2 = TestFileUtils .getSampleData ("Nab/" + NAB_FILENAME2 );
@@ -171,6 +188,8 @@ protected void doCleanup(boolean afterTest) throws TestTimeoutException
171188 {
172189 super .doCleanup (afterTest );
173190
191+ _userHelper .deleteUsers (false , TEST_ASSAY_USR_NAB_SCOPE );
192+
174193 try
175194 {
176195 new QCAssayScriptHelper (this ).deleteEngine ();
@@ -383,6 +402,9 @@ public void runUITests()
383402 startSystemMaintenance ("Database" );
384403 waitForSystemMaintenanceCompletion ();
385404
405+ // Verify cross-container access control for the run/specimen-resolving actions (NAB-1/2/8/9) while the imported runs are still present.
406+ verifyContainerScopedAccessControl ();
407+
386408 // Return to the run list
387409 navigateToFolder (getProjectName (), TEST_ASSAY_FLDR_NAB );
388410 clickAndWait (Locator .linkWithText (TEST_ASSAY_NAB ));
@@ -489,6 +511,94 @@ public void runUITests()
489511 runNabQCTest ();
490512 }
491513
514+ /**
515+ * GitHub Issue #1892: Selenium coverage for the NAb container-scoping fixes (NAB-1, NAB-2, NAB-8, NAB-9). Each of these
516+ * actions resolves a run (by global rowId) or a NAb specimen object id (resolved to its run by a global, cross-container
517+ * lookup) without an intrinsic container check. A user privileged only in a bystander folder must not be able to reach a
518+ * run living in the (foreign) assay folder by pointing one of these actions at its row/object id while scoping the request
519+ * to the bystander folder. We capture the ids as the admin, then issue the requests as an impersonated bystander Editor.
520+ */
521+ @ LogMethod
522+ private void verifyContainerScopedAccessControl ()
523+ {
524+ // Capture a protocol id, a run rowId, and a NAb specimen object id from the (foreign) assay folder — done as the admin, before impersonating.
525+ int protocolId = ((APIAssayHelper ) _assayHelper ).getIdFromAssayName (TEST_ASSAY_NAB , "/" + getProjectName ());
526+ int runId = firstRowId ("Runs" , null );
527+ int objectId = firstRowId ("Data" , null );
528+ assertTrue ("Expected an imported NAb run and specimen to scope against" , runId > 0 && objectId > 0 );
529+
530+ // A user who is an Editor (read + delete) in the bystander folder only — no access to the assay folder where the run lives.
531+ _containerHelper .createSubfolder (getProjectName (), TEST_ASSAY_FLDR_NAB_SCOPE );
532+ String bystanderPath = getProjectName () + "/" + TEST_ASSAY_FLDR_NAB_SCOPE ;
533+ _userHelper .createUser (TEST_ASSAY_USR_NAB_SCOPE );
534+ new ApiPermissionsHelper (this ).addMemberToRole (TEST_ASSAY_USR_NAB_SCOPE , "Editor" , PermissionsHelper .MemberType .user , "/" + bystanderPath );
535+
536+ impersonate (TEST_ASSAY_USR_NAB_SCOPE );
537+ try
538+ {
539+ // NAB-2: DownloadDatafileAction resolves the run by global rowId.
540+ assertForeignContainerRejected ("downloadDatafile (NAB-2)" ,
541+ WebTestHelper .buildURL ("nabassay" , bystanderPath , "downloadDatafile" , Map .of ("rowId" , String .valueOf (runId ))), "GET" );
542+
543+ // NAB-8: NabMultiGraphAction -> MultiGraphAction.getView resolves the object ids to runs.
544+ assertForeignContainerRejected ("nabMultiGraph (NAB-8)" ,
545+ WebTestHelper .buildURL ("nabassay" , bystanderPath , "nabMultiGraph" , Map .of ("protocolId" , String .valueOf (protocolId ), "id" , String .valueOf (objectId ))), "GET" );
546+
547+ // NAB-9: NabGraphSelectedAction -> GraphSelectedAction.getView resolves the object ids to runs.
548+ assertForeignContainerRejected ("nabGraphSelected (NAB-9)" ,
549+ WebTestHelper .buildURL ("nabassay" , bystanderPath , "nabGraphSelected" , Map .of ("protocolId" , String .valueOf (protocolId ), "id" , String .valueOf (objectId ))), "GET" );
550+
551+ // NAB-1: DeleteRunAction resolves the run by global rowId; this action is a POST.
552+ assertForeignContainerRejected ("deleteRun (NAB-1)" ,
553+ WebTestHelper .buildURL ("nabassay" , bystanderPath , "deleteRun" , Map .of ("rowId" , String .valueOf (runId ))), "POST" );
554+ }
555+ finally
556+ {
557+ stopImpersonating ();
558+ }
559+
560+ // The run must survive the rejected cross-container delete attempt.
561+ assertEquals ("Foreign-container delete must not remove the run" , runId , firstRowId ("Runs" , List .of (new Filter ("RowId" , runId ))));
562+ }
563+
564+ /** Fetch the RowId of the first row of an assay.NAb query across the project's subfolders, as the admin. Returns -1 if none. */
565+ private int firstRowId (String queryName , @ Nullable List <Filter > filters )
566+ {
567+ SelectRowsCommand command = new SelectRowsCommand ("assay.NAb." + TEST_ASSAY_NAB , queryName );
568+ command .setColumns (List .of ("RowId" ));
569+ command .setContainerFilter (ContainerFilter .CurrentAndSubfolders );
570+ if (filters != null )
571+ command .setFilters (filters );
572+ try
573+ {
574+ SelectRowsResponse response = command .execute (createDefaultConnection (), "/" + getProjectName ());
575+ return response .getRows ().isEmpty () ? -1 : ((Number ) response .getRows ().get (0 ).get ("RowId" )).intValue ();
576+ }
577+ catch (IOException | CommandException e )
578+ {
579+ throw new RuntimeException (e );
580+ }
581+ }
582+
583+ private void assertForeignContainerRejected (String description , String url , String requestMethod )
584+ {
585+ SimpleHttpRequest request = new SimpleHttpRequest (url , requestMethod );
586+ request .copySession (getDriver ()); // execute as the impersonated bystander user (carries CSRF token for the POST)
587+ request .clearLogin (); // rely solely on the impersonated session, not admin basic-auth
588+ SimpleHttpResponse response ;
589+ try
590+ {
591+ response = request .getResponse ();
592+ }
593+ catch (IOException e )
594+ {
595+ throw new RuntimeException (e );
596+ }
597+ assertEquals ("Foreign-container request should be rejected with 404: " + description , 404 , response .getResponseCode ());
598+ assertTrue ("Foreign-container rejection for " + description + " should report the resource does not exist, was: " + response .getResponseBody (),
599+ response .getResponseBody ().contains ("exist" ));
600+ }
601+
492602 //Issue 17050: UnsupportedOperationException from org.labkey.nab.query.NabProtocolSchema$NabResultsQueryView.createDataView
493603 private void directBrowserQueryTest ()
494604 {
0 commit comments