diff options
Diffstat (limited to 'spec/frontend/ci/ci_variable_list')
14 files changed, 2222 insertions, 0 deletions
diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js new file mode 100644 index 00000000000..e4abedb412f --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -0,0 +1,159 @@ +import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import VariableList from '~/ci/ci_variable_list/ci_variable_list'; + +const HIDE_CLASS = 'hide'; + +describe('VariableList', () => { + let $wrapper; + let variableList; + + describe('with only key/value inputs', () => { + describe('with no variables', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should remove the row when clicking the remove button', () => { + $wrapper.find('.js-row-remove-button').trigger('click'); + + expect($wrapper.find('.js-row').length).toBe(0); + }); + + it('should add another row when editing the last rows key input', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($keyInput.val()).toBe(''); + }); + + it('should add another row when editing the last rows value textarea', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-value').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($valueInput.val()).toBe(''); + }); + + it('should remove empty row after blurring', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + $row.find('.js-ci-variable-input-key').val('').trigger('input').trigger('blur'); + + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + describe('with persisted variables', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should have "Reveal values" button initially when there are already variables', () => { + expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); + }); + + it('should reveal hidden values', () => { + const $row = $wrapper.find('.js-row:first-child'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); + + // Reveal values + $wrapper.find('.js-secret-value-reveal-button').click(); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); + }); + }); + }); + + describe('toggleEnableRow method', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should disable all key inputs', () => { + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + }); + + it('should disable all remove buttons', () => { + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + }); + + it('should enable all remove buttons', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + }); + + it('should enable all key inputs', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js new file mode 100644 index 00000000000..71e8e6d3afb --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; + +describe('NativeFormVariableList', () => { + let $wrapper; + + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + setupNativeFormVariableList({ + container: $wrapper, + formField: 'schedule', + }); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('onFormSubmit', () => { + it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { + const $row = $wrapper.find('.js-row'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe( + 'schedule[variables_attributes][][key]', + ); + + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe( + 'schedule[variables_attributes][][secret_value]', + ); + + $wrapper.closest('form').trigger('trigger-submit'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(''); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js new file mode 100644 index 00000000000..5e0c35c9f90 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; + +import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +describe('Ci Project Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = () => { + wrapper = shallowMount(ciAdminVariables); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Passes down the correct props to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + areScopedVariablesAvailable: false, + componentName: 'InstanceVariables', + entity: '', + hideEnvironmentScope: true, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: true, + fullPath: null, + id: null, + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js new file mode 100644 index 00000000000..2fd395a1230 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -0,0 +1,118 @@ +import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { allEnvironments } from '~/ci/ci_variable_list/constants'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; + +describe('Ci environments dropdown', () => { + let wrapper; + + const envs = ['dev', 'prod', 'staging']; + const defaultProps = { environments: envs, selectedEnvironmentScope: '' }; + + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); + const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxText = () => findListbox().props('toggleText'); + const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ props = {}, searchTerm = '' } = {}) => { + wrapper = mount(CiEnvironmentsDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + + findListbox().vm.$emit('search', searchTerm); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('No environments found', () => { + beforeEach(() => { + createComponent({ searchTerm: 'stable' }); + }); + + it('renders create button with search term if environments do not contain search term', () => { + const button = findCreateWildcardButton(); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Create wildcard: stable'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + it('renders all environments when search term is empty', () => { + expect(findListboxItemByIndex(0).text()).toBe(envs[0]); + expect(findListboxItemByIndex(1).text()).toBe(envs[1]); + expect(findListboxItemByIndex(2).text()).toBe(envs[2]); + }); + + it('does not display active checkmark on the inactive stage', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); + }); + + describe('when `*` is the value of selectedEnvironmentScope props', () => { + const wildcardScope = '*'; + + beforeEach(() => { + createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); + }); + + it('shows the `All environments` text and not the wildcard', () => { + expect(findListboxText()).toContain(allEnvironments.text); + expect(findListboxText()).not.toContain(wildcardScope); + }); + }); + + describe('Environments found', () => { + const currentEnv = envs[2]; + + beforeEach(() => { + createComponent({ searchTerm: currentEnv }); + }); + + it('renders only the environment searched for', () => { + expect(findAllListboxItems()).toHaveLength(1); + expect(findListboxItemByIndex(0).text()).toBe(currentEnv); + }); + + it('does not display create button', () => { + expect(findCreateWildcardButton().exists()).toBe(false); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); + }); + + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); + expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); + }); + }); + + describe('when creating a new environment from a search term', () => { + const search = 'new-env'; + beforeEach(() => { + createComponent({ searchTerm: search }); + }); + + it('emits create-environment-scope', () => { + findCreateWildcardButton().vm.$emit('click'); + expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js new file mode 100644 index 00000000000..3f1eebbc6a5 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +import { GRAPHQL_GROUP_TYPE } from '~/ci/ci_variable_list/constants'; + +const mockProvide = { + glFeatures: { + groupScopedCiVariables: false, + }, + groupPath: '/group', + groupId: 12, +}; + +describe('Ci Group Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMount(ciGroupVariables, { + provide: { ...mockProvide, ...provide }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Props', () => { + beforeEach(() => { + createComponent(); + }); + + it('are passed down the correctly to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, mockProvide.groupId), + areScopedVariablesAvailable: false, + componentName: 'GroupVariables', + entity: 'group', + fullPath: mockProvide.groupPath, + hideEnvironmentScope: false, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: false, + }); + }); + }); + + describe('feature flag', () => { + describe('When enabled', () => { + beforeEach(() => { + createComponent({ provide: { glFeatures: { groupScopedCiVariables: true } } }); + }); + + it('Passes down `true` to variable shared component', () => { + expect(findCiShared().props('areScopedVariablesAvailable')).toBe(true); + }); + }); + + describe('When disabled', () => { + beforeEach(() => { + createComponent({ provide: { glFeatures: { groupScopedCiVariables: false } } }); + }); + + it('Passes down `false` to variable shared component', () => { + expect(findCiShared().props('areScopedVariablesAvailable')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js new file mode 100644 index 00000000000..7230017c560 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +import { GRAPHQL_PROJECT_TYPE } from '~/ci/ci_variable_list/constants'; + +const mockProvide = { + projectFullPath: '/namespace/project', + projectId: 1, +}; + +describe('Ci Project Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = () => { + wrapper = shallowMount(ciProjectVariables, { + provide: mockProvide, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Passes down the correct props to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, mockProvide.projectId), + areScopedVariablesAvailable: true, + componentName: 'ProjectVariables', + entity: 'project', + fullPath: mockProvide.projectFullPath, + hideEnvironmentScope: false, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: false, + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js new file mode 100644 index 00000000000..7838e4884d8 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -0,0 +1,520 @@ +import { GlButton, GlFormInput } from '@gitlab/ui'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; +import CiVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; +import { + ADD_VARIABLE_ACTION, + AWS_ACCESS_KEY_ID, + EDIT_VARIABLE_ACTION, + EVENT_LABEL, + EVENT_ACTION, + ENVIRONMENT_SCOPE_LINK_TITLE, + instanceString, + variableOptions, +} from '~/ci/ci_variable_list/constants'; +import { mockVariablesWithScopes } from '../mocks'; +import ModalStub from '../stubs'; + +describe('Ci variable modal', () => { + let wrapper; + let trackingSpy; + + const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; + const mockVariables = mockVariablesWithScopes(instanceString); + + const defaultProvide = { + awsLogoSvgPath: '/logo', + awsTipCommandsLink: '/tips', + awsTipDeployLink: '/deploy', + awsTipLearnLink: '/learn-link', + containsVariableReferenceLink: '/reference', + environmentScopeLink: '/help/environments', + isProtectedByDefault: false, + maskedEnvironmentVariablesLink: '/variables-link', + maskableRegex, + protectedEnvironmentVariablesLink: '/protected-link', + }; + + const defaultProps = { + areScopedVariablesAvailable: true, + environments: [], + hideEnvironmentScope: false, + mode: ADD_VARIABLE_ACTION, + selectedVariable: {}, + variable: [], + }; + + const createComponent = ({ mountFn = shallowMountExtended, props = {}, provide = {} } = {}) => { + wrapper = mountFn(CiVariableModal, { + attachTo: document.body, + provide: { ...defaultProvide, ...provide }, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlModal: ModalStub, + }, + }); + }; + + const findCiEnvironmentsDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); + const findReferenceWarning = () => wrapper.findByTestId('contains-variable-reference'); + const findModal = () => wrapper.findComponent(ModalStub); + const findAWSTip = () => wrapper.findByTestId('aws-guidance-tip'); + const findAddorUpdateButton = () => wrapper.findByTestId('ciUpdateOrAddVariableBtn'); + const deleteVariableButton = () => + findModal() + .findAllComponents(GlButton) + .wrappers.find((button) => button.props('variant') === 'danger'); + const findExpandedVariableCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox'); + const findProtectedVariableCheckbox = () => + wrapper.findByTestId('ci-variable-protected-checkbox'); + const findMaskedVariableCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox'); + const findValueField = () => wrapper.find('#ci-variable-value'); + const findEnvScopeLink = () => wrapper.findByTestId('environment-scope-link'); + const findEnvScopeInput = () => + wrapper.findByTestId('environment-scope').findComponent(GlFormInput); + const findRawVarTip = () => wrapper.findByTestId('raw-variable-tip'); + const findVariableTypeDropdown = () => wrapper.find('#ci-variable-type'); + const findEnvironmentScopeText = () => wrapper.findByText('Environment scope'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Adding a variable', () => { + describe('when no key/value pair are present', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows the submit button as disabled', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when a key/value pair is present', () => { + beforeEach(() => { + createComponent({ props: { selectedVariable: mockVariables[0] } }); + }); + + it('shows the submit button as enabled', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('events', () => { + const [currentVariable] = mockVariables; + + beforeEach(() => { + createComponent({ props: { selectedVariable: currentVariable } }); + jest.spyOn(wrapper.vm, '$emit'); + }); + + it('Dispatches `add-variable` action on submit', () => { + findAddorUpdateButton().vm.$emit('click'); + expect(wrapper.emitted('add-variable')).toEqual([[currentVariable]]); + }); + + it('Dispatches the `hideModal` event when dismissing', () => { + findModal().vm.$emit('hidden'); + expect(wrapper.emitted('hideModal')).toEqual([[]]); + }); + }); + }); + + describe('when protected by default', () => { + describe('when adding a new variable', () => { + beforeEach(() => { + createComponent({ provide: { isProtectedByDefault: true } }); + findModal().vm.$emit('shown'); + }); + + it('updates the protected value to true', () => { + expect(findProtectedVariableCheckbox().attributes('data-is-protected-checked')).toBe( + 'true', + ); + }); + }); + + describe('when editing a variable', () => { + beforeEach(() => { + createComponent({ + provide: { isProtectedByDefault: false }, + props: { + selectedVariable: {}, + mode: EDIT_VARIABLE_ACTION, + }, + }); + findModal().vm.$emit('shown'); + }); + + it('keeps the value as false', async () => { + expect( + findProtectedVariableCheckbox().attributes('data-is-protected-checked'), + ).toBeUndefined(); + }); + }); + }); + + describe('Adding a new non-AWS variable', () => { + beforeEach(() => { + const [variable] = mockVariables; + createComponent({ mountFn: mountExtended, props: { selectedVariable: variable } }); + }); + + it('does not show AWS guidance tip', () => { + const tip = findAWSTip(); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(false); + }); + }); + + describe('Adding a new AWS variable', () => { + beforeEach(() => { + const [variable] = mockVariables; + const AWSKeyVariable = { + ...variable, + key: AWS_ACCESS_KEY_ID, + value: 'AKIAIOSFODNN7EXAMPLEjdhy', + }; + createComponent({ mountFn: mountExtended, props: { selectedVariable: AWSKeyVariable } }); + }); + + it('shows AWS guidance tip', () => { + const tip = findAWSTip(); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(true); + }); + }); + + describe('when expanded', () => { + describe('with a $ character', () => { + beforeEach(() => { + const [variable] = mockVariables; + const variableWithDollarSign = { + ...variable, + value: 'valueWith$', + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: variableWithDollarSign }, + }); + }); + + it(`renders the variable reference warning`, () => { + expect(findReferenceWarning().exists()).toBe(true); + }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); + }); + + describe('without a $ character', () => { + beforeEach(() => { + const [variable] = mockVariables; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: variable }, + }); + }); + + it(`does not render the variable reference warning`, () => { + expect(findReferenceWarning().exists()).toBe(false); + }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); + }); + + describe('setting raw value', () => { + const [variable] = mockVariables; + + it('defaults to expanded and raw:false when adding a variable', () => { + createComponent({ props: { selectedVariable: variable } }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + + expect(findExpandedVariableCheckbox().attributes('checked')).toBe('true'); + + findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('add-variable')).toEqual([ + [ + { + ...variable, + raw: false, + }, + ], + ]); + }); + + it('sets correct raw value when editing', async () => { + createComponent({ + props: { + selectedVariable: variable, + mode: EDIT_VARIABLE_ACTION, + }, + }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + await findExpandedVariableCheckbox().vm.$emit('change'); + await findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...variable, + raw: true, + }, + ], + ]); + }); + }); + }); + + describe('when not expanded', () => { + describe('with a $ character', () => { + beforeEach(() => { + const selectedVariable = mockVariables[1]; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable }, + }); + }); + + it(`renders raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(true); + }); + }); + }); + + describe('Editing a variable', () => { + const [variable] = mockVariables; + + beforeEach(() => { + createComponent({ props: { selectedVariable: variable, mode: EDIT_VARIABLE_ACTION } }); + jest.spyOn(wrapper.vm, '$emit'); + }); + + it('button text is Update variable when updating', () => { + expect(findAddorUpdateButton().text()).toBe('Update variable'); + }); + + it('Update variable button dispatches updateVariable with correct variable', () => { + findAddorUpdateButton().vm.$emit('click'); + expect(wrapper.emitted('update-variable')).toEqual([[variable]]); + }); + + it('Propagates the `hideModal` event', () => { + findModal().vm.$emit('hidden'); + expect(wrapper.emitted('hideModal')).toEqual([[]]); + }); + + it('dispatches `delete-variable` with correct variable to delete', () => { + deleteVariableButton().vm.$emit('click'); + expect(wrapper.emitted('delete-variable')).toEqual([[variable]]); + }); + }); + + describe('Environment scope', () => { + describe('when feature is available', () => { + describe('and section is not hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + }, + }); + }); + + it('renders the environment dropdown and section title', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(true); + expect(findCiEnvironmentsDropdown().isVisible()).toBe(true); + expect(findEnvironmentScopeText().exists()).toBe(true); + }); + + it('renders a link to documentation on scopes', () => { + const link = findEnvScopeLink(); + + expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); + expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); + }); + }); + + describe('and section is hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: true, + hideEnvironmentScope: true, + }, + }); + }); + + it('does not renders the environment dropdown and section title', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + expect(findEnvironmentScopeText().exists()).toBe(false); + }); + }); + }); + + describe('when feature is not available', () => { + describe('and section is not hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: false, + hideEnvironmentScope: false, + }, + }); + }); + + it('disables the dropdown', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + expect(findEnvironmentScopeText().exists()).toBe(true); + expect(findEnvScopeInput().attributes('readonly')).toBe('readonly'); + }); + }); + + describe('and section is hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: false, + hideEnvironmentScope: true, + }, + }); + }); + + it('hides the dropdown', () => { + expect(findEnvironmentScopeText().exists()).toBe(false); + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + }); + }); + }); + }); + + describe('variable type dropdown', () => { + describe('default behaviour', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('adds each option as a dropdown item', () => { + expect(findVariableTypeDropdown().findAll('option')).toHaveLength(variableOptions.length); + variableOptions.forEach((v) => { + expect(findVariableTypeDropdown().text()).toContain(v.text); + }); + }); + }); + }); + + describe('Validations', () => { + const maskError = 'This variable can not be masked.'; + + describe('when the mask state is invalid', () => { + beforeEach(async () => { + const [variable] = mockVariables; + const invalidMaskVariable = { + ...variable, + value: 'd:;', + masked: false, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: invalidMaskVariable }, + }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findMaskedVariableCheckbox().trigger('click'); + }); + + it('disables the submit button', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBe('disabled'); + }); + + it('shows the correct error text', () => { + expect(findModal().text()).toContain(maskError); + }); + + it('sends the correct tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: ';', + }); + }); + }); + + describe.each` + value | masked | eventSent | trackingErrorProperty + ${'secretValue'} | ${false} | ${0} | ${null} + ${'short'} | ${true} | ${0} | ${null} + ${'dollar$ign'} | ${false} | ${1} | ${'$'} + ${'dollar$ign'} | ${true} | ${1} | ${'$'} + ${'unsupported|char'} | ${true} | ${1} | ${'|'} + ${'unsupported|char'} | ${false} | ${0} | ${null} + `('Adding a new variable', ({ value, masked, eventSent, trackingErrorProperty }) => { + beforeEach(async () => { + const [variable] = mockVariables; + const invalidKeyVariable = { + ...variable, + value: '', + masked: false, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: invalidKeyVariable }, + }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findValueField().vm.$emit('input', value); + if (masked) { + await findMaskedVariableCheckbox().trigger('click'); + } + }); + + it(`${ + eventSent > 0 ? 'sends the correct' : 'does not send the' + } variable validation tracking event with ${value}`, () => { + expect(trackingSpy).toHaveBeenCalledTimes(eventSent); + + if (eventSent > 0) { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: trackingErrorProperty, + }); + } + }); + }); + + describe('when masked variable has acceptable value', () => { + beforeEach(() => { + const [variable] = mockVariables; + const validMaskandKeyVariable = { + ...variable, + key: AWS_ACCESS_KEY_ID, + value: '12345678', + masked: true, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: validMaskandKeyVariable }, + }); + }); + + it('does not disable the submit button', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js new file mode 100644 index 00000000000..32af2ec4de9 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -0,0 +1,147 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import { + ADD_VARIABLE_ACTION, + EDIT_VARIABLE_ACTION, + projectString, +} from '~/ci/ci_variable_list/constants'; +import { mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; + +import { mockEnvs, mockVariablesWithScopes, newVariable } from '../mocks'; + +describe('Ci variable table', () => { + let wrapper; + + const defaultProps = { + areScopedVariablesAvailable: true, + entity: 'project', + environments: mapEnvironmentNames(mockEnvs), + hideEnvironmentScope: false, + isLoading: false, + maxVariableLimit: 5, + variables: mockVariablesWithScopes(projectString), + }; + + const findCiVariableTable = () => wrapper.findComponent(ciVariableTable); + const findCiVariableModal = () => wrapper.findComponent(ciVariableModal); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(CiVariableSettings, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('props passing', () => { + it('passes props down correctly to the ci table', () => { + createComponent(); + + expect(findCiVariableTable().props()).toEqual({ + entity: 'project', + isLoading: defaultProps.isLoading, + maxVariableLimit: defaultProps.maxVariableLimit, + variables: defaultProps.variables, + }); + }); + + it('passes props down correctly to the ci modal', async () => { + createComponent(); + + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().props()).toEqual({ + areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, + environments: defaultProps.environments, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + variables: defaultProps.variables, + mode: ADD_VARIABLE_ACTION, + selectedVariable: {}, + }); + }); + }); + + describe('modal mode', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes down ADD mode when receiving an empty variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().props('mode')).toBe(ADD_VARIABLE_ACTION); + }); + + it('passes down EDIT mode when receiving a variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable', newVariable); + await nextTick(); + + expect(findCiVariableModal().props('mode')).toBe(EDIT_VARIABLE_ACTION); + }); + }); + + describe('variable modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('is hidden by default', () => { + expect(findCiVariableModal().exists()).toBe(false); + }); + + it('shows modal when adding a new variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(true); + }); + + it('shows modal when updating a variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable', newVariable); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(true); + }); + + it('hides modal when receiving the event from the modal', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + findCiVariableModal().vm.$emit('hideModal'); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(false); + }); + }); + + describe('variable events', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + eventName + ${'add-variable'} + ${'update-variable'} + ${'delete-variable'} + `('bubbles up the $eventName event', async ({ eventName }) => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + findCiVariableModal().vm.$emit(eventName, newVariable); + await nextTick(); + + expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js new file mode 100644 index 00000000000..2d39bff8ce0 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -0,0 +1,450 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; + +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + environmentFetchErrorText, + genericMutationErrorText, + variableFetchErrorText, +} from '~/ci/ci_variable_list/constants'; + +import { + createGroupProps, + createInstanceProps, + createProjectProps, + createGroupProvide, + createProjectProvide, + devName, + mockProjectEnvironments, + mockProjectVariables, + newVariable, + prodName, + mockGroupVariables, + mockAdminVariables, +} from '../mocks'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const mockProvide = { + endpoint: '/variables', + isGroup: false, + isProject: false, +}; + +const defaultProps = { + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + refetchAfterMutation: false, +}; + +describe('Ci Variable Shared Component', () => { + let wrapper; + + let mockApollo; + let mockEnvironments; + let mockVariables; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findCiTable = () => wrapper.findComponent(GlTable); + const findCiSettings = () => wrapper.findComponent(ciVariableSettings); + + // eslint-disable-next-line consistent-return + async function createComponentWithApollo({ + customHandlers = null, + isLoading = false, + props = { ...createProjectProps() }, + provide = {}, + } = {}) { + const handlers = customHandlers || [ + [getProjectEnvironments, mockEnvironments], + [getProjectVariables, mockVariables], + ]; + + mockApollo = createMockApollo(handlers, resolvers); + + wrapper = shallowMount(ciVariableShared, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...mockProvide, + ...provide, + }, + apolloProvider: mockApollo, + stubs: { ciVariableSettings, ciVariableTable }, + }); + + if (!isLoading) { + return waitForPromises(); + } + } + + beforeEach(() => { + mockEnvironments = jest.fn(); + mockVariables = jest.fn(); + }); + + describe('while queries are being fetch', () => { + beforeEach(() => { + createComponentWithApollo({ isLoading: true }); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findCiTable().exists()).toBe(false); + }); + }); + + describe('when queries are resolved', () => { + describe('successfully', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo({ provide: createProjectProvide() }); + }); + + it('passes down the expected max variable limit as props', () => { + expect(findCiSettings().props('maxVariableLimit')).toBe( + mockProjectVariables.data.project.ciVariables.limit, + ); + }); + + it('passes down the expected environments as props', () => { + expect(findCiSettings().props('environments')).toEqual([prodName, devName]); + }); + + it('passes down the expected variables as props', () => { + expect(findCiSettings().props('variables')).toEqual( + mockProjectVariables.data.project.ciVariables.nodes, + ); + }); + + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('with an error for variables', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockRejectedValue(); + + await createComponentWithApollo(); + }); + + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); + }); + }); + + describe('with an error for environments', () => { + beforeEach(async () => { + mockEnvironments.mockRejectedValue(); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo(); + }); + + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText }); + }); + }); + }); + + describe('environment query', () => { + describe('when there is an environment key in queryData', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo({ props: { ...createProjectProps() } }); + }); + + it('is executed', () => { + expect(mockVariables).toHaveBeenCalled(); + }); + }); + + describe('when there isnt an environment key in queryData', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + + await createComponentWithApollo({ props: { ...createGroupProps() } }); + }); + + it('is skipped', () => { + expect(mockVariables).not.toHaveBeenCalled(); + }); + }); + }); + + describe('mutations', () => { + const groupProps = createGroupProps(); + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: groupProps, + }); + }); + it.each` + actionName | mutation | event + ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'} + ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'} + ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'} + `( + 'calls the right mutation from propsData when user performs $actionName variable', + async ({ event, mutation }) => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + + await findCiSettings().vm.$emit(event, newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation, + variables: { + endpoint: mockProvide.endpoint, + fullPath: groupProps.fullPath, + id: convertToGraphQLId('Group', groupProps.id), + variable: newVariable, + }, + }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws with the specific graphql error if present when user performs $actionName variable', + async ({ event }) => { + const graphQLErrorMessage = 'There is a problem with this graphQL action'; + jest + .spyOn(wrapper.vm.$apollo, 'mutate') + .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } }); + await findCiSettings().vm.$emit(event, newVariable); + await nextTick(); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws generic error on failure with no graphql errors and user performs $actionName variable', + async ({ event }) => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => { + throw new Error(); + }); + await findCiSettings().vm.$emit(event, newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); + }, + ); + + describe('without fullpath and ID props', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockAdminVariables); + + await createComponentWithApollo({ + customHandlers: [[getAdminVariables, mockVariables]], + props: createInstanceProps(), + }); + }); + + it('does not pass fullPath and ID to the mutation', async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + + await findCiSettings().vm.$emit('add-variable', newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION], + variables: { + endpoint: mockProvide.endpoint, + variable: newVariable, + }, + }); + }); + }); + }); + + describe('Props', () => { + const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables; + const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables; + + describe('in a specific context as', () => { + it.each` + name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit + ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit} + ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit} + ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0} + `( + 'passes down all the required props when its a $name component', + async ({ + mutation, + maxVariableLimit, + mockVariablesValue, + mockEnvironmentsValue, + withEnvironments, + expectedEnvironments, + propsFn, + provideFn, + }) => { + const props = propsFn(); + const provide = provideFn(); + + mockVariables.mockResolvedValue(mockVariablesValue); + + if (withEnvironments) { + mockEnvironments.mockResolvedValue(mockEnvironmentsValue); + } + + let customHandlers = null; + + if (mutation) { + customHandlers = [[mutation, mockVariables]]; + } + + await createComponentWithApollo({ customHandlers, props, provide }); + + expect(findCiSettings().props()).toEqual({ + areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + isLoading: false, + maxVariableLimit, + variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes, + entity: props.entity, + environments: expectedEnvironments, + }); + }, + ); + }); + + describe('refetchAfterMutation', () => { + it.each` + bool | text + ${true} | ${'refetches the variables'} + ${false} | ${'does not refetch the variables'} + `('when $bool it $text', async ({ bool }) => { + await createComponentWithApollo({ + props: { ...createInstanceProps(), refetchAfterMutation: bool }, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} }); + jest.spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch').mockImplementation(jest.fn()); + + await findCiSettings().vm.$emit('add-variable', newVariable); + + await nextTick(); + + if (bool) { + expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled(); + } else { + expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled(); + } + }); + }); + + describe('Validators', () => { + describe('queryData', () => { + let error; + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: { ...createGroupProps() }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('will not mount component with wrong data', async () => { + try { + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: { ...createGroupProps(), queryData: { wrongKey: {} } }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(false); + expect(error.toString()).toContain('custom validator check failed for prop'); + } + }); + }); + + describe('mutationData', () => { + let error; + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + props: { ...createGroupProps() }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('will not mount component with wrong data', async () => { + try { + await createComponentWithApollo({ + props: { ...createGroupProps(), mutationData: { wrongKey: {} } }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(false); + expect(error.toString()).toContain('custom validator check failed for prop'); + } + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js new file mode 100644 index 00000000000..9e2508c56ee --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -0,0 +1,172 @@ +import { GlAlert } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci/ci_variable_list/constants'; +import { mockVariables } from '../mocks'; + +describe('Ci variable table', () => { + let wrapper; + + const defaultProps = { + entity: 'project', + isLoading: false, + maxVariableLimit: mockVariables(projectString).length + 1, + variables: mockVariables(projectString), + }; + + const mockMaxVariableLimit = defaultProps.variables.length; + + const createComponent = ({ props = {} } = {}) => { + wrapper = mountExtended(CiVariableTable, { + attachTo: document.body, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findRevealButton = () => wrapper.findByText('Reveal values'); + const findAddButton = () => wrapper.findByLabelText('Add'); + const findEditButton = () => wrapper.findByLabelText('Edit'); + const findEmptyVariablesPlaceholder = () => wrapper.findByText('There are no variables yet.'); + const findHiddenValues = () => wrapper.findAllByTestId('hiddenValue'); + const findLimitReachedAlerts = () => wrapper.findAllComponents(GlAlert); + const findRevealedValues = () => wrapper.findAllByTestId('revealedValue'); + const findOptionsValues = (rowIndex) => + wrapper.findAllByTestId('ci-variable-table-row-options').at(rowIndex).text(); + + const generateExceedsVariableLimitText = (entity, currentVariableCount, maxVariableLimit) => { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When table is empty', () => { + beforeEach(() => { + createComponent({ props: { variables: [] } }); + }); + + it('displays empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(true); + }); + + it('hides the reveal button', () => { + expect(findRevealButton().exists()).toBe(false); + }); + }); + + describe('When table has variables', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not display the empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(false); + }); + + it('displays the reveal button', () => { + expect(findRevealButton().exists()).toBe(true); + }); + + it('displays the correct amount of variables', async () => { + expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); + }); + + it('displays the correct variable options', async () => { + expect(findOptionsValues(0)).toBe('Protected, Expanded'); + expect(findOptionsValues(1)).toBe('Masked'); + }); + + it('enables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(false); + }); + }); + + describe('When variables have exceeded the max limit', () => { + beforeEach(() => { + createComponent({ props: { maxVariableLimit: mockVariables(projectString).length } }); + }); + + it('disables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(true); + }); + }); + + describe('max limit reached alert', () => { + describe('when there is no variable limit', () => { + beforeEach(() => { + createComponent({ + props: { maxVariableLimit: 0 }, + }); + }); + + it('hides alert', () => { + expect(findLimitReachedAlerts().length).toBe(0); + }); + }); + + describe('when variable limit exists', () => { + it('hides alert when limit has not been reached', () => { + createComponent(); + + expect(findLimitReachedAlerts().length).toBe(0); + }); + + it('shows alert when limit has been reached', () => { + const exceedsVariableLimitText = generateExceedsVariableLimitText( + defaultProps.entity, + defaultProps.variables.length, + mockMaxVariableLimit, + ); + + createComponent({ + props: { maxVariableLimit: mockMaxVariableLimit }, + }); + + expect(findLimitReachedAlerts().length).toBe(2); + + expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText); + + expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText); + }); + }); + }); + + describe('Table click actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('reveals secret values when button is clicked', async () => { + expect(findHiddenValues()).toHaveLength(defaultProps.variables.length); + expect(findRevealedValues()).toHaveLength(0); + + await findRevealButton().trigger('click'); + + expect(findHiddenValues()).toHaveLength(0); + expect(findRevealedValues()).toHaveLength(defaultProps.variables.length); + }); + + it('dispatches `setSelectedVariable` with correct variable to edit', async () => { + await findEditButton().trigger('click'); + + expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]); + }); + + it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => { + await findAddButton().trigger('click'); + + expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js new file mode 100644 index 00000000000..4da4f53f69f --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -0,0 +1,213 @@ +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + variableTypes, + groupString, + instanceString, + projectString, +} from '~/ci/ci_variable_list/constants'; + +import addAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql'; +import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; +import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql'; + +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; + +export const devName = 'dev'; +export const prodName = 'prod'; + +export const mockVariables = (kind) => { + return [ + { + __typename: `Ci${kind}Variable`, + id: 1, + key: 'my-var', + masked: false, + protected: true, + raw: false, + value: 'variable_value', + variableType: variableTypes.envType, + }, + { + __typename: `Ci${kind}Variable`, + id: 2, + key: 'secret', + masked: true, + protected: false, + raw: true, + value: 'another_value', + variableType: variableTypes.fileType, + }, + ]; +}; + +export const mockVariablesWithScopes = (kind) => + mockVariables(kind).map((variable) => { + return { ...variable, environmentScope: '*' }; + }); + +const createDefaultVars = ({ withScope = true, kind } = {}) => { + let base = mockVariables(kind); + + if (withScope) { + base = mockVariablesWithScopes(kind); + } + + return { + __typename: `Ci${kind}VariableConnection`, + limit: 200, + pageInfo: { + startCursor: 'adsjsd12kldpsa', + endCursor: 'adsjsd12kldpsa', + hasPreviousPage: false, + hasNextPage: true, + }, + nodes: base, + }; +}; + +const defaultEnvs = { + __typename: 'EnvironmentConnection', + nodes: [ + { + __typename: 'Environment', + id: 1, + name: prodName, + }, + { + __typename: 'Environment', + id: 2, + name: devName, + }, + ], +}; + +export const mockEnvs = defaultEnvs.nodes; + +export const mockProjectEnvironments = { + data: { + project: { + __typename: 'Project', + id: 1, + environments: defaultEnvs, + }, + }, +}; + +export const mockProjectVariables = { + data: { + project: { + __typename: 'Project', + id: 1, + ciVariables: createDefaultVars({ kind: projectString }), + }, + }, +}; + +export const mockGroupVariables = { + data: { + group: { + __typename: 'Group', + id: 1, + ciVariables: createDefaultVars({ kind: groupString }), + }, + }, +}; + +export const mockAdminVariables = { + data: { + ciVariables: createDefaultVars({ withScope: false, kind: instanceString }), + }, +}; + +export const newVariable = { + id: 3, + environmentScope: 'new', + key: 'AWS_RANDOM_THING', + masked: true, + protected: false, + value: 'devops', + variableType: variableTypes.variableType, +}; + +export const createProjectProps = () => { + return { + componentName: 'ProjectVariable', + entity: 'project', + fullPath: '/namespace/project/', + id: 'gid://gitlab/Project/20', + mutationData: { + [ADD_MUTATION_ACTION]: addProjectVariable, + [UPDATE_MUTATION_ACTION]: updateProjectVariable, + [DELETE_MUTATION_ACTION]: deleteProjectVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.project?.ciVariables, + query: getProjectVariables, + }, + environments: { + lookup: (data) => data?.project?.environments, + query: getProjectEnvironments, + }, + }, + }; +}; + +export const createGroupProps = () => { + return { + componentName: 'GroupVariable', + entity: 'group', + fullPath: '/my-group', + id: 'gid://gitlab/Group/20', + mutationData: { + [ADD_MUTATION_ACTION]: addGroupVariable, + [UPDATE_MUTATION_ACTION]: updateGroupVariable, + [DELETE_MUTATION_ACTION]: deleteGroupVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.group?.ciVariables, + query: getGroupVariables, + }, + }, + }; +}; + +export const createInstanceProps = () => { + return { + componentName: 'InstanceVariable', + entity: '', + mutationData: { + [ADD_MUTATION_ACTION]: addAdminVariable, + [UPDATE_MUTATION_ACTION]: updateAdminVariable, + [DELETE_MUTATION_ACTION]: deleteAdminVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.ciVariables, + query: getAdminVariables, + }, + }, + }; +}; + +export const createGroupProvide = () => ({ + isGroup: true, + isProject: false, +}); + +export const createProjectProvide = () => ({ + isGroup: false, + isProject: true, +}); diff --git a/spec/frontend/ci/ci_variable_list/services/mock_data.js b/spec/frontend/ci/ci_variable_list/services/mock_data.js new file mode 100644 index 00000000000..44f4db93c63 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/services/mock_data.js @@ -0,0 +1,156 @@ +export default { + mockVariables: [ + { + environment_scope: 'All (default)', + id: 113, + key: 'test_var', + masked: false, + protected: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'Variable', + }, + ], + + mockVariablesApi: [ + { + environment_scope: '*', + id: 113, + key: 'test_var', + masked: false, + protected: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'env_var', + }, + { + environment_scope: '*', + id: 114, + key: 'test_var_2', + masked: false, + protected: false, + secret_value: 'test_val_2', + value: 'test_val_2', + variable_type: 'file', + }, + ], + + mockVariablesDisplay: [ + { + environment_scope: 'All (default)', + id: 113, + key: 'test_var', + masked: false, + protected: false, + protected_variable: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'Variable', + }, + { + environment_scope: 'All (default)', + id: 114, + key: 'test_var_2', + masked: false, + protected: false, + protected_variable: false, + secret_value: 'test_val_2', + value: 'test_val_2', + variable_type: 'File', + }, + ], + + mockEnvironments: [ + { + id: 28, + name: 'staging', + slug: 'staging', + external_url: 'https://staging.example.com', + state: 'available', + }, + { + id: 29, + name: 'production', + slug: 'production', + external_url: 'https://production.example.com', + state: 'available', + }, + ], + + mockPemCert: `-----BEGIN CERTIFICATE REQUEST----- + MIIB9TCCAWACAQAwgbgxGTAXBgNVBAoMEFF1b1ZhZGlzIExpbWl0ZWQxHDAaBgNV + BAsME0RvY3VtZW50IERlcGFydG1lbnQxOTA3BgNVBAMMMFdoeSBhcmUgeW91IGRl + Y29kaW5nIG1lPyAgVGhpcyBpcyBvbmx5IGEgdGVzdCEhITERMA8GA1UEBwwISGFt + aWx0b24xETAPBgNVBAgMCFBlbWJyb2tlMQswCQYDVQQGEwJCTTEPMA0GCSqGSIb3 + DQEJARYAMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCJ9WRanG/fUvcfKiGl + EL4aRLjGt537mZ28UU9/3eiJeJznNSOuNLnF+hmabAu7H0LT4K7EdqfF+XUZW/2j + RKRYcvOUDGF9A7OjW7UfKk1In3+6QDCi7X34RE161jqoaJjrm/T18TOKcgkkhRzE + apQnIDm0Ea/HVzX/PiSOGuertwIDAQABMAsGCSqGSIb3DQEBBQOBgQBzMJdAV4QP + Awel8LzGx5uMOshezF/KfP67wJ93UW+N7zXY6AwPgoLj4Kjw+WtU684JL8Dtr9FX + ozakE+8p06BpxegR4BR3FMHf6p+0jQxUEAkAyb/mVgm66TyghDGC6/YkiKoZptXQ + 98TwDIK/39WEB/V607As+KoYazQG8drorw== + -----END CERTIFICATE REQUEST-----`, + + mockVariableScopes: [ + { + id: 13, + key: 'test_var_1', + value: 'test_val_1', + variable_type: 'File', + protected: true, + masked: true, + environment_scope: 'All (default)', + secret_value: 'test_val_1', + }, + { + id: 28, + key: 'goku_var', + value: 'goku_val', + variable_type: 'Variable', + protected: true, + masked: true, + environment_scope: 'staging', + secret_value: 'goku_val', + }, + { + id: 25, + key: 'test_var_4', + value: 'test_val_4', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_4', + }, + { + id: 14, + key: 'test_var_2', + value: 'test_val_2', + variable_type: 'File', + protected: false, + masked: false, + environment_scope: 'staging', + secret_value: 'test_val_2', + }, + { + id: 24, + key: 'test_var_3', + value: 'test_val_3', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'All (default)', + secret_value: 'test_val_3', + }, + { + id: 26, + key: 'test_var_5', + value: 'test_val_5', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_5', + }, + ], +}; diff --git a/spec/frontend/ci/ci_variable_list/stubs.js b/spec/frontend/ci/ci_variable_list/stubs.js new file mode 100644 index 00000000000..5769d6190f6 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/stubs.js @@ -0,0 +1,14 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, +}; + +export default ModalStub; diff --git a/spec/frontend/ci/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js new file mode 100644 index 00000000000..beeae71376a --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/utils_spec.js @@ -0,0 +1,78 @@ +import { + createJoinedEnvironments, + convertEnvironmentScope, + mapEnvironmentNames, +} from '~/ci/ci_variable_list/utils'; +import { allEnvironments } from '~/ci/ci_variable_list/constants'; + +describe('utils', () => { + const environments = ['dev', 'prod']; + const newEnvironments = ['staging']; + + describe('createJoinedEnvironments', () => { + it('returns only `environments` if `variables` argument is undefined', () => { + const variables = undefined; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual(environments); + }); + + it('returns a list of environments and environment scopes taken from variables in alphabetical order', () => { + const envScope1 = 'new1'; + const envScope2 = 'new2'; + + const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual([ + environments[0], + envScope1, + envScope2, + environments[1], + ]); + }); + + it('returns combined list with new environments included', () => { + const variables = undefined; + + expect(createJoinedEnvironments(variables, environments, newEnvironments)).toEqual([ + ...environments, + ...newEnvironments, + ]); + }); + + it('removes duplicate environments', () => { + const envScope1 = environments[0]; + const envScope2 = 'new2'; + + const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual([ + environments[0], + envScope2, + environments[1], + ]); + }); + }); + + describe('convertEnvironmentScope', () => { + it('converts the * to the `All environments` text', () => { + expect(convertEnvironmentScope('*')).toBe(allEnvironments.text); + }); + + it('returns the environment as is if not the *', () => { + expect(convertEnvironmentScope('prod')).toBe('prod'); + }); + }); + + describe('mapEnvironmentNames', () => { + const envName = 'dev'; + const envName2 = 'prod'; + + const nodes = [ + { name: envName, otherProp: {} }, + { name: envName2, otherProp: {} }, + ]; + it('flatten a nodes array with only their names', () => { + expect(mapEnvironmentNames(nodes)).toEqual([envName, envName2]); + }); + }); +}); |