diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/editor_layout.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/editor_layout.vue index 40f15320ed89e94fdf97e6ffdb015168c9b1acdd..607bd8e2566191e7c8603f229c5a5952cf907ac8 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/editor_layout.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/editor_layout.vue @@ -138,11 +138,11 @@ export default { return this.namespaceType === NAMESPACE_TYPES.GROUP; }, shouldShowScope() { - return ( - this.glFeatures.securityPoliciesPolicyScope && - this.securityPoliciesPolicyScopeToggleEnabled && - this.isGroupLevel - ); + const featureFlagEnabled = this.isGroupLevel + ? this.glFeatures.securityPoliciesPolicyScope + : this.glFeatures.securityPoliciesPolicyScopeProject; + + return featureFlagEnabled && this.securityPoliciesPolicyScopeToggleEnabled; }, deleteModalTitle() { return sprintf(s__('SecurityOrchestration|Delete policy: %{policy}'), { diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scope/scope_section.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scope/scope_section.vue index 80db3a5cb74618935f16762e8ff7c49ed9f1b0f3..fe9353bb5ebc14c35ec0921a663fb424f08b289e 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scope/scope_section.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scope/scope_section.vue @@ -1,10 +1,14 @@ <script> -import { GlAlert, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { GlAlert, GlCollapsibleListbox, GlIcon, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { NAMESPACE_TYPES } from 'ee/security_orchestration/constants'; import PolicyPopover from 'ee/security_orchestration/components/policy_popover.vue'; +import getSppLinkedProjectsNamespaces from 'ee/security_orchestration/graphql/queries/get_spp_linked_projects_namespaces.graphql'; import GroupProjectsDropdown from '../../group_projects_dropdown.vue'; import ComplianceFrameworkDropdown from './compliance_framework_dropdown.vue'; import { @@ -28,6 +32,13 @@ export default { PROJECT_SCOPE_TYPE_LISTBOX_ITEMS, EXCEPTION_TYPE_LISTBOX_ITEMS, i18n: { + policyScopeLoadingText: s__('SecurityOrchestration|Fetching the scope information.'), + policyScopeErrorText: s__( + 'SecurityOrchestration|Failed to fetch the scope information. Please refresh the page to try again.', + ), + policyScopeFrameworkCopyProject: s__( + 'SecurityOrchestration|Apply this policy to current project.', + ), policyScopeFrameworkCopy: s__( `SecurityOrchestration|Apply this policy to %{projectScopeType}named %{frameworkSelector}`, ), @@ -45,14 +56,47 @@ export default { }, name: 'ScopeSection', components: { + ComplianceFrameworkDropdown, GlAlert, + GlIcon, GlCollapsibleListbox, - ComplianceFrameworkDropdown, + GlLoadingIcon, GlSprintf, GroupProjectsDropdown, PolicyPopover, }, - inject: ['namespacePath', 'rootNamespacePath'], + apollo: { + linkedSppItems: { + query: getSppLinkedProjectsNamespaces, + variables() { + return { + fullPath: this.namespacePath, + }; + }, + update(data) { + const { + securityPolicyProjectLinkedProjects: { nodes: linkedProjects = [] }, + securityPolicyProjectLinkedNamespaces: { nodes: linkedNamespaces = [] }, + } = data?.project || {}; + + const items = [...linkedProjects, ...linkedNamespaces]; + + if (isEmpty(this.policyScope) && items.length > 1 && !this.isGroupLevel) { + this.triggerChanged({ compliance_frameworks: [] }); + } + + return items; + }, + error() { + this.showLinkedSppItemsError = true; + }, + skip() { + return this.shouldSkipDependenciesCheck; + }, + }, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['namespacePath', 'rootNamespacePath', 'namespaceType'], props: { policyScope: { type: Object, @@ -83,9 +127,26 @@ export default { projectsPayloadKey, showAlert: false, errorDescription: '', + linkedSppItems: [], + showLinkedSppItemsError: false, }; }, computed: { + isGroupLevel() { + return this.namespaceType === NAMESPACE_TYPES.GROUP; + }, + shouldSkipDependenciesCheck() { + return this.isGroupLevel || !this.glFeatures.securityPoliciesPolicyScopeProject; + }, + groupProjectsFullPath() { + return this.isGroupLevel ? this.namespacePath : this.rootNamespacePath; + }, + hasMultipleProjectsLinked() { + return this.linkedSppItems.length > 1; + }, + showScopeSelector() { + return this.isGroupLevel || this.hasMultipleProjectsLinked; + }, projectIds() { /** * Protection from manual yam input as objects @@ -132,6 +193,9 @@ export default { ? this.$options.i18n.policyScopeFrameworkCopy : this.$options.i18n.policyScopeProjectCopy; }, + showLoader() { + return this.$apollo.queries.linkedSppItems?.loading && !this.isGroupLevel; + }, }, methods: { resetPolicyScope() { @@ -180,59 +244,85 @@ export default { {{ errorDescription }} </gl-alert> - <div class="gl-display-flex gl-gap-3 gl-align-items-center gl-flex-wrap gl-mt-2 gl-mb-6"> - <gl-sprintf :message="policyScopeCopy"> - <template #projectScopeType> - <gl-collapsible-listbox - data-testid="project-scope-type" - :items="$options.PROJECT_SCOPE_TYPE_LISTBOX_ITEMS" - :selected="selectedProjectScopeType" - :toggle-text="selectedProjectScopeText" - @select="selectProjectScopeType" - /> - </template> - - <template #frameworkSelector> - <div class="gl-display-inline-flex gl-align-items-center gl-flex-wrap gl-gap-3"> - <compliance-framework-dropdown - :selected-framework-ids="complianceFrameworksIds" - :full-path="rootNamespacePath" - @framework-query-error=" - setShowAlert($options.i18n.complianceFrameworkErrorDescription) - " - @select="setSelectedFrameworkIds" + <div v-if="showLoader" class="gl-display-flex gl-gap-3 gl-align-items-baseline gl-mb-4"> + <gl-loading-icon inline /> + <span data-testid="loading-text">{{ $options.i18n.policyScopeLoadingText }}</span> + </div> + + <div v-else class="gl-display-flex gl-gap-3 gl-align-items-center gl-flex-wrap gl-mt-2 gl-mb-6"> + <template v-if="showLinkedSppItemsError"> + <div + data-testid="policy-scope-project-error" + class="gl-display-flex gl-align-items-center gl-gap-3" + > + <gl-icon class="gl-text-red-500" name="status_warning" /> + <p data-testid="policy-scope-project-error-text" class="gl-text-red-500 gl-m-0"> + {{ $options.i18n.policyScopeErrorText }} + </p> + </div> + </template> + + <template v-else-if="showScopeSelector"> + <gl-sprintf :message="policyScopeCopy"> + <template #projectScopeType> + <gl-collapsible-listbox + data-testid="project-scope-type" + :items="$options.PROJECT_SCOPE_TYPE_LISTBOX_ITEMS" + :selected="selectedProjectScopeType" + :toggle-text="selectedProjectScopeText" + @select="selectProjectScopeType" /> + </template> - <policy-popover - :content="$options.i18n.complianceFrameworkPopoverContent" - :href="$options.COMPLIANCE_FRAMEWORK_PATH" - :title="$options.i18n.complianceFrameworkPopoverTitle" - target="compliance-framework-icon" + <template #frameworkSelector> + <div class="gl-display-inline-flex gl-align-items-center gl-flex-wrap gl-gap-3"> + <compliance-framework-dropdown + :selected-framework-ids="complianceFrameworksIds" + :full-path="rootNamespacePath" + @framework-query-error=" + setShowAlert($options.i18n.complianceFrameworkErrorDescription) + " + @select="setSelectedFrameworkIds" + /> + + <policy-popover + :content="$options.i18n.complianceFrameworkPopoverContent" + :href="$options.COMPLIANCE_FRAMEWORK_PATH" + :title="$options.i18n.complianceFrameworkPopoverTitle" + target="compliance-framework-icon" + /> + </div> + </template> + + <template #exceptionType> + <gl-collapsible-listbox + v-if="showExceptionTypeDropdown" + data-testid="exception-type" + :items="$options.EXCEPTION_TYPE_LISTBOX_ITEMS" + :toggle-text="selectedExceptionTypeText" + :selected="selectedExceptionType" + @select="selectExceptionType" /> - </div> - </template> - - <template #exceptionType> - <gl-collapsible-listbox - v-if="showExceptionTypeDropdown" - data-testid="exception-type" - :items="$options.EXCEPTION_TYPE_LISTBOX_ITEMS" - :toggle-text="selectedExceptionTypeText" - :selected="selectedExceptionType" - @select="selectExceptionType" - /> - </template> - - <template #projectSelector> - <group-projects-dropdown - v-if="showGroupProjectsDropdown" - :group-full-path="namespacePath" - :selected="projectIds" - @projects-query-error="setShowAlert($options.i18n.groupProjectErrorDescription)" - @select="setSelectedProjectIds" - /> - </template> - </gl-sprintf> + </template> + + <template #projectSelector> + <group-projects-dropdown + v-if="showGroupProjectsDropdown" + :group-full-path="groupProjectsFullPath" + :selected="projectIds" + state + @projects-query-error="setShowAlert($options.i18n.groupProjectErrorDescription)" + @select="setSelectedProjectIds" + /> + </template> + </gl-sprintf> + </template> + + <template v-else> + <p data-testid="policy-scope-project-text"> + {{ $options.i18n.policyScopeFrameworkCopyProject }} + </p> + </template> </div> </div> </template> diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/editor_layout_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/editor_layout_spec.js index 3194939941539248116aa00b67f27b6ab0c530e5..0580ed81ea906cdf881f7fb66a4988f3b75d5103 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/editor_layout_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/editor_layout_spec.js @@ -300,21 +300,34 @@ describe('EditorLayout component', () => { describe('policy scope', () => { it.each` - flagEnabled | securityPoliciesPolicyScopeToggleEnabled | type | expectedResult - ${true} | ${true} | ${NAMESPACE_TYPES.GROUP} | ${true} - ${true} | ${true} | ${NAMESPACE_TYPES.PROJECT} | ${false} - ${false} | ${false} | ${NAMESPACE_TYPES.GROUP} | ${false} - ${false} | ${false} | ${NAMESPACE_TYPES.PROJECT} | ${false} - ${true} | ${false} | ${NAMESPACE_TYPES.GROUP} | ${false} - ${true} | ${false} | ${NAMESPACE_TYPES.PROJECT} | ${false} + flagEnabledGroup | flagEnabledProject | securityPoliciesPolicyScopeToggleEnabled | type | expectedResult + ${true} | ${false} | ${true} | ${NAMESPACE_TYPES.GROUP} | ${true} + ${true} | ${false} | ${true} | ${NAMESPACE_TYPES.PROJECT} | ${false} + ${false} | ${false} | ${false} | ${NAMESPACE_TYPES.GROUP} | ${false} + ${false} | ${false} | ${false} | ${NAMESPACE_TYPES.PROJECT} | ${false} + ${true} | ${false} | ${false} | ${NAMESPACE_TYPES.GROUP} | ${false} + ${true} | ${false} | ${false} | ${NAMESPACE_TYPES.PROJECT} | ${false} + ${false} | ${false} | ${true} | ${NAMESPACE_TYPES.GROUP} | ${false} + ${false} | ${true} | ${true} | ${NAMESPACE_TYPES.PROJECT} | ${true} + ${false} | ${false} | ${false} | ${NAMESPACE_TYPES.PROJECT} | ${false} + ${true} | ${true} | ${false} | ${NAMESPACE_TYPES.GROUP} | ${false} `( 'renders policy scope conditionally for $namespaceType level based on feature flag', - ({ flagEnabled, securityPoliciesPolicyScopeToggleEnabled, type, expectedResult }) => { + ({ + flagEnabledGroup, + flagEnabledProject, + securityPoliciesPolicyScopeToggleEnabled, + type, + expectedResult, + }) => { factory({ provide: { securityPoliciesPolicyScopeToggleEnabled, namespaceType: type, - glFeatures: { securityPoliciesPolicyScope: flagEnabled }, + glFeatures: { + securityPoliciesPolicyScope: flagEnabledGroup, + securityPoliciesPolicyScopeProject: flagEnabledProject, + }, }, }); diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scope/scope_section_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scope/scope_section_spec.js index 118443814fc54e470d27ab4a8e9d08a55dcfa4e1..536c9bb119cbe031d50c9b7337b4af034dc7a0c9 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/scope/scope_section_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scope/scope_section_spec.js @@ -1,10 +1,16 @@ -import { GlAlert, GlSprintf } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlSprintf, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; +import { NAMESPACE_TYPES } from 'ee/security_orchestration/constants'; +import waitForPromises from 'helpers/wait_for_promises'; import ScopeSection from 'ee/security_orchestration/components/policy_editor/scope/scope_section.vue'; import ComplianceFrameworkDropdown from 'ee/security_orchestration/components/policy_editor/scope/compliance_framework_dropdown.vue'; import GroupProjectsDropdown from 'ee/security_orchestration/components/group_projects_dropdown.vue'; +import getSppLinkedProjectsNamespaces from 'ee/security_orchestration/graphql/queries/get_spp_linked_projects_namespaces.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { PROJECTS_WITH_FRAMEWORK, ALL_PROJECTS_IN_GROUP, @@ -14,16 +20,42 @@ import { describe('PolicyScope', () => { let wrapper; + let requestHandler; + + const createHandler = ({ projects = [], namespaces = [] } = {}) => + jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + securityPolicyProjectLinkedProjects: { + nodes: projects, + }, + securityPolicyProjectLinkedNamespaces: { + nodes: namespaces, + }, + }, + }, + }); - const createComponent = ({ propsData } = {}) => { + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + requestHandler = handler; + + return createMockApollo([[getSppLinkedProjectsNamespaces, requestHandler]]); + }; + + const createComponent = ({ propsData, provide = {}, handler = createHandler() } = {}) => { wrapper = shallowMountExtended(ScopeSection, { + apolloProvider: createMockApolloProvider(handler), propsData: { policyScope: {}, ...propsData, }, provide: { + namespaceType: NAMESPACE_TYPES.GROUP, namespacePath: 'gitlab-org', - rootNamespacePath: 'gitlab-org', + rootNamespacePath: 'gitlab-org-root', + ...provide, }, stubs: { GlSprintf, @@ -36,6 +68,12 @@ describe('PolicyScope', () => { const findGroupProjectsDropdown = () => wrapper.findComponent(GroupProjectsDropdown); const findProjectScopeTypeDropdown = () => wrapper.findByTestId('project-scope-type'); const findExceptionTypeDropdown = () => wrapper.findByTestId('exception-type'); + const findPolicyScopeProjectText = () => wrapper.findByTestId('policy-scope-project-text'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLoadingText = () => wrapper.findByTestId('loading-text'); + const findErrorMessage = () => wrapper.findByTestId('policy-scope-project-error'); + const findErrorMessageText = () => wrapper.findByTestId('policy-scope-project-error-text'); + const findIcon = () => wrapper.findComponent(GlIcon); beforeEach(() => { createComponent(); @@ -190,6 +228,7 @@ describe('PolicyScope', () => { expect(findExceptionTypeDropdown().props('selected')).toBe(EXCEPT_PROJECTS); expect(findExceptionTypeDropdown().exists()).toBe(true); expect(findGroupProjectsDropdown().exists()).toBe(true); + expect(findGroupProjectsDropdown().props('state')).toBe(true); expect(findGroupProjectsDropdown().props('selected')).toEqual([ convertToGraphQLId(TYPENAME_PROJECT, 'id1'), convertToGraphQLId(TYPENAME_PROJECT, 'id2'), @@ -237,4 +276,190 @@ describe('PolicyScope', () => { expect(findGlAlert().exists()).toBe(true); }); }); + + describe('project level', () => { + it('should check linked items on project level', () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + }); + + expect(requestHandler).toHaveBeenCalledWith({ fullPath: 'gitlab-org' }); + }); + + it('should not check linked items on group level', async () => { + createComponent(); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findComplianceFrameworkDropdown().exists()).toBe(true); + expect(requestHandler).toHaveBeenCalledTimes(0); + expect(findPolicyScopeProjectText().exists()).toBe(false); + }); + + it('show text message for project without linked items', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + }, + }); + + await waitForPromises(); + + expect(findPolicyScopeProjectText().text()).toBe('Apply this policy to current project.'); + }); + + it('show compliance framework selector for projects with links', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + handler: createHandler({ + projects: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + namespaces: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + }), + }); + + await waitForPromises(); + + expect(findPolicyScopeProjectText().exists()).toBe(false); + expect(findComplianceFrameworkDropdown().exists()).toBe(true); + }); + + it('shows loading state', () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findLoadingText().text()).toBe('Fetching the scope information.'); + }); + + it('shows error message when spp query fails', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + handler: jest.fn().mockRejectedValue({}), + }); + + await waitForPromises(); + + expect(findErrorMessage().exists()).toBe(true); + expect(findErrorMessageText().text()).toBe( + 'Failed to fetch the scope information. Please refresh the page to try again.', + ); + expect(findIcon().props('name')).toBe('status_warning'); + }); + + it('emits default policy scope on project level for SPP with multiple dependencies', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + handler: createHandler({ + projects: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + namespaces: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + }), + }); + + await waitForPromises(); + + expect(wrapper.emitted('changed')).toEqual([[{ compliance_frameworks: [] }]]); + }); + + it('does not emit default policy scope on group level', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.GROUP, + }, + }); + + await waitForPromises(); + + expect(wrapper.emitted('changed')).toBeUndefined(); + }); + + it('does not check dependencies on project level when ff is disabled', async () => { + createComponent({ + provide: { + namespaceType: NAMESPACE_TYPES.PROJECT, + glFeatures: { + securityPoliciesPolicyScopeProject: false, + }, + }, + }); + + await waitForPromises(); + + expect(requestHandler).toHaveBeenCalledTimes(0); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('namespace', () => { + it.each` + namespaceType | expectedResult + ${NAMESPACE_TYPES.GROUP} | ${'gitlab-org'} + ${NAMESPACE_TYPES.PROJECT} | ${'gitlab-org-root'} + `( + 'queries different namespaces on group and project level', + async ({ namespaceType, expectedResult }) => { + createComponent({ + provide: { + namespaceType, + glFeatures: { + securityPoliciesPolicyScopeProject: true, + }, + }, + handler: createHandler({ + projects: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + namespaces: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2 ' }, + ], + }), + }); + + await waitForPromises(); + await findProjectScopeTypeDropdown().vm.$emit('select', SPECIFIC_PROJECTS); + + expect(findGroupProjectsDropdown().props('groupFullPath')).toBe(expectedResult); + }, + ); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4c3c0e88470789063802ecce6260dc344b436698..249ee7024db59fbff5d09b30b1dd2d9ffcf24f2a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -44665,6 +44665,9 @@ msgstr "" msgid "SecurityOrchestration|Apply this policy to %{projectScopeType}named %{frameworkSelector}" msgstr "" +msgid "SecurityOrchestration|Apply this policy to current project." +msgstr "" + msgid "SecurityOrchestration|Are you sure you want to delete this policy? This action cannot be undone." msgstr "" @@ -44764,6 +44767,9 @@ msgstr "" msgid "SecurityOrchestration|Exceptions" msgstr "" +msgid "SecurityOrchestration|Failed to fetch the scope information. Please refresh the page to try again." +msgstr "" + msgid "SecurityOrchestration|Failed to load cluster agents." msgstr "" @@ -44776,6 +44782,9 @@ msgstr "" msgid "SecurityOrchestration|Failed to load images." msgstr "" +msgid "SecurityOrchestration|Fetching the scope information." +msgstr "" + msgid "SecurityOrchestration|Fill in branch name with project name in the format of %{boldStart}branch-name@project-path,%{boldEnd} separate with `,`" msgstr ""