diff --git a/data/studies/Study001/study/export/My Study_2025-11-06_12-53-44.folder.zip b/data/studies/Study001/study/export/My Study_2025-11-06_12-53-44.folder.zip new file mode 100644 index 0000000000..a8cbe715c0 Binary files /dev/null and b/data/studies/Study001/study/export/My Study_2025-11-06_12-53-44.folder.zip differ diff --git a/data/studies/Study001/study/export/My Study_2025-11-10_11-29-52.folder.zip b/data/studies/Study001/study/export/My Study_2025-11-10_11-29-52.folder.zip new file mode 100644 index 0000000000..1c0009727d Binary files /dev/null and b/data/studies/Study001/study/export/My Study_2025-11-10_11-29-52.folder.zip differ diff --git a/data/studies/Study001/study/export/My Study_2025-11-10_11-43-15.folder.zip b/data/studies/Study001/study/export/My Study_2025-11-10_11-43-15.folder.zip new file mode 100644 index 0000000000..c342e6a7ef Binary files /dev/null and b/data/studies/Study001/study/export/My Study_2025-11-10_11-43-15.folder.zip differ diff --git a/data/studies/Study001/study/export/data_states.xml b/data/studies/Study001/study/export/data_states.xml new file mode 100644 index 0000000000..f4f8840ee2 --- /dev/null +++ b/data/studies/Study001/study/export/data_states.xml @@ -0,0 +1,14 @@ + + + + + + + + true + true + false + First QC State + Second QC State + Third QC State + \ No newline at end of file diff --git a/data/studies/Study001/study/export/filebrowser_admin_config.xml b/data/studies/Study001/study/export/filebrowser_admin_config.xml new file mode 100644 index 0000000000..4f8c0c7bfa --- /dev/null +++ b/data/studies/Study001/study/export/filebrowser_admin_config.xml @@ -0,0 +1,6 @@ + + + true + useDefault + false + \ No newline at end of file diff --git a/data/studies/Study001/study/export/folder.xml b/data/studies/Study001/study/export/folder.xml new file mode 100644 index 0000000000..ab9f2341d8 --- /dev/null +++ b/data/studies/Study001/study/export/folder.xml @@ -0,0 +1,40 @@ + + + + + Core + + Wiki + StudyDesign + Specimen + Study + Issues + Search + FileContent + Assay + Experiment + Query + Pipeline + API + Announcements + Core + + + + + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/pages.xml b/data/studies/Study001/study/export/pages.xml new file mode 100644 index 0000000000..792b2af4b0 --- /dev/null +++ b/data/studies/Study001/study/export/pages.xml @@ -0,0 +1,60 @@ + + + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/queries/Hidden.query.xml b/data/studies/Study001/study/export/queries/Hidden.query.xml new file mode 100644 index 0000000000..103f65ddf5 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Hidden.query.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/queries/Hidden.sql b/data/studies/Study001/study/export/queries/Hidden.sql new file mode 100644 index 0000000000..64662866c1 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Hidden.sql @@ -0,0 +1,6 @@ +SELECT APIKeys.CreatedBy, +APIKeys.Created, +APIKeys.Expiration, +APIKeys.LastUsed, +APIKeys.Description +FROM APIKeys diff --git a/data/studies/Study001/study/export/queries/Inherit & Hidden.query.xml b/data/studies/Study001/study/export/queries/Inherit & Hidden.query.xml new file mode 100644 index 0000000000..d1718bc0ba --- /dev/null +++ b/data/studies/Study001/study/export/queries/Inherit & Hidden.query.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/queries/Inherit & Hidden.sql b/data/studies/Study001/study/export/queries/Inherit & Hidden.sql new file mode 100644 index 0000000000..64662866c1 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Inherit & Hidden.sql @@ -0,0 +1,6 @@ +SELECT APIKeys.CreatedBy, +APIKeys.Created, +APIKeys.Expiration, +APIKeys.LastUsed, +APIKeys.Description +FROM APIKeys diff --git a/data/studies/Study001/study/export/queries/Inherit.query.xml b/data/studies/Study001/study/export/queries/Inherit.query.xml new file mode 100644 index 0000000000..754f927566 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Inherit.query.xml @@ -0,0 +1,11 @@ + + + + + + + +
+
+
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/queries/Inherit.sql b/data/studies/Study001/study/export/queries/Inherit.sql new file mode 100644 index 0000000000..64662866c1 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Inherit.sql @@ -0,0 +1,6 @@ +SELECT APIKeys.CreatedBy, +APIKeys.Created, +APIKeys.Expiration, +APIKeys.LastUsed, +APIKeys.Description +FROM APIKeys diff --git a/data/studies/Study001/study/export/queries/Normal.query.xml b/data/studies/Study001/study/export/queries/Normal.query.xml new file mode 100644 index 0000000000..657e46e14b --- /dev/null +++ b/data/studies/Study001/study/export/queries/Normal.query.xml @@ -0,0 +1,11 @@ + + + + + + + +
+
+
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/queries/Normal.sql b/data/studies/Study001/study/export/queries/Normal.sql new file mode 100644 index 0000000000..64662866c1 --- /dev/null +++ b/data/studies/Study001/study/export/queries/Normal.sql @@ -0,0 +1,6 @@ +SELECT APIKeys.CreatedBy, +APIKeys.Created, +APIKeys.Expiration, +APIKeys.LastUsed, +APIKeys.Description +FROM APIKeys diff --git a/data/studies/Study001/study/export/study/assaySchedule/assayspecimen.tsv b/data/studies/Study001/study/export/study/assaySchedule/assayspecimen.tsv new file mode 100644 index 0000000000..44e055d5ea --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/assayspecimen.tsv @@ -0,0 +1 @@ +RowId AssayName Description LocationId DataSet Source TubeType Lab SampleType SampleQuantity SampleUnits diff --git a/data/studies/Study001/study/export/study/assaySchedule/assayspecimenvisit.tsv b/data/studies/Study001/study/export/study/assaySchedule/assayspecimenvisit.tsv new file mode 100644 index 0000000000..2280ed81f9 --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/assayspecimenvisit.tsv @@ -0,0 +1 @@ +RowId VisitId AssaySpecimenId Container Created CreatedBy Modified ModifiedBy visitId.sequenceNumMin diff --git a/data/studies/Study001/study/export/study/assaySchedule/studydesignassays.tsv b/data/studies/Study001/study/export/study/assaySchedule/studydesignassays.tsv new file mode 100644 index 0000000000..64baf08c64 --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/studydesignassays.tsv @@ -0,0 +1 @@ +Name Label Description Inactive Type Platform Category TargetFunction LeadContributor Contact Summary Keywords TargetType TargetSubtype Editorial AlternateName Lab LabPI diff --git a/data/studies/Study001/study/export/study/assaySchedule/studydesignlabs.tsv b/data/studies/Study001/study/export/study/assaySchedule/studydesignlabs.tsv new file mode 100644 index 0000000000..c7e127cc24 --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/studydesignlabs.tsv @@ -0,0 +1 @@ +Name Label Inactive PI Description Summary Institution diff --git a/data/studies/Study001/study/export/study/assaySchedule/studydesignsampletypes.tsv b/data/studies/Study001/study/export/study/assaySchedule/studydesignsampletypes.tsv new file mode 100644 index 0000000000..36af02138e --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/studydesignsampletypes.tsv @@ -0,0 +1 @@ +Name PrimaryType ShortSampleCode Inactive diff --git a/data/studies/Study001/study/export/study/assaySchedule/studydesignunits.tsv b/data/studies/Study001/study/export/study/assaySchedule/studydesignunits.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/assaySchedule/studydesignunits.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/cohorts.xml b/data/studies/Study001/study/export/study/cohorts.xml new file mode 100644 index 0000000000..243701b269 --- /dev/null +++ b/data/studies/Study001/study/export/study/cohorts.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/datasets/Manually.dataset b/data/studies/Study001/study/export/study/datasets/Manually.dataset new file mode 100644 index 0000000000..8e59702888 --- /dev/null +++ b/data/studies/Study001/study/export/study/datasets/Manually.dataset @@ -0,0 +1,19 @@ +# default group can be used to avoid repeating definitions for each dataset +# +# action=[REPLACE,APPEND,DELETE] (default:REPLACE) +# deleteAfterImport=[TRUE|FALSE] (default:FALSE) + +default.action=REPLACE +default.deleteAfterImport=FALSE + +# map a source tsv column (right side) to a property name or full propertyURI (left) +# predefined properties: ParticipantId, SiteId, VisitId, Created +default.property.ParticipantId=ptid +default.property.Created=dfcreate + +# use to map from filename->datasetid +# NOTE: if there are NO explicit import definitions, we will try to import all files matching pattern +# NOTE: if there are ANY explicit mapping, we will only import listed datasets + +default.filePattern=dataset(\\d*).tsv +default.importAllMatches=TRUE diff --git a/data/studies/Study001/study/export/study/datasets/Study.dataset b/data/studies/Study001/study/export/study/datasets/Study.dataset new file mode 100644 index 0000000000..8e59702888 --- /dev/null +++ b/data/studies/Study001/study/export/study/datasets/Study.dataset @@ -0,0 +1,19 @@ +# default group can be used to avoid repeating definitions for each dataset +# +# action=[REPLACE,APPEND,DELETE] (default:REPLACE) +# deleteAfterImport=[TRUE|FALSE] (default:FALSE) + +default.action=REPLACE +default.deleteAfterImport=FALSE + +# map a source tsv column (right side) to a property name or full propertyURI (left) +# predefined properties: ParticipantId, SiteId, VisitId, Created +default.property.ParticipantId=ptid +default.property.Created=dfcreate + +# use to map from filename->datasetid +# NOTE: if there are NO explicit import definitions, we will try to import all files matching pattern +# NOTE: if there are ANY explicit mapping, we will only import listed datasets + +default.filePattern=dataset(\\d*).tsv +default.importAllMatches=TRUE diff --git a/data/studies/Study001/study/export/study/datasets/dataset5001.tsv b/data/studies/Study001/study/export/study/datasets/dataset5001.tsv new file mode 100644 index 0000000000..2096ce34f5 --- /dev/null +++ b/data/studies/Study001/study/export/study/datasets/dataset5001.tsv @@ -0,0 +1,2 @@ +ParticipantId SequenceNum date QCStateLabel TestInt TestString TestDate TestDateTime Location +PTID123 1.0000 First QC State 999 ABC 2013-10-29 00:00:00.0 2013-10-28 01:23:00.0 diff --git a/data/studies/Study001/study/export/study/datasets/datasets_manifest.xml b/data/studies/Study001/study/export/study/datasets/datasets_manifest.xml new file mode 100644 index 0000000000..13d5faf969 --- /dev/null +++ b/data/studies/Study001/study/export/study/datasets/datasets_manifest.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/datasets/datasets_metadata.xml b/data/studies/Study001/study/export/study/datasets/datasets_metadata.xml new file mode 100644 index 0000000000..5aa30dd5dd --- /dev/null +++ b/data/studies/Study001/study/export/study/datasets/datasets_metadata.xml @@ -0,0 +1,93 @@ + + + + Contains up to one row of TestDataset data for each Participant/Visit combination. + + + varchar + Participant ID + Subject identifier + http://cpas.labkey.com/Study#ParticipantId + false + /study-participant.view?participantId=${ParticipantId} + + ptid + participantid + + + study + Participant + ParticipantId + + 32 + + + double + Sequence Num + http://cpas.labkey.com/Study#SequenceNum + false + + visit + visitid + + + + timestamp + Date + http://cpas.labkey.com/Study#VisitDate + Date + + + integer + TestInt + http://www.w3.org/2001/XMLSchema#int + FIXED_EDITABLE + + + numberValidator + numberValidator + urn:lsid:labkey.com:PropertyValidator:range + ~eq=999 + TestInt must equals '999'. + + failOnMatch + false + + + + + + varchar + TestRequiredString + http://www.w3.org/2001/XMLSchema#string + true + FIXED_EDITABLE + 4000 + + + timestamp + TestDate + http://www.w3.org/2001/XMLSchema#dateTime + FIXED_EDITABLE + + + timestamp + TestDateTime + http://www.w3.org/2001/XMLSchema#dateTime + FIXED_EDITABLE + + + integer + Location + http://www.w3.org/2001/XMLSchema#int + + study + Location + RowId + + FIXED_EDITABLE + + + TestDataset +
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/study/properties/objective.tsv b/data/studies/Study001/study/export/study/properties/objective.tsv new file mode 100644 index 0000000000..4651df66af --- /dev/null +++ b/data/studies/Study001/study/export/study/properties/objective.tsv @@ -0,0 +1 @@ +RowId Label Type Description DescriptionRendererType diff --git a/data/studies/Study001/study/export/study/properties/personnel.tsv b/data/studies/Study001/study/export/study/properties/personnel.tsv new file mode 100644 index 0000000000..d4e4a9bfed --- /dev/null +++ b/data/studies/Study001/study/export/study/properties/personnel.tsv @@ -0,0 +1 @@ +RowId Label Role URL UserId userId.displayName diff --git a/data/studies/Study001/study/export/study/properties/study.tsv b/data/studies/Study001/study/export/study/properties/study.tsv new file mode 100644 index 0000000000..742c786b73 --- /dev/null +++ b/data/studies/Study001/study/export/study/properties/study.tsv @@ -0,0 +1,3 @@ +Label StartDate EndDate TimepointType SubjectNounSingular SubjectNounPlural SubjectColumnName Grant Investigator Species ParticipantAliasDatasetId ParticipantAliasProperty ParticipantAliasSourceProperty AssayPlan Description DescriptionRendererType DateBased LSID cust_string cust_integer cust_dateTime cust_double cust_multiline +Study Properties 2025-11-10 00:00:00.0 VISIT Participant Participants ParticipantId Grantland Dr. Strangelove Human study description TEXT_WITH_LINKS false urn:lsid:labkey.com:Study.Folder-68:68 custom string 2 2014-03-26 00:00:00.0 3.14 "custom multiline +custom multiline" diff --git a/data/studies/Study001/study/export/study/properties/study_metadata.xml b/data/studies/Study001/study/export/study/properties/study_metadata.xml new file mode 100644 index 0000000000..dcf1fc8313 --- /dev/null +++ b/data/studies/Study001/study/export/study/properties/study_metadata.xml @@ -0,0 +1,45 @@ + + + + Contains one row per each study personnel + +
+ + Contains one row per study, describing basic and extended study properties. + + + varchar + cust_string + http://www.w3.org/2001/XMLSchema#string + FIXED_EDITABLE + 4000 + + + integer + cust_integer + http://www.w3.org/2001/XMLSchema#int + FIXED_EDITABLE + + + timestamp + cust_dateTime + http://www.w3.org/2001/XMLSchema#dateTime + FIXED_EDITABLE + + + double + cust_double + http://www.w3.org/2001/XMLSchema#double + FIXED_EDITABLE + + + varchar + textarea + cust_multiline + http://www.w3.org/2001/XMLSchema#multiLine + FIXED_EDITABLE + 4000 + + +
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/study/security_policy.xml b/data/studies/Study001/study/export/study/security_policy.xml new file mode 100644 index 0000000000..a6dd7ec149 --- /dev/null +++ b/data/studies/Study001/study/export/study/security_policy.xml @@ -0,0 +1,9 @@ + + + BASIC_READ + + + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/specimens/Manually.specimens b/data/studies/Study001/study/export/study/specimens/Manually.specimens new file mode 100644 index 0000000000..f9e67a486c Binary files /dev/null and b/data/studies/Study001/study/export/study/specimens/Manually.specimens differ diff --git a/data/studies/Study001/study/export/study/specimens/Study.specimens b/data/studies/Study001/study/export/study/specimens/Study.specimens new file mode 100644 index 0000000000..7d8e67180b Binary files /dev/null and b/data/studies/Study001/study/export/study/specimens/Study.specimens differ diff --git a/data/studies/Study001/study/export/study/specimens/specimen_settings.xml b/data/studies/Study001/study/export/study/specimens/specimen_settings.xml new file mode 100644 index 0000000000..17c3f9e5fe --- /dev/null +++ b/data/studies/Study001/study/export/study/specimens/specimen_settings.xml @@ -0,0 +1,60 @@ + + + + + Primary Type + Derivative Type + Additive Type + + + Derivative Type + Additive Type + + + + + + + + + + + + + + +
+ Assay Plan-1 + Please enter a description of or reference to the assay plan(s) that will be used for the requested specimens. +
+
+ Shipping Information + Please enter your shipping address along with any special instructions. +
+
+ Comments + Please enter any additional information regarding your request. +
+
+ Request #4 + Request help text #4 +
+
+ Request #5 + Request help text #5 +
+
+ + specimen-test@simpleexport.test + specimen-test@simpleexport.test + Specimen Request Notification + None + ExcelAttachment + + + + + + + +
\ No newline at end of file diff --git a/data/studies/Study001/study/export/study/specimens/specimens_metadata.xml b/data/studies/Study001/study/export/study/specimens/specimens_metadata.xml new file mode 100644 index 0000000000..a33ef28389 --- /dev/null +++ b/data/studies/Study001/study/export/study/specimens/specimens_metadata.xml @@ -0,0 +1,753 @@ + + + + + + bigint + Row Id + false + false + false + + + varchar + Specimen Hash + A string value that uniquely identifies the specimen/draw-level properties of the specimen, including subject, visit, and specimen type. + 256 + + + varchar + PTID + The ID of the subject providing each specimen. + 32 + + + varchar + Visit Description + A description of the visit type for each specimen. For example, "visit", "day", or "week". + 10 + + + double + Visit Value + The visit value (sequence num) of specimen collection. + 0.#### + + + varchar + Volume Units + The units of volume for each specimen. + 20 + + + integer + Primary Type Id + The ID of the primary type of each specimen. + + + integer + Additive Type Id + The ID of the additive type of each specimen. + + + integer + Derivative Type Id + The ID of the derivative type of each specimen. + + + integer + Derivative Type Id2 + The ID of the second derivative type of each specimen. + + + varchar + Subadditive Derivative + The sub-additive/derivative type of each specimen. + 50 + + + timestamp + Draw Timestamp + The timestamp of specimen collection. + DateTime + + + timestamp + Draw Date + The date of specimen collection. + Date + + + time + Draw Time + The time of specimen collection. + Time + + + timestamp + Sal Receipt Date + The timestamp of specimen receipt at the site-affiliated laboratory. + + + varchar + Class Id + The class, network, or study associated with each specimen. + 20 + + + varchar + Protocol Number + The protocol associated with each specimen. + 20 + + + integer + Originating Location Id + The ID of the originating (collection) location of each specimen. + + + double + Total Volume + The total volume of all vials from each specimen. + + + double + Available Volume + The total volume of all vials from each specimen that are currently available for request. + + + integer + Vial Count + The total number of vials from each specimen. + + + integer + Locked In Request Count + The number of vials from each specimen that are currently locked in requests. + + + integer + At Repository Count + The number of vials from each specimen that are currently at a repository. + + + integer + Available Count + The number of vials from each specimen that are currently available for request. + + + integer + Expected Available Count + The total volume of all vials from each specimen that are expected to become available for request. This includes vials currently available as well as those not available but not yet requested. + + + varchar + Participant Sequence Num + A string value uniquely identifying the subject/visit combination for each specimen row. + 200 + + + integer + Processing Location + The ID of the processing location of each vial. + + + varchar + First Processed By Initials + The common FirstProcessedByInitials value for all vials in this specimen. Will be null if all vials do not share a common FirstProcessedByInitials values. + 32 + + +
+ + + + bigint + Row Id + The unique ID of each event. + false + false + false + + + bigint + Vial Id + The ID of the vial. + false + + + integer + Lab Id + The ID of the location associated with each event. + + + varchar + Unique Specimen Id + The unique specimen ID provided by the LIMS at the storage location. + 50 + + + integer + Parent Specimen Id + The parent specimen ID provided by the LIMS at the storage location. + + + integer + Stored + The location-specific storage code. + + + integer + Storage Flag + The location-specific storage flag. + + + timestamp + Storage Date + The date/time of vial storage. + + + integer + Ship Flag + The location-specific shipment flag. + + + integer + Ship Batch Number + The shipment batch number, if the vial/specimen has left this location. + + + timestamp + Ship Date + The date/time of shipment, if the vial has left this location. + + + integer + Imported Batch Number + The ID of the import batch that included this vial/specimen. + + + timestamp + Lab Receipt Date + The date/time of vial receipt. + + + varchar + textarea + Comments + Location-specific comments associated with each vial. + 500 + + + varchar + Specimen Condition + The condition code for the vial. + 30 + + + integer + Sample Number + A text identifier of the collection/draw. This value may vary by location/LIMS, so it should not be used to group events cross-location. + + + varchar + Xsample Origin + The LIMS-provided sample origin. + 50 + + + varchar + External Location + The LIMS-provided external location. + 50 + + + timestamp + Update Timestamp + The date/time of last record modification in the external LIMS. + DateTime + + + varchar + Other Specimen Id + Alternate specimen ID, if needed. + 50 + + + varchar + Expected Time Unit + The LIMS-provided expected time units. + 15 + + + double + Expected Time Value + The LIMS-provided expected time value. + + + integer + Group Protocol + The group/protocol of each vial. + + + varchar + Record Source + The LIMS system responsible for each event. Generally used when multiple LIMS (LDMS and Labware, for example) are providing loading data into a single repository. + 20 + + + varchar + Specimen Number + A text identifier of the collection/draw. This value may vary by location/LIMS, so it should not be used to group events cross-location. + 50 + + + bigint + External Id + The external identifier for each event, if imported from an external data source. + + + varchar + Shipped From Lab + The date/time each vial was shipped from the lab. + 32 + + + varchar + Shipped To Lab + The date/time each vial was shipped to the lab. + 32 + + + varchar + PTID + The ID of the subject providing each specimen. + 32 + + + timestamp + Draw Timestamp + The timestamp of specimen collection. + DateTime + + + timestamp + Sal Receipt Date + The timestamp of specimen receipt at the site-affiliated laboratory. + + + varchar + Class Id + The class, network, or study associated with each specimen. + 20 + + + double + Visit Value + The visit value (sequence num) of specimen collection. + + + varchar + Protocol Number + The protocol associated with each specimen. + 20 + + + varchar + Visit Description + A description of the visit type for each specimen. For example, "visit", "day", or "week". + 10 + + + double + Volume + The volume of each vial. + + + varchar + Volume Units + The units of volume for each specimen. + 20 + + + varchar + Subadditive Derivative + The sub-additive/derivative type of each specimen. + 50 + + + integer + Primary Type Id + The ID of the primary type of each specimen. + + + integer + Additive Type Id + The ID of the additive type of each specimen. + + + integer + Derivative Type Id + The ID of the derivative type of each specimen. + + + integer + Derivative Type Id2 + The ID of the second derivative type of each specimen. + + + integer + Originating Location Id + The ID of the originating (collection) location of each specimen. + + + timestamp + Frozen Time + The duration of the freezing process. + H:mm:ss + + + timestamp + Processing Time + The duration of the processing process. + H:mm:ss + + + double + Primary Volume + The primary/original volume of each vial. This value is passed through from the specimen data feed, so the provided data determines whether this is primary volume per vial or primary volume per collection/draw. + + + varchar + Primary Volume Units + The units of the primary volume value of each vial. For example, cells or milliliters. + 20 + + + varchar + Processed By Initials + The initials of the vial's processor. + 32 + + + timestamp + Processing Date + The date/time of vial processing. + + + double + Total Cell Count + The total cell count for each vial. + 0.#### + + + varchar + textarea + Quality Comments + 500 + + + varchar + Input Hash + 16 + + + boolean + Obsolete + Indicates the event precedes the last edit of the vial. + false + + + varchar + Tube Type + The type of vial. + 64 + + + varchar + Deviation Code1 + Site-defined deviation code. + http://www.w3.org/2001/XMLSchema#string + + deviation_code1 + + 50 + + + varchar + Deviation Code2 + Site-defined deviation code. + http://www.w3.org/2001/XMLSchema#string + + deviation_code2 + + 50 + + + varchar + Deviation Code3 + Site-defined deviation code. + http://www.w3.org/2001/XMLSchema#string + + deviation_code3 + + 50 + + + float + Concentration + Processing concentration value. + http://www.w3.org/2001/XMLSchema#float + + + float + Integrity + Processing integrity value. + http://www.w3.org/2001/XMLSchema#float + + + float + Ratio + Processing ratio value. + http://www.w3.org/2001/XMLSchema#float + + + float + Yield + Processing yield value. + http://www.w3.org/2001/XMLSchema#float + + + varchar + Freezer + The ID of the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Container + The container location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Position + The storage position within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Level1 + The level 1 location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Level2 + The level 2 location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + +
+ + + + bigint + Row Id + false + + + varchar + Global Unique Id + The global unique ID of each vial. Assumed to be globally unique within a study, but duplicates can exist cross-study. + false + 50 + + + double + Volume + The volume of each vial. + #,###.0# + + + varchar + Specimen Hash + A string value that uniquely identifies the specimen/draw-level properties of the vial, including subject, visit, and specimen type. + 256 + + + boolean + Requestable + Whether or not each vial should be requestable. If null, requestability will be determined by whether the vial is at a repository or locked in another request. If true/false, repository calculation is overridden, and only locked status will be considered. + + + integer + Current Location + The ID of the current location of the vial. Current location is determined by the most recent event for each vial, and may be null if the most recent specimen event indicates that the vial has been shipped. + + + boolean + At Repository + Whether each vial is currently at a repository. + false + + + boolean + Locked In Request + Whether each vial is currently locked in a specimen request. + false + + + boolean + Available + Whether each vial is currently available for request. Calculated from the Requestable, AtRepository, and LockedInRequest columns. + false + + + integer + Processing Location + The ID of the processing location of each vial. + + + bigint + Specimen Id + The ID of the primary specimen associated with each vial. + false + + + double + Primary Volume + The primary/original volume of each vial. This value is passed through from the specimen data feed, so the provided data determines whether this is primary volume per vial or primary volume per collection/draw. + + + varchar + Primary Volume Units + The units of the primary volume value of each vial. For example, cells or milliliters. + 20 + + + varchar + First Processed By Initials + The value of the ProcessedByInitials from the first recorded event for this vial. + 32 + + + varchar + Availability Reason + Text explanation for why each vial is or is not available for request. May be null if the vial is available and no special circumstances apply. + 256 + + + double + Total Cell Count + The total cell count for each vial. + 0.#### + + + varchar + textarea + Latest Comments + Additional comments on processing quality from latest event. + 500 + + + varchar + textarea + Latest Quality Comments + Additional comments on processing quality from latest event. + 500 + + + varchar + Tube Type + The type of vial. + 64 + + + varchar + Latest Deviation Code1 + Site-defined deviation code from latest event. + http://www.w3.org/2001/XMLSchema#string + 50 + + + varchar + Latest Deviation Code2 + Site-defined deviation code from latest event. + http://www.w3.org/2001/XMLSchema#string + 50 + + + varchar + Latest Deviation Code3 + Site-defined deviation code from latest event. + http://www.w3.org/2001/XMLSchema#string + 50 + + + float + Latest Concentration + Processing concentration value from latest event. + http://www.w3.org/2001/XMLSchema#float + + + float + Latest Integrity + Processing integrity value from latest event. + http://www.w3.org/2001/XMLSchema#float + + + float + Latest Ratio + Processing ratio value from latest event. + http://www.w3.org/2001/XMLSchema#float + + + float + Latest Yield + Processing yield value from latest event. + http://www.w3.org/2001/XMLSchema#float + + + varchar + Freezer + The ID of the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Container + The container location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Position + The storage position within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Level1 + The level 1 location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + + varchar + Fr Level2 + The level 2 location within the storage freezer. + http://www.w3.org/2001/XMLSchema#string + 200 + + +
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/study/study.xml b/data/studies/Study001/study/export/study/study.xml new file mode 100644 index 0000000000..d28b2ff127 --- /dev/null +++ b/data/studies/Study001/study/export/study/study.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + study description + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/treatmentData/doseandroute.tsv b/data/studies/Study001/study/export/study/treatmentData/doseandroute.tsv new file mode 100644 index 0000000000..7519f3b7b9 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/doseandroute.tsv @@ -0,0 +1 @@ +RowId Dose Route ProductId Label diff --git a/data/studies/Study001/study/export/study/treatmentData/product.tsv b/data/studies/Study001/study/export/study/treatmentData/product.tsv new file mode 100644 index 0000000000..06686bc056 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/product.tsv @@ -0,0 +1 @@ +RowId Label Role Type diff --git a/data/studies/Study001/study/export/study/treatmentData/productantigen.tsv b/data/studies/Study001/study/export/study/treatmentData/productantigen.tsv new file mode 100644 index 0000000000..6414cb686d --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/productantigen.tsv @@ -0,0 +1 @@ +RowId ProductId Gene SubType GenBankId Sequence diff --git a/data/studies/Study001/study/export/study/treatmentData/studydesignchallengetypes.tsv b/data/studies/Study001/study/export/study/treatmentData/studydesignchallengetypes.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/studydesignchallengetypes.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/treatmentData/studydesigngenes.tsv b/data/studies/Study001/study/export/study/treatmentData/studydesigngenes.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/studydesigngenes.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/treatmentData/studydesignimmunogentypes.tsv b/data/studies/Study001/study/export/study/treatmentData/studydesignimmunogentypes.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/studydesignimmunogentypes.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/treatmentData/studydesignroutes.tsv b/data/studies/Study001/study/export/study/treatmentData/studydesignroutes.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/studydesignroutes.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/treatmentData/studydesignsubtypes.tsv b/data/studies/Study001/study/export/study/treatmentData/studydesignsubtypes.tsv new file mode 100644 index 0000000000..46b4c2ec02 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/studydesignsubtypes.tsv @@ -0,0 +1 @@ +Name Label Inactive diff --git a/data/studies/Study001/study/export/study/treatmentData/treatment.tsv b/data/studies/Study001/study/export/study/treatmentData/treatment.tsv new file mode 100644 index 0000000000..0fd7180ac1 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/treatment.tsv @@ -0,0 +1 @@ +RowId Label Description DescriptionRendererType diff --git a/data/studies/Study001/study/export/study/treatmentData/treatment_metadata.xml b/data/studies/Study001/study/export/study/treatmentData/treatment_metadata.xml new file mode 100644 index 0000000000..6e395992ac --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/treatment_metadata.xml @@ -0,0 +1,19 @@ + + + + Contains one row per study treatment + +
+ + Contains one row per study product + +
+ + Contains one row per study treatment product + +
+ + Contains one row per study product antigen + +
+
\ No newline at end of file diff --git a/data/studies/Study001/study/export/study/treatmentData/treatmentproductmap.tsv b/data/studies/Study001/study/export/study/treatmentData/treatmentproductmap.tsv new file mode 100644 index 0000000000..c9ef022b5e --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/treatmentproductmap.tsv @@ -0,0 +1 @@ +RowId TreatmentId ProductId Dose Route diff --git a/data/studies/Study001/study/export/study/treatmentData/treatmentvisitmap.tsv b/data/studies/Study001/study/export/study/treatmentData/treatmentvisitmap.tsv new file mode 100644 index 0000000000..d5a9b3f417 --- /dev/null +++ b/data/studies/Study001/study/export/study/treatmentData/treatmentvisitmap.tsv @@ -0,0 +1 @@ +CohortId TreatmentId VisitId Container cohortId.label visitId.sequenceNumMin diff --git a/data/studies/Study001/study/export/study/views/participant.html b/data/studies/Study001/study/export/study/views/participant.html new file mode 100644 index 0000000000..2373cfddfa --- /dev/null +++ b/data/studies/Study001/study/export/study/views/participant.html @@ -0,0 +1 @@ +This is my custom participant view \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/views/settings.xml b/data/studies/Study001/study/export/study/views/settings.xml new file mode 100644 index 0000000000..8dd463225d --- /dev/null +++ b/data/studies/Study001/study/export/study/views/settings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/study/visit_map.xml b/data/studies/Study001/study/export/study/visit_map.xml new file mode 100644 index 0000000000..3c6e8bc99e --- /dev/null +++ b/data/studies/Study001/study/export/study/visit_map.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/studies/Study001/study/export/wikis/wikis.xml b/data/studies/Study001/study/export/wikis/wikis.xml new file mode 100644 index 0000000000..ec9f9190df --- /dev/null +++ b/data/studies/Study001/study/export/wikis/wikis.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/org/labkey/test/tests/AttachmentFieldTest.java b/src/org/labkey/test/tests/AttachmentFieldTest.java index dfe316e6f7..b643476362 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; @@ -10,24 +11,35 @@ 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.pages.ReactAssayDesignerPage; import org.labkey.test.pages.admin.FileRootsManagementPage; import org.labkey.test.pages.experiment.UpdateSampleTypePage; import org.labkey.test.pages.list.EditListDefinitionPage; 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); + } + @Before public void preTest() { @@ -191,4 +211,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/SpecimenTest.java b/src/org/labkey/test/tests/SpecimenTest.java index 6dea72c0af..47d78cc623 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/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/elispotassay/ElispotAssayTest.java b/src/org/labkey/test/tests/elispotassay/ElispotAssayTest.java index 0026867d17..93488884ec 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; @@ -40,15 +45,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; @@ -521,10 +530,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/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); + } +} 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; + } +} diff --git a/src/org/labkey/test/tests/nab/NabAssayTest.java b/src/org/labkey/test/tests/nab/NabAssayTest.java index b064647619..97a2c81f46 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; @@ -41,13 +48,18 @@ import org.labkey.test.pages.query.SourceQueryPage; import org.labkey.test.params.FieldDefinition; 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; @@ -55,6 +67,7 @@ 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.Date; @@ -64,6 +77,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.labkey.test.util.PermissionsHelper.READER_ROLE; @Category({Daily.class, Assays.class}) @@ -80,6 +94,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); @@ -178,6 +196,8 @@ protected void doCleanup(boolean afterTest) throws TestTimeoutException { super.doCleanup(afterTest); + _userHelper.deleteUsers(false, TEST_ASSAY_USR_NAB_SCOPE); + try { new QCAssayScriptHelper(this).deleteEngine(); @@ -373,6 +393,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")); @@ -409,6 +436,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)); @@ -515,6 +545,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() {