Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions cypress/component/DataSearch/dataset_search_table.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,99 @@ const datasets = [
}),
]

// Mock assembleFullQuery function that mimics the parent's behavior
const assembleFullQuery = (isSigningOfficial, isInstitutionQuery, subQuery) => {
const queryChunks = [
{
match: {
_type: 'dataset',
},
},
{
exists: {
field: 'study',
},
},
]

// Add visibility modifiers unless user is signing official viewing own institution
if (!isSigningOfficial || !isInstitutionQuery) {
const visibilityModifier = [
{
term: {
'study.publicVisibility': true,
},
},
{
bool: {
should: [
{
term: {
dacApproval: true,
},
},
{
term: {
accessManagement: 'open',
},
},
{
term: {
accessManagement: 'external',
},
},
],
},
},
]
queryChunks.push(...visibilityModifier)
}

if (subQuery !== null) {
queryChunks.push(subQuery)
}

return {
from: 0,
size: 10000,
query: {
bool: {
must: queryChunks,
},
},
}
}

const props = {
datasets: datasets,
assembleFullQuery: assembleFullQuery,
isSigningOfficial: false,
isInstitutionQuery: false,
}

describe('Dataset Search Table tests', () => {
describe('Initial search request optimization', () => {
it('Should not make redundant search request on initial mount', () => {
cy.initApplicationConfig()
cy.stub(TerraDataRepo, 'listSnapshotsByDatasetIds').returns({})
cy.clock()

// Spy on search endpoint to verify it's not called initially
let searchCallCount = 0
cy.intercept('POST', '**/search/index', () => {
searchCallCount++
}).as('searchSpy')

mount(<BrowserRouter><DatasetSearchTable {...props} /></BrowserRouter>)

// Wait for component to render
cy.get('button').contains('View By Studies').should('exist')

// Verify no initial search request was made
cy.wrap(searchCallCount).should('equal', 0)
})
})

describe('Data library with one dataset footer tests', () => {
beforeEach(() => {
cy.initApplicationConfig()
Expand Down Expand Up @@ -93,4 +181,96 @@ describe('Dataset Search Table tests', () => {
})
})
})

describe('Visibility modifier security tests', () => {
beforeEach(() => {
cy.initApplicationConfig()
cy.stub(TerraDataRepo, 'listSnapshotsByDatasetIds').returns({})
cy.clock()
})

it('Should include visibility modifiers in search requests', () => {
let capturedRequest = null
cy.intercept('POST', '**/search/index', (req) => {
capturedRequest = req.body
req.reply([])
}).as('searchIndex')

mount(<BrowserRouter><DatasetSearchTable {...props} /></BrowserRouter>)

// Trigger a search by typing in the search bar
cy.get('[data-cy="search-bar"]').type('test query')
cy.tick(150)

cy.wait('@searchIndex').then(() => {
// Verify the request includes visibility modifiers
const queryString = JSON.stringify(capturedRequest)

// Check for publicVisibility modifier
cy.wrap(queryString).should('include', '"study.publicVisibility":true')

// Check for access management modifiers (dacApproval, open, external)
cy.wrap(queryString).should('include', '"dacApproval":true')
cy.wrap(queryString).should('include', '"accessManagement":"open"')
cy.wrap(queryString).should('include', '"accessManagement":"external"')
})
})

it('Should include visibility modifiers when filters are applied', () => {
let capturedRequest = null
cy.intercept('POST', '**/search/index', (req) => {
capturedRequest = req.body
req.reply([])
}).as('searchIndex')

mount(<BrowserRouter><DatasetSearchTable {...props} /></BrowserRouter>)

// Apply a filter to trigger a search
cy.get('#participantCountMin-range-input').clear()
cy.get('#participantCountMin-range-input').type('10')
cy.tick(150)

cy.wait('@searchIndex').then(() => {
// Verify the request includes visibility modifiers even with filters
const queryString = JSON.stringify(capturedRequest)

cy.wrap(queryString).should('include', '"study.publicVisibility":true')
cy.wrap(queryString).should('include', '"dacApproval":true')

// Also verify the filter was applied
cy.wrap(queryString).should('include', '"participantCount":{"gte":10')
})
})

it('Should omit visibility modifiers for signing officials viewing own institution', () => {
const soProps = {
...props,
isSigningOfficial: true,
isInstitutionQuery: true,
}

let capturedRequest = null
cy.intercept('POST', '**/search/index', (req) => {
capturedRequest = req.body
req.reply([])
}).as('searchIndex')

mount(<BrowserRouter><DatasetSearchTable {...soProps} /></BrowserRouter>)

// Trigger a search
cy.get('[data-cy="search-bar"]').type('test')
cy.tick(150)

cy.wait('@searchIndex').then(() => {
const queryString = JSON.stringify(capturedRequest)

// Verify visibility modifiers are NOT included
cy.wrap(queryString).should('not.include', '"study.publicVisibility":true')
cy.wrap(queryString).should('not.include', '"dacApproval":true')

// But basic query structure should still be there
cy.wrap(queryString).should('include', '"_type":"dataset"')
})
})
})
})
95 changes: 49 additions & 46 deletions src/components/data_search/DatasetSearchTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ const styles = {

export const DatasetSearchTable = (props) => {
const navigate = useNavigate()
const { datasets, icon, title } = props
const { datasets, icon, title, assembleFullQuery: assembleBaseQuery, isSigningOfficial, isInstitutionQuery } = props
const [exportableDatasets, setExportableDatasets] = useState({})
const [filters, setFilters] = useState(defaultFilters(datasets))
const [filtered, setFiltered] = useState(datasets)
const [selected, setSelected] = useState([])
const [selectedTable, setSelectedTable] = useState(datasetSearchTableTabs.study)
const [searchTerm, setSearchTerm] = useState('')
const searchRef = useRef('')
const hasRunInitialSearch = useRef(false)

const isFilteredArray = (filter, category) => (filters[category]).indexOf(filter) > -1

Expand All @@ -66,46 +67,35 @@ export const DatasetSearchTable = (props) => {
}

const assembleFullQuery = () => {
const queryChunks = [
{
match: {
_type: 'dataset',
},
},
{
exists: {
field: 'study',
},
},
]

// do not apply search modifier if there is no search term
// Build search modifier if there is a search term
let searchModifier = null
if (searchTerm.length > 0) {
const searchModifier = [
{
multi_match: {
query: searchTerm,
type: 'phrase_prefix',
fields: [
'datasetName',
'dataLocation',
'study.description',
'study.studyName',
'study.species',
'study.piName',
'study.dataCustodianEmail',
'study.dataTypes',
'dataUse.primary.code',
'dataUse.secondary.code',
'dac.dacName',
'datasetIdentifier',
],
},
searchModifier = {
multi_match: {
query: searchTerm,
type: 'phrase_prefix',
fields: [
'datasetName',
'dataLocation',
'study.description',
'study.studyName',
'study.species',
'study.piName',
'study.dataCustodianEmail',
'study.dataTypes',
'dataUse.primary.code',
'dataUse.secondary.code',
'dac.dacName',
'datasetIdentifier',
],
},
]
queryChunks.push(...searchModifier)
}
}

// Get base query with visibility modifiers from parent
const baseQuery = assembleBaseQuery(isSigningOfficial, isInstitutionQuery, searchModifier)

// Add filter layer if any filters are selected
let filterQuery = {}
if (anyFiltersSelected(filters)) {
const filterTerms = []
Expand Down Expand Up @@ -174,17 +164,21 @@ export const DatasetSearchTable = (props) => {
}
}

return {
from: 0,
size: 10000,
query: {
bool: {
must: queryChunks,
// Only add filter subquery when filters are applied.
...(Object.keys(filterQuery).length > 0 && { filter: filterQuery }),
// Add filter layer to base query if filters are present
if (Object.keys(filterQuery).length > 0) {
return {
...baseQuery,
query: {
...baseQuery.query,
bool: {
...baseQuery.query.bool,
filter: filterQuery,
},
},
},
}
}

return baseQuery
}

const filterHandler = (category, filter) => {
Expand Down Expand Up @@ -223,6 +217,12 @@ export const DatasetSearchTable = (props) => {
const handleSearchChange = useCallback(searchTerms => setSearchTerm(searchTerms))

useEffect(() => {
if (!hasRunInitialSearch.current) {
hasRunInitialSearch.current = true
setFiltered(datasets)
return
}

const fullQuery = assembleFullQuery()
try {
searchAndFilter.current(fullQuery)
Expand All @@ -235,6 +235,9 @@ export const DatasetSearchTable = (props) => {
datasets: PropTypes.array.isRequired,
icon: PropTypes.string,
title: PropTypes.string,
assembleFullQuery: PropTypes.func.isRequired,
isSigningOfficial: PropTypes.bool.isRequired,
isInstitutionQuery: PropTypes.bool.isRequired,
}

return (
Expand Down
10 changes: 9 additions & 1 deletion src/pages/DatasetSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,15 @@
<CircularProgress />
</Box>
)
: <DatasetSearchTable {...props} datasets={datasets} icon={version.icon} title={version.title} />
: <DatasetSearchTable

Check failure on line 156 in src/pages/DatasetSearch.jsx

View workflow job for this annotation

GitHub Actions / lint

Missing parentheses around multilines JSX
{...props}
datasets={datasets}
icon={version.icon}
title={version.title}
assembleFullQuery={assembleFullQuery}
isSigningOfficial={isSigningOfficial}
isInstitutionQuery={isInstitutionQuery}
/>
)
}

Expand Down
Loading