diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/identifier_token.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/identifier_token.vue index c1c457df77795bc6ac8ace1173cd694693324e87..f328341880c4b2ef5d74e31462525fc342a3de26 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/identifier_token.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/identifier_token.vue @@ -2,7 +2,8 @@ import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; -import identifiersQuery from 'ee/security_dashboard/graphql/queries/project_identifiers.query.graphql'; +import projectIdentifiersQuery from 'ee/security_dashboard/graphql/queries/project_identifiers.query.graphql'; +import groupIdentifiersQuery from 'ee/security_dashboard/graphql/queries/group_identifiers.query.graphql'; import SearchSuggestion from '../components/search_suggestion.vue'; import QuerystringSync from '../../filters/querystring_sync.vue'; import eventHub from '../event_hub'; @@ -16,7 +17,14 @@ export default { QuerystringSync, SearchSuggestion, }, - inject: ['projectFullPath'], + inject: { + projectFullPath: { + default: '', + }, + groupFullPath: { + default: '', + }, + }, props: { config: { type: Object, @@ -34,16 +42,18 @@ export default { }, apollo: { identifiers: { - query: identifiersQuery, + query() { + return this.queryConfig.query || ''; + }, debounce: 300, variables() { return { searchTerm: this.searchTerm, - fullPath: this.projectFullPath, + fullPath: this.queryConfig.fullPath || '', }; }, update(data) { - return data.project?.vulnerabilityIdentifierSearch || []; + return data?.[this.queryConfig?.dataPath].vulnerabilityIdentifierSearch || []; }, result() { this.isLoadingIdentifiers = false; @@ -71,6 +81,24 @@ export default { }; }, computed: { + queryConfig() { + const namespaceType = this.groupFullPath ? 'group' : 'project'; + + const queryTypes = { + group: { + query: groupIdentifiersQuery, + fullPath: this.groupFullPath, + dataPath: 'group', + }, + project: { + query: projectIdentifiersQuery, + fullPath: this.projectFullPath, + dataPath: 'project', + }, + }; + + return queryTypes[namespaceType]; + }, queryStringValue() { return this.selectedIdentifier ? [this.selectedIdentifier] : []; }, diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue index 23ad033ad59b1d5659a23e4cc2b13e81faca622d..c44b7d065270219ed21d06aca7ac98c82620b0db 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue @@ -26,6 +26,9 @@ export default { toolFilterType: { default: 'scanner', // scanner or reportType }, + projectFullPath: { + default: '', + }, }, props: { availableFilters: { @@ -55,7 +58,9 @@ export default { // This `includes` part is necessary because we don't include this filter everywhere. if (this.availableFilters.includes(FILTERS.IDENTIFIER)) { - nonDefaultTypes.push('identifier'); + if (this.projectFullPath || this.glFeatures?.vulnerabilityFilteringByIdentifierGroup) { + nonDefaultTypes.push('identifier'); + } } nonDefaultTypes.forEach((type) => { @@ -112,7 +117,11 @@ export default { case FILTERS.PROJECT: return PROJECT_TOKEN_DEFINITION; case FILTERS.IDENTIFIER: - return IDENTIFIER_TOKEN_DEFINITION; + if (this.projectFullPath || this.glFeatures?.vulnerabilityFilteringByIdentifierGroup) { + return IDENTIFIER_TOKEN_DEFINITION; + } + + return undefined; default: return undefined; } diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_identifiers.query.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_identifiers.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..60d141d414b505f36cbd5742c8fd41afe98953e0 --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_identifiers.query.graphql @@ -0,0 +1,6 @@ +query groupIdentifiers($fullPath: ID!, $searchTerm: String!) { + group(fullPath: $fullPath) { + id + vulnerabilityIdentifierSearch(name: $searchTerm) + } +} diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql index cd586d8a92e73180d54d321ef8e4d3057a0639ca..d3ee829bbea0e34997b6b93b534a18d764585e13 100644 --- a/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql +++ b/ee/app/assets/javascripts/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql @@ -14,6 +14,7 @@ query groupVulnerabilities( $scannerId: [VulnerabilitiesScannerID!] $state: [VulnerabilityState!] $dismissalReason: [VulnerabilityDismissalReason!] + $identifierName: String $sort: VulnerabilitySort $hasIssues: Boolean $hasResolution: Boolean @@ -46,6 +47,7 @@ query groupVulnerabilities( hasAiResolution: $hasAiResolution clusterAgentId: $clusterAgentId owaspTopTen: $owaspTopTen + identifierName: $identifierName ) { nodes { ...VulnerabilityFragment diff --git a/ee/app/controllers/groups/security/vulnerabilities_controller.rb b/ee/app/controllers/groups/security/vulnerabilities_controller.rb index 9d8f140a4cf728ef6169ba630dd6f0f50eaecea6..83f22ffedabd21a1c281ad58fb97ec2809faf135 100644 --- a/ee/app/controllers/groups/security/vulnerabilities_controller.rb +++ b/ee/app/controllers/groups/security/vulnerabilities_controller.rb @@ -18,6 +18,7 @@ class VulnerabilitiesController < Groups::ApplicationController push_frontend_feature_flag(:vulnerability_report_vr_badge, @group, type: :beta) push_frontend_feature_flag(:vulnerability_report_vr_filter, @group, type: :beta) push_frontend_feature_flag(:vulnerability_report_filtered_search_v2, @project, type: :wip) + push_frontend_feature_flag(:vulnerability_filtering_by_identifier_group, @group, type: :beta) push_frontend_feature_flag(:enhanced_vulnerability_bulk_actions, @group, type: :wip) push_frontend_ability(ability: :resolve_vulnerability_with_ai, resource: @group, user: current_user) diff --git a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/identifier_token_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/identifier_token_spec.js index b0c984ad70eeb4a3997f4c3ea8db9987a1d84fd5..326ec41100f4866fa9cfd3c62184591d87a7dc2b 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/identifier_token_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/identifier_token_spec.js @@ -5,14 +5,16 @@ import VueApollo from 'vue-apollo'; import IdentifierToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/identifier_token.vue'; import QuerystringSync from 'ee/security_dashboard/components/shared/filters/querystring_sync.vue'; import eventHub from 'ee/security_dashboard/components/shared/filtered_search/event_hub'; -import identifierSearch from 'ee/security_dashboard/graphql/queries/project_identifiers.query.graphql'; +import projectIdentifierSearch from 'ee/security_dashboard/graphql/queries/project_identifiers.query.graphql'; +import groupIdentifierSearch from 'ee/security_dashboard/graphql/queries/group_identifiers.query.graphql'; import SearchSuggestion from 'ee/security_dashboard/components/shared/filtered_search/components/search_suggestion.vue'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import { createAlert } from '~/alert'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; -import { createAlert } from '~/alert'; Vue.use(VueApollo); Vue.use(VueRouter); @@ -30,21 +32,23 @@ describe('Identifier Token component', () => { operators: OPERATORS_IS, }; - const createMockApolloProvider = ({ handlers = {} } = {}) => { + const createMockApolloProvider = ({ handlers = {}, namespace, query } = {}) => { + const capitalized = capitalizeFirstCharacter(namespace); + const defaultHandlers = { identifierSearch: jest.fn().mockResolvedValue({ data: { - project: { - id: 'gid://gitlab/Project/19', + [namespace]: { + id: `gid://gitlab/${capitalized}/19`, vulnerabilityIdentifierSearch: ['CVE-2018-3741'], - __typename: 'Project', + __typename: capitalized, }, }, }), }; const handlerMocks = { ...defaultHandlers, ...handlers }; - const requestHandlers = [[identifierSearch, handlerMocks.identifierSearch]]; + const requestHandlers = [[query, handlerMocks.identifierSearch]]; return createMockApollo(requestHandlers); }; @@ -55,6 +59,8 @@ describe('Identifier Token component', () => { stubs, provide = {}, handlers = {}, + namespace = 'project', + query = projectIdentifierSearch, mountFn = shallowMountExtended, } = {}) => { router = new VueRouter({ mode: 'history' }); @@ -89,7 +95,9 @@ describe('Identifier Token component', () => { ...stubs, }, apolloProvider: createMockApolloProvider({ + namespace, handlers, + query, }), }); }; @@ -225,6 +233,42 @@ describe('Identifier Token component', () => { }); }); + describe('group level', () => { + beforeEach(() => { + createWrapper({ + provide: { + projectFullPath: '', + groupFullPath: 'my-group', + }, + query: groupIdentifierSearch, + namespace: 'group', + }); + }); + + it('handles fuzzy search', async () => { + const CVE = 'CVE-2018-3741'; + + eventSpy = jest.fn(); + eventHub.$on('filters-changed', eventSpy); + + await searchTerm('CVE-2018'); + await waitForPromises(); + + expect(wrapper.findByTestId(`suggestion-${CVE}`).exists()).toBe(true); + + // The alert should not be called on succesful calls + expect(createAlert).not.toHaveBeenCalled(); + + await selectOption(CVE); + + expect(eventSpy).toHaveBeenCalledWith({ + identifierName: CVE, + }); + + expect(eventSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('QuerystringSync component', () => { beforeEach(() => { eventSpy = jest.fn(); diff --git a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js index c138abc0d058f1cb3aa5e51006870f4984502a5b..5722f32bce53f520aa185f477a14461ffa2fedea 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js @@ -82,7 +82,10 @@ describe('Vulnerability Report Filtered Search component', () => { `( `passes the expected available tokens for filters '$name'`, ({ availableFilters, availableTokens }) => { - createWrapper({ availableFilters }); + createWrapper({ + availableFilters, + glFeatures: { vulnerabilityFilteringByIdentifierGroup: true }, + }); expect(findFilteredSearchComponent().props('availableTokens')).toEqual(availableTokens); }, @@ -149,4 +152,53 @@ describe('Vulnerability Report Filtered Search component', () => { ]); }); }); + + describe('when vulnerabilityFilteringByIdentifierGroup feature flag is on', () => { + beforeEach(() => { + createWrapper({ + availableFilters: [FILTERS.STATUS, FILTERS.ACTIVITY, FILTERS.PROJECT, FILTERS.IDENTIFIER], + query: { + identifier: 'cve-test', + }, + glFeatures: { + vulnerabilityFilteringByIdentifierGroup: true, + }, + }); + }); + + it('includes identifier token in available tokens', () => { + expect(findFilteredSearchComponent().props('availableTokens')).toEqual([ + STATUS_TOKEN_DEFINITION, + ACTIVITY_TOKEN_DEFINITION, + PROJECT_TOKEN_DEFINITION, + IDENTIFIER_TOKEN_DEFINITION, + ]); + }); + + it('should pass route parameters to the identifier token', () => { + expect(findFilteredSearchComponent().props('value')).toEqual([ + { + type: 'state', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: '||', + }, + }, + { + type: 'activity', + value: { + data: ActivityToken.DEFAULT_VALUES, + operator: '||', + }, + }, + { + type: 'identifier', + value: { + data: ['cve-test'], + operator: '=', + }, + }, + ]); + }); + }); });