diff options
Diffstat (limited to 'spec/frontend/ci/runner/components/registration')
3 files changed, 469 insertions, 0 deletions
diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js new file mode 100644 index 00000000000..cb46c668930 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -0,0 +1,198 @@ +import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; +import { mount, shallowMount, createWrapper } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; + +import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue'; +import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; + +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, +} from 'jest/vue_shared/components/runner_instructions/mock_data'; + +const mockToken = '0123456789'; +const maskToken = '**********'; + +Vue.use(VueApollo); + +describe('RegistrationDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + + const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); + const findRegistrationTokenInput = () => + wrapper.findByLabelText(RegistrationToken.i18n.registrationToken); + const findTokenResetDropdownItem = () => + wrapper.findComponent(RegistrationTokenResetDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); + const findModalContent = () => + createWrapper(document.body) + .find('[data-testid="runner-instructions-modal"]') + .text() + .replace(/[\n\t\s]+/g, ' '); + + const openModal = async () => { + await findRegistrationInstructionsDropdownItem().trigger('click'); + findModal().vm.$emit('shown'); + + await waitForPromises(); + }; + + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }), + ); + }; + + const createComponentWithModal = () => { + const requestHandlers = [ + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + createComponent( + { + // Mock load modal contents from API + apolloProvider: createMockApollo(requestHandlers), + // Use `attachTo` to find the modal + attachTo: document.body, + }, + mount, + ); + }; + + it.each` + type | text + ${INSTANCE_TYPE} | ${'Register an instance runner'} + ${GROUP_TYPE} | ${'Register a group runner'} + ${PROJECT_TYPE} | ${'Register a project runner'} + `('Dropdown text for type $type is "$text"', () => { + createComponent({ props: { type: INSTANCE_TYPE } }, mount); + + expect(wrapper.text()).toContain('Register an instance runner'); + }); + + it('Passes attributes to the dropdown component', () => { + createComponent({ attrs: { right: true } }); + + expect(findDropdown().attributes()).toMatchObject({ right: 'true' }); + }); + + describe('Instructions dropdown item', () => { + it('Displays "Show runner" dropdown item', () => { + createComponent(); + + expect(findRegistrationInstructionsDropdownItem().text()).toBe( + 'Show runner installation and registration instructions', + ); + }); + + describe('When the dropdown item is clicked', () => { + beforeEach(async () => { + createComponentWithModal({}, mount); + + await openModal(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('opens the modal with contents', () => { + const modalText = findModalContent(); + + expect(modalText).toContain('Install a runner'); + + // Environment selector + expect(modalText).toContain('Environment'); + expect(modalText).toContain('Linux macOS Windows Docker Kubernetes'); + + // Architecture selector + expect(modalText).toContain('Architecture'); + expect(modalText).toContain('amd64 amd64 386 arm arm64'); + + expect(modalText).toContain('Download and install binary'); + }); + }); + }); + + describe('Registration token', () => { + it('Displays dropdown form for the registration token', () => { + createComponent(); + + expect(findTokenDropdownItem().exists()).toBe(true); + }); + + it('Displays masked value by default', () => { + createComponent({}, mount); + + expect(findRegistrationTokenInput().element.value).toBe(maskToken); + }); + }); + + describe('Reset token item', () => { + it('Displays registration token reset item', () => { + createComponent(); + + expect(findTokenResetDropdownItem().exists()).toBe(true); + }); + + it.each([INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE])('Set up token reset for %s', (type) => { + createComponent({ props: { type } }); + + expect(findTokenResetDropdownItem().props('type')).toBe(type); + }); + }); + + describe('When token is reset', () => { + const newToken = 'mock1'; + + const resetToken = async () => { + findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); + await nextTick(); + }; + + it('Updates token input', async () => { + createComponent({}, mount); + + expect(findRegistrationToken().props('value')).not.toBe(newToken); + + await resetToken(); + + expect(findRegistrationToken().props('value')).toBe(newToken); + }); + + it('Updates token in modal', async () => { + createComponentWithModal({}, mount); + + await openModal(); + + expect(findModalContent()).toContain(mockToken); + + await resetToken(); + + expect(findModalContent()).toContain(newToken); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js new file mode 100644 index 00000000000..783a4d9252a --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -0,0 +1,209 @@ +import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; + +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; +import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); + +Vue.use(VueApollo); +Vue.use(GlToast); + +const mockNewToken = 'NEW_TOKEN'; +const modalID = 'token-reset-modal'; + +describe('RegistrationTokenResetDropdownItem', () => { + let wrapper; + let runnersRegistrationTokenResetMutationHandler; + let showToast; + + const mockEvent = { preventDefault: jest.fn() }; + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); + const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); + + const createComponent = ({ props, provide = {} } = {}) => { + wrapper = shallowMount(RegistrationTokenResetDropdownItem, { + provide, + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + apolloProvider: createMockApollo([ + [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], + ]), + directives: { + GlModal: createMockDirective(), + }, + }); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; + }; + + beforeEach(() => { + runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: mockNewToken, + errors: [], + }, + }, + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays reset button', () => { + expect(findDropdownItem().exists()).toBe(true); + }); + + describe('modal directive integration', () => { + it('has the correct ID on the dropdown', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(modalID); + }); + + it('has the correct ID on the modal', () => { + expect(findModal().props('modalId')).toBe(modalID); + }); + }); + + describe('On click and confirmation', () => { + const mockGroupId = '11'; + const mockProjectId = '22'; + + describe.each` + type | provide | expectedInput + ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }} + ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }} + ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }} + `('Resets token of type $type', ({ type, provide, expectedInput }) => { + beforeEach(async () => { + createComponent({ + provide, + props: { type }, + }); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + }); + + it('resets token', () => { + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ + input: expectedInput, + }); + }); + + it('emits result', () => { + expect(wrapper.emitted('tokenReset')).toHaveLength(1); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows confirmation', () => { + expect(showToast).toHaveBeenLastCalledWith( + expect.stringContaining('registration token generated'), + ); + }); + }); + }); + + describe('On click without confirmation', () => { + beforeEach(async () => { + findDropdownItem().vm.$emit('click'); + await waitForPromises(); + }); + + it('does not reset token', () => { + expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled(); + }); + + it('does not emit any result', () => { + expect(wrapper.emitted('tokenReset')).toBeUndefined(); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not shows confirmation', () => { + expect(showToast).not.toHaveBeenCalled(); + }); + }); + + describe('On error', () => { + it('On network error, error message is shown', async () => { + const mockErrorMsg = 'Token reset failed!'; + + runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerRegistrationTokenReset', + }); + }); + + it('On validation error, error message is shown', async () => { + const mockErrorMsg = 'User not allowed!'; + const mockErrorMsg2 = 'Type is not valid!'; + + runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: null, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerRegistrationTokenReset', + }); + }); + }); + + describe('Immediately after click', () => { + it('shows loading state', async () => { + findDropdownItem().trigger('click'); + clickSubmit(); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js new file mode 100644 index 00000000000..d2a51c0d910 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -0,0 +1,62 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +const mockToken = '01234567890'; +const mockMasked = '***********'; + +describe('RegistrationToken', () => { + let wrapper; + let showToast; + + Vue.use(GlToast); + + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RegistrationToken, { + propsData: { + value: mockToken, + inputId: 'token-value', + ...props, + }, + }); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays value and copy button', () => { + createComponent(); + + expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( + 'Copy registration token', + ); + }); + + // Component integration test to ensure secure masking + it('Displays masked value by default', () => { + createComponent({ mountFn: mountExtended }); + + expect(wrapper.find('input').element.value).toBe(mockMasked); + }); + + describe('When the copy to clipboard button is clicked', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a copied message', () => { + findInputCopyToggleVisibility().vm.$emit('copy'); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Registration token copied!'); + }); + }); +}); |