diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue new file mode 100644 index 0000000000000000000000000000000000000000..137e9c58cf07bd23fdf96264c4c3f8bdc984be75 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue @@ -0,0 +1,104 @@ + + diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue index ac204e5b222687c8198e6c7609cba1615c284c0d..ddde0da08816891a6966d779b4bb322631a1d049 100644 --- a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue @@ -1,35 +1,60 @@ diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..f16f9a68eed3cd208a49cfab485bf3f56f63427b --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql @@ -0,0 +1,7 @@ +mutation createClusterAgentMapping( + $input: NamespaceCreateRemoteDevelopmentClusterAgentMappingInput! +) { + namespaceCreateRemoteDevelopmentClusterAgentMapping(input: $input) { + clientMutationId + } +} diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2ac54d398f2229b98ae3a7e8ca78cbd2a75911b4 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteClusterAgentMapping( + $input: NamespaceDeleteRemoteDevelopmentClusterAgentMappingInput! +) { + namespaceDeleteRemoteDevelopmentClusterAgentMapping(input: $input) { + clientMutationId + } +} diff --git a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb index be64b1852891fb257a86ae86c45800539bcd6d48..cf721af51c35ffd6bada9e08b707fa21b970e153 100644 --- a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb +++ b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb @@ -16,7 +16,7 @@ end before_all do - group.add_developer(user) + group.add_owner(user) end before do @@ -71,6 +71,40 @@ expect(page).to have_content 'Allowed' expect(page).to have_content 'Blocked' end + + it 'allows mapping or unmapping agents' do + first_agent_row_selector = 'tbody tr:first-child' + + click_link 'All agents' + + # Executes block action on the first agent + within first_agent_row_selector do + expect(page).to have_content('Allowed') + + click_button 'Block' + end + + expect(page).to have_content('Block agent') + + click_button 'Block agent' + + wait_for_requests + + # Reverts the block action by allowing the agent + within first_agent_row_selector do + expect(page).to have_content('Blocked') + + click_button 'Allow' + + wait_for_requests + end + + expect(page).to have_content('Allow agent') + + click_button 'Allow agent' + + expect(page).to have_content('Allowed') + end end end end diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9d9edd34c788669bbce02b60ac5eb4233dd93a50 --- /dev/null +++ b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_status_toggle_spec.js @@ -0,0 +1,85 @@ +import { GlModal, GlButton } from '@gitlab/ui'; +import AgentMappingStatusToggle from 'ee_component/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT, NAMESPACE_ID } from '../../mock_data'; + +describe('workspaces/agent_mapping/components/agent_mapping_status_toggle', () => { + let wrapper; + + const buildWrapper = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(AgentMappingStatusToggle, { + propsData: { + namespaceId: NAMESPACE_ID, + ...propsData, + }, + }); + }; + const findToggleButton = () => wrapper.findComponent(GlButton); + const findConfirmBlockModal = () => wrapper.findComponent(GlModal); + + it('adds a primary action to the block modal', () => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } }); + + expect(findConfirmBlockModal().props('actionPrimary')).toEqual({ + text: 'Block agent', + attributes: { + variant: 'danger', + }, + }); + }); + + it.each` + agent | buttonLabel + ${MAPPED_CLUSTER_AGENT} | ${'Block'} + ${UNMAPPED_CLUSTER_AGENT} | ${'Allow'} + `('displays $buttonLabel when agent is $agent', ({ agent, buttonLabel }) => { + buildWrapper({ propsData: { agent } }); + + expect(findToggleButton().text()).toContain(buttonLabel); + }); + + it.each` + agent | modalActionPrimary | modalTitle + ${MAPPED_CLUSTER_AGENT} | ${{ text: 'Block agent', variant: 'danger' }} | ${'Block this agent for all group members'} + `( + 'customizes confirmation modal based on agent status', + ({ agent, modalActionPrimary, modalTitle }) => { + buildWrapper({ propsData: { agent } }); + + expect(findConfirmBlockModal().props().actionPrimary).toMatchObject({ + text: modalActionPrimary.text, + attributes: { + variant: modalActionPrimary.variant, + }, + }); + expect(findConfirmBlockModal().props().title).toContain(modalTitle); + }, + ); + + describe('when clicking toggle', () => { + beforeEach(() => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } }); + }); + + it('makes confirm block modal visible', async () => { + await findToggleButton().vm.$emit('click'); + + expect(findConfirmBlockModal().props('visible')).toBe(true); + }); + }); + + describe('when confirm block modal triggers primary event', () => { + it('triggers toggle event', async () => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } }); + await findConfirmBlockModal().vm.$emit('primary'); + + expect(wrapper.emitted('toggle')).toHaveLength(1); + }); + }); + + it.each([true, false])('sets toggle button as loading based on loading property', (loading) => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT, loading } }); + + expect(findToggleButton().props('loading')).toBe(loading); + }); +}); diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js index 637175089c75f3aaa97c2da7c0219aae3c639612..785e624c6b6396a447ae1b719a11c9401e0f1cbe 100644 --- a/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js +++ b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js @@ -1,27 +1,42 @@ import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import AgentsTable from 'ee_component/workspaces/agent_mapping/components/agents_table.vue'; -import { - AGENT_MAPPING_STATUS_MAPPED, - AGENT_MAPPING_STATUS_UNMAPPED, -} from 'ee_component/workspaces/agent_mapping/constants'; +import AgentMappingStatusToggle from 'ee_component/workspaces/agent_mapping/components/agent_mapping_status_toggle.vue'; +import ToggleAgentMappingStatusMutation from 'ee_component/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation.vue'; +import { MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT, NAMESPACE_ID } from '../../mock_data'; -describe('workspaces/agent_mapping/components/agents_table.vue', () => { +describe('workspaces/agent_mapping/components/agents_table', () => { let wrapper; const EMPTY_STATE_MESSAGE = 'No agents found'; - const agents = [ - { name: 'agent-1', mappingStatus: AGENT_MAPPING_STATUS_MAPPED }, - { name: 'agent-1', mappingStatus: AGENT_MAPPING_STATUS_UNMAPPED }, - ]; + const agents = [MAPPED_CLUSTER_AGENT, UNMAPPED_CLUSTER_AGENT]; + let ToggleAgentMappingStatusMutationStub; + let executeToggleAgentMappingStatusMutationFn; + + const buildWrapper = ({ propsData = {}, provide = {} } = {}, mountFn = shallowMountExtended) => { + executeToggleAgentMappingStatusMutationFn = jest.fn(); + ToggleAgentMappingStatusMutationStub = stubComponent(ToggleAgentMappingStatusMutation, { + render() { + return this.$scopedSlots.default?.({ + execute: executeToggleAgentMappingStatusMutationFn, + }); + }, + }); - const buildWrapper = ({ propsData = {} } = {}, mountFn = shallowMountExtended) => { wrapper = mountFn(AgentsTable, { propsData: { agents: [], emptyStateMessage: EMPTY_STATE_MESSAGE, isLoading: false, + namespaceId: NAMESPACE_ID, ...propsData, }, + provide: { + ...provide, + }, + stubs: { + ToggleAgentMappingStatusMutation: ToggleAgentMappingStatusMutationStub, + }, }); }; const findAgentsTable = () => wrapper.findComponent(GlTable); @@ -114,7 +129,7 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => { }); it('displays agents list', () => { - expect(findAgentsTable().text()).toContain('agent-1'); + expect(findAgentsTable().text()).toContain(MAPPED_CLUSTER_AGENT.name); }); describe('when displayMappingStatus is true', () => { @@ -131,9 +146,15 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => { ); const labels = wrapper .findAllByTestId('agent-mapping-status-label') - .wrappers.map((labelWrapper) => labelWrapper.text()); - - expect(labels).toEqual(['Allowed', 'Blocked']); + .wrappers.map((labelWrapper) => ({ + text: labelWrapper.text(), + variant: labelWrapper.props().variant, + })); + + expect(labels).toEqual([ + { text: 'Allowed', variant: 'success' }, + { text: 'Blocked', variant: 'danger' }, + ]); }); }); @@ -153,5 +174,64 @@ describe('workspaces/agent_mapping/components/agents_table.vue', () => { expect(wrapper.findAllByTestId('agent-mapping-status-label').length).toBe(0); }); }); + + describe('when canAdminClusterAgentMapping is true', () => { + beforeEach(() => { + buildWrapper( + { + propsData: { + isLoading: false, + agents, + }, + provide: { + canAdminClusterAgentMapping: true, + }, + }, + mountExtended, + ); + }); + + it('displays actions column', () => { + expect(findAgentsTable().text()).toContain('Actions'); + + const toggles = findAgentsTable().findAllComponents(AgentMappingStatusToggle); + const mutations = findAgentsTable().findAllComponents(ToggleAgentMappingStatusMutationStub); + + agents.forEach((agent, index) => { + expect(toggles.at(index).props().agent).toMatchObject(agent); + expect(mutations.at(index).props()).toMatchObject({ + agent, + namespaceId: NAMESPACE_ID, + }); + }); + }); + + describe('when action toggle emits toggle event', () => { + it('executes the toggle mapping agent status mutation', () => { + findAgentsTable().findAllComponents(AgentMappingStatusToggle).at(0).vm.$emit('toggle'); + + expect(executeToggleAgentMappingStatusMutationFn).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when canAdminClusterAgentMapping is false', () => { + it('does not display actions column', () => { + buildWrapper( + { + propsData: { + isLoading: false, + agents, + }, + provide: { + canAdminClusterAgentMapping: false, + }, + }, + mountExtended, + ); + expect(findAgentsTable().text()).not.toContain('Actions'); + expect(findAgentsTable().findAllComponents(AgentMappingStatusToggle).length).toBe(0); + }); }); }); diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8c30a312df0607089c0c6a154d1994e2b52cab9c --- /dev/null +++ b/ee/spec/frontend/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation_spec.js @@ -0,0 +1,183 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { logError } from '~/lib/logger'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ToggleAgentMappingStatusMutation from 'ee_component/workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation.vue'; +import createClusterAgentMappingMutation from 'ee/workspaces/agent_mapping/graphql/mutations/create_cluster_agent_mapping.mutation.graphql'; +import deleteClusterAgentMappingMutation from 'ee/workspaces/agent_mapping/graphql/mutations/delete_cluster_agent_mapping.mutation.graphql'; +import getAgentsWithAuthorizationStatusQuery from 'ee/workspaces/agent_mapping/graphql/queries/get_agents_with_mapping_status.query.graphql'; +import { + CREATE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT, + DELETE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT, + GET_AGENTS_WITH_MAPPING_STATUS_QUERY_RESULT, + MAPPED_CLUSTER_AGENT, + NAMESPACE_ID, + UNMAPPED_CLUSTER_AGENT, +} from '../../mock_data'; + +jest.mock('~/lib/logger'); +jest.mock('~/sentry/sentry_browser_wrapper'); + +Vue.use(VueApollo); + +describe('workspaces/agent_mapping/components/toggle_agent_mapping_status_mutation', () => { + let executeMutationFn; + let loadingStatus; + let apolloProvider; + let createClusterAgentMappingMutationHandler; + let deleteClusterAgentMappingMutationHandler; + let wrapper; + const namespace = 'foo/bar'; + + const setupApolloProvider = () => { + createClusterAgentMappingMutationHandler = jest + .fn() + .mockResolvedValueOnce(CREATE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT); + deleteClusterAgentMappingMutationHandler = jest + .fn() + .mockResolvedValueOnce(DELETE_CLUSTER_AGENT_MAPPING_MUTATION_RESULT); + + apolloProvider = createMockApollo([ + [createClusterAgentMappingMutation, createClusterAgentMappingMutationHandler], + [deleteClusterAgentMappingMutation, deleteClusterAgentMappingMutationHandler], + ]); + + apolloProvider.clients.defaultClient.writeQuery({ + query: getAgentsWithAuthorizationStatusQuery, + variables: { + namespace, + }, + data: GET_AGENTS_WITH_MAPPING_STATUS_QUERY_RESULT.data, + }); + }; + + const readRemoteDevelopmentClusterAgentsFromQueryCache = () => { + const apolloClient = apolloProvider.clients.defaultClient; + const result = apolloClient.readQuery({ + query: getAgentsWithAuthorizationStatusQuery, + variables: { + namespace, + }, + }); + + return result?.group.remoteDevelopmentClusterAgents.nodes; + }; + + const buildWrapper = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(ToggleAgentMappingStatusMutation, { + apolloProvider, + propsData: { + namespaceId: NAMESPACE_ID, + ...propsData, + }, + provide: { + namespace, + }, + scopedSlots: { + default(props) { + executeMutationFn = props.execute; + loadingStatus = props.loading; + return this.$createElement('div'); + }, + }, + }); + }; + + beforeEach(() => { + setupApolloProvider(); + }); + + describe('when executing mutation', () => { + it('sets loading status as loading', async () => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } }); + + expect(loadingStatus).toBe(false); + + executeMutationFn(); + + await nextTick(); + + expect(loadingStatus).toBe(true); + }); + + describe(`given the agent status is ${MAPPED_CLUSTER_AGENT.mappingStatus}`, () => { + beforeEach(async () => { + buildWrapper({ propsData: { agent: MAPPED_CLUSTER_AGENT } }); + + executeMutationFn(); + + await nextTick(); + }); + + it('executes deleteClusterAgentMappingMutation', () => { + expect(deleteClusterAgentMappingMutationHandler).toHaveBeenCalledWith({ + input: { + clusterAgentId: MAPPED_CLUSTER_AGENT.id, + namespaceId: NAMESPACE_ID, + }, + }); + }); + + it('removes agent from the remoteDevelopmentClusterAgents collection', () => { + const agents = readRemoteDevelopmentClusterAgentsFromQueryCache(); + + expect(agents.some((agent) => agent.id === MAPPED_CLUSTER_AGENT.id)).toBe(false); + }); + }); + + describe(`given the agent status is ${UNMAPPED_CLUSTER_AGENT.mappingStatus}`, () => { + beforeEach(async () => { + buildWrapper({ propsData: { agent: UNMAPPED_CLUSTER_AGENT } }); + + executeMutationFn(); + + await nextTick(); + }); + + it('executes createClusterAgentMappingMutation', () => { + expect(createClusterAgentMappingMutationHandler).toHaveBeenCalledWith({ + input: { + clusterAgentId: UNMAPPED_CLUSTER_AGENT.id, + namespaceId: NAMESPACE_ID, + }, + }); + }); + + it('adds unmapped agents to the remoteDevelopmentClusterAgents collection', () => { + const agents = readRemoteDevelopmentClusterAgentsFromQueryCache(); + + expect(agents.some((agent) => agent.id === UNMAPPED_CLUSTER_AGENT.id)).toBe(true); + }); + }); + + describe('when the mutation fails', () => { + const error = new Error(); + + beforeEach(async () => { + createClusterAgentMappingMutationHandler.mockReset().mockRejectedValueOnce(error); + + buildWrapper({ propsData: { agent: UNMAPPED_CLUSTER_AGENT } }); + + executeMutationFn(); + + await nextTick(); + await waitForPromises(); + }); + + it('emits error event', () => { + expect(wrapper.emitted('error')).toHaveLength(1); + }); + + it('logs error', () => { + expect(logError).toHaveBeenCalledWith(error); + }); + + it('captures the exception in Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0b50f1e4b8c38495e0ff2ae4bc98d32d8792aa1e..98700053747c9a8a512238ef528c370c353fb68f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5162,6 +5162,9 @@ msgstr "" msgid "All users with matching phone numbers" msgstr "" +msgid "Allow" +msgstr "" + msgid "Allow %{strongOpen}%{group_name}%{strongClose} to sign you in?" msgstr "" @@ -8609,6 +8612,9 @@ msgstr "" msgid "BlobViewer|View on %{environmentName}" msgstr "" +msgid "Block" +msgstr "" + msgid "Block secrets such as keys and API tokens from being pushed to your repositories. Secret push protection is triggered when commits are pushed to a repository. If any secrets are detected, the push is blocked." msgstr "" @@ -59666,9 +59672,24 @@ msgstr "" msgid "Workspaces|All agents" msgstr "" +msgid "Workspaces|Allow agent" +msgstr "" + +msgid "Workspaces|Allow this agent for all group members?" +msgstr "" + msgid "Workspaces|Allowed Agents" msgstr "" +msgid "Workspaces|Block agent" +msgstr "" + +msgid "Workspaces|Block this agent for all group members?" +msgstr "" + +msgid "Workspaces|Blocking an agent doesn't delete it. Agents can only be deleted in the project where they were created." +msgstr "" + msgid "Workspaces|Cancel" msgstr "" @@ -59720,6 +59741,12 @@ msgstr "" msgid "Workspaces|Group agents" msgstr "" +msgid "Workspaces|Group members can use allowed agents to create workspaces." +msgstr "" + +msgid "Workspaces|Group members can't create a workspace with a blocked agent. Existing workspaces using this agent will continue to run, but can't restart if they are terminated." +msgstr "" + msgid "Workspaces|If your devfile is not in the root directory of your project, specify a relative path." msgstr ""