diff --git a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue new file mode 100644 index 0000000000000000000000000000000000000000..6bcc65694aa072e9909843e59cc33e9c05187967 --- /dev/null +++ b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue @@ -0,0 +1,115 @@ + + diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index 29dbc49f835aed0b6000f51ffa5d961e28fa9df6..ef5be9f499a17e958d1b09e454e7aa0d9bf3ad26 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -3,7 +3,8 @@ import { GlAlert, GlButton, GlCard, - GlFormInput, + GlForm, + GlFormGroup, GlLink, GlIcon, GlLoadingIcon, @@ -21,6 +22,7 @@ import inboundRemoveGroupCIJobTokenScopeMutation from '../graphql/mutations/inbo import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql'; +import GroupsAndProjectsListbox from './groups_and_projects_listbox.vue'; import TokenAccessTable from './token_access_table.vue'; export default { @@ -36,22 +38,23 @@ export default { addGroupOrProject: __('Add group or project'), add: __('Add'), cancel: __('Cancel'), - addProjectPlaceholder: __( - 'Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)', - ), + addProjectPlaceholder: __('Pick a group or project'), projectsFetchError: __('There was a problem fetching the projects'), scopeFetchError: __('There was a problem fetching the job token scope value'), + projectInScopeError: s__('CICD|Target project is already in the job token scope.'), }, components: { GlAlert, GlButton, GlCard, - GlFormInput, + GlForm, + GlFormGroup, GlLink, GlIcon, GlLoadingIcon, GlSprintf, GlToggle, + GroupsAndProjectsListbox, TokenAccessTable, }, directives: { @@ -112,6 +115,11 @@ export default { isGroupOrProjectPathEmpty() { return this.groupOrProjectPath === ''; }, + isGroupOrProjectPathInScope() { + return this.groupsAndProjectsWithAccess.some( + (item) => item.fullPath === this.groupOrProjectPath, + ); + }, ciJobTokenHelpPage() { return helpPagePath('ci/jobs/ci_job_token#control-job-token-access-to-your-project'); }, @@ -219,6 +227,9 @@ export default { this.getGroupsAndProjects(); } }, + setGroupOrProjectPath(path) { + this.groupOrProjectPath = path; + }, clearGroupOrProjectPath() { this.groupOrProjectPath = ''; this.isAddFormVisible = false; @@ -312,23 +323,33 @@ export default {
-

{{ $options.i18n.addGroupOrProject }}

- -
- {{ $options.i18n.addGroupOrProject }} + + - {{ $options.i18n.add }} - - {{ $options.i18n.cancel }} -
+ + +
+ + {{ $options.i18n.add }} + + {{ $options.i18n.cancel }} +
+
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..4d59dcebd3970e1c57ba2af284d06f3e5abeb7b5 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql @@ -0,0 +1,18 @@ +query getTokenAccessGroupsAndProjects($search: String) { + groups(search: $search, first: 4) { + nodes { + id + name + avatarUrl + fullPath + } + } + projects(search: $search, first: 4) { + nodes { + id + name + avatarUrl + fullPath + } + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1afd8e113877a04ec266b62949e0f4676a08f79b..82185d52cb45555dcc69dcb6c4231ccb3f457155 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10172,6 +10172,9 @@ msgstr "" msgid "CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more%{linkEnd}." msgstr "" +msgid "CICD|Target project is already in the job token scope." +msgstr "" + msgid "CICD|The %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}from%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting is deprecated and will be removed in the 18.0 milestone. Use the %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}to%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting and allowlist instead. %{linkStart}How do I do this?%{linkEnd}" msgstr "" @@ -38036,9 +38039,6 @@ msgstr "" msgid "Paste a public key here. %{link_start}How do I generate it?%{link_end}" msgstr "" -msgid "Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)" -msgstr "" - msgid "Paste project path (i.e. gitlab-org/gitlab)" msgstr "" @@ -38306,6 +38306,9 @@ msgstr "" msgid "PhoneVerification|You've reached the maximum number of tries. Wait %{interval} and try again." msgstr "" +msgid "Pick a group or project" +msgstr "" + msgid "Pick a name" msgstr "" diff --git a/spec/frontend/token_access/groups_and_projects_listbox_spec.js b/spec/frontend/token_access/groups_and_projects_listbox_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1c492bb856a73b395a8767bc97f83cd391d17517 --- /dev/null +++ b/spec/frontend/token_access/groups_and_projects_listbox_spec.js @@ -0,0 +1,101 @@ +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue'; +import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql'; +import { getGroupsAndProjectsResponse } from './mock_data'; + +const placeholder = 'Pick a group or project'; + +Vue.use(VueApollo); + +describe('GroupsAndProjectsListbox component', () => { + let wrapper; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const openListbox = () => findListbox().vm.$emit('shown'); + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); + + const createComponent = ({ + mountFn = mountExtended, + requestHandlers = [ + [ + getGroupsAndProjectsQuery, + jest.fn().mockImplementation(({ search }) => { + if (search !== '') { + return { + data: { + groups: { + nodes: [getGroupsAndProjectsResponse.data.groups.nodes[0]], + }, + projects: { + nodes: [getGroupsAndProjectsResponse.data.projects.nodes[0]], + }, + }, + }; + } + return getGroupsAndProjectsResponse; + }), + ], + ], + value = '', + } = {}) => { + wrapper = mountFn(GroupsAndProjectsListbox, { + apolloProvider: createMockApollo(requestHandlers), + propsData: { + value, + placeholder, + }, + }); + }; + + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('displays a listbox', () => { + expect(findListbox().exists()).toBe(true); + }); + + it('displays the placeholder as the toggle text', () => { + expect(findListbox().props('toggleText')).toBe(placeholder); + }); + + it('lists groups and projects', () => { + expect(findListbox().props('items')).toMatchObject([ + { text: 'Groups', options: getGroupsAndProjectsResponse.data.groups.nodes }, + { text: 'Projects', options: getGroupsAndProjectsResponse.data.projects.nodes }, + ]); + }); + + it('searches for groups and projects', async () => { + expect(findListbox().props('items')[0].options).toHaveLength( + getGroupsAndProjectsResponse.data.groups.nodes.length, + ); + expect(findListbox().props('items')[1].options).toHaveLength( + getGroupsAndProjectsResponse.data.projects.nodes.length, + ); + + openListbox(); + findListbox().vm.$emit('search', 'gitlab'); + + await waitForPromises(); + + expect(findListbox().props('items')[0].options).toHaveLength(1); + expect(findListbox().props('items')[1].options).toHaveLength(1); + }); + + it('emits select event with the fullPath when an item is selected', () => { + openListbox(); + findListboxItems().at(0).trigger('click'); + + expect(wrapper.emitted('select')[0][0]).toEqual( + getGroupsAndProjectsResponse.data.groups.nodes[0].fullPath, + ); + }); +}); diff --git a/spec/frontend/token_access/inbound_token_access_spec.js b/spec/frontend/token_access/inbound_token_access_spec.js index 045156075c9d6826bab42afeab8028c35bc6904c..78a33817d928b9264017fd9d21afc35b957ad754 100644 --- a/spec/frontend/token_access/inbound_token_access_spec.js +++ b/spec/frontend/token_access/inbound_token_access_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlFormInput, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlCollapsibleListbox, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -6,17 +6,20 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import InboundTokenAccess from '~/token_access/components/inbound_token_access.vue'; +import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue'; import inboundAddGroupOrProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_add_group_or_project_ci_job_token_scope.mutation.graphql'; import inboundRemoveGroupCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_group_ci_job_token_scope.mutation.graphql'; import inboundRemoveProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql'; import inboundUpdateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; import inboundGetCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql'; +import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql'; import { inboundJobTokenScopeEnabledResponse, inboundJobTokenScopeDisabledResponse, inboundGroupsAndProjectsWithScopeResponse, inboundGroupsAndProjectsWithScopeResponseWithAddedItem, + getGroupsAndProjectsResponse, inboundAddGroupOrProjectSuccessResponse, inboundRemoveGroupSuccess, inboundRemoveProjectSuccess, @@ -45,6 +48,9 @@ describe('TokenAccess component', () => { const inboundGroupsAndProjectsWithScopeResponseHandler = jest .fn() .mockResolvedValue(inboundGroupsAndProjectsWithScopeResponse); + const getGroupsAndProjectsSuccessResponseHandler = jest + .fn() + .mockResolvedValue(getGroupsAndProjectsResponse); const inboundAddGroupOrProjectSuccessResponseHandler = jest .fn() .mockResolvedValue(inboundAddGroupOrProjectSuccessResponse); @@ -61,7 +67,9 @@ describe('TokenAccess component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAddProjectBtn = () => wrapper.findByTestId('add-project-btn'); const findCancelBtn = () => wrapper.findByRole('button', { name: 'Cancel' }); - const findProjectInput = () => wrapper.findComponent(GlFormInput); + const findGroupOrProjectFormGroup = () => wrapper.findByTestId('group-or-project-form-group'); + const findGroupsAndProjectsListbox = () => wrapper.findComponent(GroupsAndProjectsListbox); + const findListboxInput = () => wrapper.findComponent(GlCollapsibleListbox); const findRemoveProjectBtnAt = (i) => wrapper.findAllByRole('button', { name: 'Remove access' }).at(i); const findToggleFormBtn = () => wrapper.findByTestId('toggle-form-btn'); @@ -242,6 +250,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [ inboundAddGroupOrProjectCIJobTokenScopeMutation, inboundAddGroupOrProjectSuccessResponseHandler, @@ -253,7 +262,7 @@ describe('TokenAccess component', () => { await waitForPromises(); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); + await findListboxInput().vm.$emit('select', testPath); findAddProjectBtn().trigger('click'); expect(inboundAddGroupOrProjectSuccessResponseHandler).toHaveBeenCalledWith({ @@ -273,6 +282,7 @@ describe('TokenAccess component', () => { .mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponse) .mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponseWithAddedItem), ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [ inboundAddGroupOrProjectCIJobTokenScopeMutation, inboundAddGroupOrProjectSuccessResponseHandler, @@ -289,7 +299,7 @@ describe('TokenAccess component', () => { ); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); + await findListboxInput().vm.$emit('select', testPath); findAddProjectBtn().trigger('click'); await waitForPromises(); @@ -308,6 +318,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [inboundAddGroupOrProjectCIJobTokenScopeMutation, failureHandler], ], mountExtended, @@ -316,8 +327,8 @@ describe('TokenAccess component', () => { await waitForPromises(); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); - findAddProjectBtn().trigger('click'); + await findListboxInput().vm.$emit('select', testPath); + await findAddProjectBtn().trigger('click'); await waitForPromises(); @@ -332,6 +343,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], ], mountExtended, ); @@ -340,17 +352,53 @@ describe('TokenAccess component', () => { await findToggleFormBtn().trigger('click'); - expect(findProjectInput().exists()).toBe(true); - - await findProjectInput().vm.$emit('input', testPath); + expect(findListboxInput().exists()).toBe(true); + await findListboxInput().vm.$emit('select', testPath); await findCancelBtn().trigger('click'); - expect(findProjectInput().exists()).toBe(false); + expect(findListboxInput().exists()).toBe(false); await findToggleFormBtn().trigger('click'); - expect(findProjectInput().element.value).toBe(''); + expect(findListboxInput().props('selected')).toEqual(''); + }); + }); + + describe.each` + type | testPath + ${'group'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.inboundAllowlist.nodes[0].fullPath} + ${'project'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.groupsAllowlist.nodes[0].fullPath} + `('add a duplicate $type', ({ testPath }) => { + it(`validates whether path is already in the allowlist`, async () => { + createComponent( + [ + [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler], + [ + inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, + inboundGroupsAndProjectsWithScopeResponseHandler, + ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], + [ + inboundAddGroupOrProjectCIJobTokenScopeMutation, + inboundAddGroupOrProjectSuccessResponseHandler, + ], + ], + mountExtended, + ); + + await waitForPromises(); + + await findToggleFormBtn().trigger('click'); + + expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe(undefined); + expect(findGroupsAndProjectsListbox().props('isValid')).toBe(true); + + await findListboxInput().vm.$emit('select', testPath); + + expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe('true'); + expect(findGroupsAndProjectsListbox().props('isValid')).toBe(false); + expect(findAddProjectBtn().props('disabled')).toBe(true); }); }); diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js index ab8f6d63c591522a6562590d5992c493f77ef195..54cb7788e2048f968808fc0a86889a1b72a2d97e 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -236,6 +236,23 @@ export const inboundGroupsAndProjectsWithScopeResponseWithAddedItem = { }, }; +export const getGroupsAndProjectsResponse = { + data: { + groups: { + nodes: [ + { id: 1, name: 'gitlab-org', avatarUrl: '', fullPath: 'gitlab-org' }, + { id: 2, name: 'ci-group', avatarUrl: '', fullPath: 'root/ci-group' }, + ], + }, + projects: { + nodes: [ + { id: 1, name: 'gitlab', avatarUrl: '', fullPath: 'gitlab-org/gitlab' }, + { id: 2, name: 'ci-project', avatarUrl: '', fullPath: 'root/ci-project' }, + ], + }, + }, +}; + export const inboundAddGroupOrProjectSuccessResponse = { data: { ciJobTokenScopeAddProject: {