From 4a4dac7d7c607fa490cee08f95cebc83edab87cf Mon Sep 17 00:00:00 2001 From: Alexander Turinske <aturinske@gitlab.com> Date: Thu, 30 Nov 2023 20:10:59 +0000 Subject: [PATCH] Add upload file with code button - create upload button component - create confirmation modal - add tests --- .../action/code_block_action.vue | 6 + .../action/code_block_import.vue | 123 ++++++++++++++++++ .../policy_editor/scan_execution/constants.js | 2 +- .../action/code_block_action_spec.js | 22 +++- .../action/code_block_import_spec.js | 106 +++++++++++++++ locale/gitlab.pot | 21 ++- 6 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue create mode 100644 ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_import_spec.js diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_action.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_action.vue index 9d6f68ac73fc..f38bcc02cf32 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_action.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_action.vue @@ -12,6 +12,7 @@ import { CUSTOM_ACTION_OPTIONS_KEYS, LINKED_EXISTING_FILE, } from '../constants'; +import CodeBlockImport from './code_block_import.vue'; export default { SCAN_EXECUTION_PATH: helpPagePath('user/application_security/policies/scan-execution-policies', { @@ -35,6 +36,7 @@ export default { name: 'CodeBlockAction', components: { PolicyPopover, + CodeBlockImport, GlCollapsibleListbox, GlFormInput, GlFormGroup, @@ -68,6 +70,9 @@ export default { filePath() { return this.initAction?.ci_configuration_path?.file; }, + hasExistingCode() { + return Boolean(this.yamlEditorValue.length); + }, isFirstAction() { return this.actionIndex === 0; }, @@ -185,6 +190,7 @@ export default { @input="updateYaml" /> </div> + <code-block-import :has-existing-code="hasExistingCode" @changed="updateYaml" /> </template> </section-layout> </div> diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue new file mode 100644 index 000000000000..3be3f5d25e80 --- /dev/null +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue @@ -0,0 +1,123 @@ +<script> +import { GlModal, GlTruncate } from '@gitlab/ui'; +import { sprintf, __, s__ } from '~/locale'; + +export default { + i18n: { + confirmTitle: s__( + "SecurityOrchestration|Overwrite the current CI/CD code with the new file's content?", + ), + uploadFileButtonText: s__('SecurityOrchestration|Load CI/CD code from file'), + uploadFileSuccess: s__('SecurityOrchestration|%{fileName} loaded succeeded.'), + uploadFileFailure: s__('SecurityOrchestration|%{fileName} loading failed. Please try again.'), + }, + name: 'CodeBlockImport', + components: { + GlModal, + GlTruncate, + }, + props: { + hasExistingCode: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + hasError: false, + showConfirmModal: false, + showStatus: false, + uploadedFileText: '', + uploadedFile: null, + }; + }, + computed: { + uploadStatusText() { + return this.hasError + ? sprintf(this.$options.i18n.uploadFileFailure, { + fileName: this.uploadedFile?.name || __('File'), + }) + : sprintf(this.$options.i18n.uploadFileSuccess, { fileName: this.uploadedFile.name }); + }, + }, + methods: { + clearInput() { + this.$refs.codeUploadFileInput.value = null; + }, + handleFileProcessed(element) { + this.uploadedFileText = element?.target?.result; + if (this.hasExistingCode) { + this.showConfirmModal = true; + } else { + this.handleConfirmUpload(); + } + }, + handleFileUpload(e) { + this.uploadedFile = e?.target?.files[0]; + if (this.uploadedFile) { + const processedFile = new FileReader(); + processedFile.readAsText(this.uploadedFile); + processedFile.onload = this.handleFileProcessed; + processedFile.onloadend = this.clearInput; + processedFile.onerror = this.showError; + } else { + this.showError(); + } + }, + handleConfirmUpload() { + this.$emit('changed', this.uploadedFileText); + this.hasError = false; + this.showConfirmModal = false; + this.showStatus = true; + }, + removeFile() { + this.uploadedFileText = ''; + this.uploadedFile = null; + this.showStatus = false; + this.showConfirmModal = false; + }, + showError() { + this.hasError = true; + this.showStatus = true; + }, + }, + modalId: 'confirm-upload-modal', + confirmOptions: { text: __('Load new file'), attributes: { variant: 'confirm' } }, + cancelOptions: { text: __('Cancel') }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <label for="code-upload" class="btn btn-default btn-md gl-button gl-mb-0 gl-font-weight-normal"> + {{ $options.i18n.uploadFileButtonText }} + </label> + <input + id="code-upload" + ref="codeUploadFileInput" + type="file" + accept=".yml" + name="code-upload" + hidden="true" + @change="handleFileUpload" + /> + <gl-truncate + v-if="showStatus" + class="gl-display-flex gl-align-items-center gl-ml-3 gl-mb-0 gl-max-w-62" + :text="uploadStatusText" + position="middle" + with-tooltip + /> + <gl-modal + v-model="showConfirmModal" + :modal-id="$options.modalId" + size="sm" + :title="$options.i18n.confirmTitle" + :action-primary="$options.confirmOptions" + :action-secondary="$options.cancelOptions" + @primary="handleConfirmUpload" + @secondary="removeFile" + /> + </div> +</template> diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/constants.js b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/constants.js index a5dfbb3244c2..23514ee723e6 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/constants.js +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_execution/constants.js @@ -72,7 +72,7 @@ export const EXECUTE_YAML_ACTION = 'execute_yaml_action'; export const SCAN_EXECUTION_ACTIONS = { [RUN_SCAN_ACTION]: s__('ScanExecutionPolicy|Run a scan'), - [EXECUTE_YAML_ACTION]: s__('ScanExecutionPolicy|Execute a YAML code block'), + [EXECUTE_YAML_ACTION]: s__('ScanExecutionPolicy|Run CI/CD code'), }; export const SCAN_EXECUTION_ACTIONS_LISTBOX_ITEMS = Object.entries( diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_action_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_action_spec.js index 1c85813f3b83..9ba101462f07 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_action_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_action_spec.js @@ -3,6 +3,7 @@ import { GlSprintf, GlCollapsibleListbox, GlFormInput } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import CodeBlockAction from 'ee/security_orchestration/components/policy_editor/scan_execution/action/code_block_action.vue'; +import CodeBlockImport from 'ee/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue'; import PolicyPopover from 'ee/security_orchestration/components/policy_popover.vue'; import { NAMESPACE_TYPES } from 'ee/security_orchestration/constants'; import YamlEditor from 'ee/security_orchestration/components/yaml_editor.vue'; @@ -38,16 +39,35 @@ describe('CodeBlockAction', () => { const findListBox = () => wrapper.findComponent(GlCollapsibleListbox); const findCodeBlockActionTooltip = () => wrapper.findComponent(PolicyPopover); const findGlFormInput = () => wrapper.findComponent(GlFormInput); + const findCodeBlockImport = () => wrapper.findComponent(CodeBlockImport); describe('default state', () => { it('should render yaml editor in default state', async () => { createComponent(); - await waitForPromises(); expect(findYamlEditor().exists()).toBe(true); expect(findCodeBlockActionTooltip().exists()).toBe(true); expect(findListBox().props('selected')).toBe(''); expect(findListBox().props('toggleText')).toBe('Choose a method to execute code'); + expect(findCodeBlockImport().props('hasExistingCode')).toBe(false); + }); + }); + + describe('code block', () => { + it('should render the import button when code exists', async () => { + createComponent(); + await waitForPromises(); + await findYamlEditor().vm.$emit('input', 'foo: bar'); + expect(findCodeBlockImport().props('hasExistingCode')).toBe(true); + }); + + it('updates the yaml when a file is imported', async () => { + const fileContents = 'foo: bar'; + createComponent(); + await waitForPromises(); + await findCodeBlockImport().vm.$emit('changed', fileContents); + expect(findYamlEditor().props('value')).toBe(fileContents); + expect(findCodeBlockImport().props('hasExistingCode')).toBe(true); }); }); diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_import_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_import_spec.js new file mode 100644 index 000000000000..941cec8efc3f --- /dev/null +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_execution/action/code_block_import_spec.js @@ -0,0 +1,106 @@ +import { GlModal, GlTruncate } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import CodeBlockImport from 'ee/security_orchestration/components/policy_editor/scan_execution/action/code_block_import.vue'; + +describe('CodeBlockImport', () => { + let wrapper; + const fileOneText = 'foo: bar'; + const fileTwoText = 'fizz: buzz'; + const fileOneName = 'code-block.yml'; + const fileTwoName = 'best-code-block.yml'; + const fileOne = new File([fileOneText], fileOneName); + const fileTwo = new File([fileTwoText], fileTwoName); + + const uploadFile = async (file) => { + const input = wrapper.find('input[type="file"]'); + Object.defineProperty(input.element, 'files', { value: [file], configurable: true }); + input.trigger('change', file); + // when loading a file, two waits are needed as it is a multi-step process + await waitForPromises(); + await waitForPromises(); + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(CodeBlockImport, { + propsData: { + hasExistingCode: false, + ...propsData, + }, + }); + }; + + const findStatus = () => wrapper.findComponent(GlTruncate); + const findUploadButton = () => wrapper.find('label'); + const findConfirmationModal = () => wrapper.findComponent(GlModal); + + describe('initial load', () => { + it('renders the correct components', () => { + createComponent(); + expect(findUploadButton().exists()).toBe(true); + expect(findStatus().exists()).toBe(false); + expect(findConfirmationModal().props('visible')).toBe(false); + }); + }); + + describe('uploading a file', () => { + beforeEach(() => { + jest.spyOn(FileReader.prototype, 'readAsText'); + createComponent(); + }); + + it('does not show the confirmation modal', async () => { + await uploadFile(fileOne); + expect(findConfirmationModal().props('visible')).toBe(false); + }); + + it('uploads the file contents', async () => { + await uploadFile(fileOne); + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(fileOne); + expect(wrapper.emitted('changed')[0]).toStrictEqual([fileOneText]); + }); + + it('shows the success status', async () => { + await uploadFile(fileOne); + expect(findStatus().exists()).toBe(true); + expect(findStatus().props('text')).toBe(`${fileOneName} loaded succeeded.`); + }); + }); + + describe('uploading a file that overwrites code', () => { + beforeEach(() => { + createComponent({ propsData: { hasExistingCode: true } }); + }); + + it('shows the confirmation modal on upload', async () => { + await uploadFile(fileOne); + expect(wrapper.emitted('changed')).toBeUndefined(); + expect(findConfirmationModal().props('visible')).toBe(true); + }); + + it('uploads the file contents on confirm', async () => { + await uploadFile(fileOne); + await findConfirmationModal().vm.$emit('primary'); + expect(findConfirmationModal().props('visible')).toBe(false); + expect(wrapper.emitted('changed')[0]).toStrictEqual([fileOneText]); + }); + + it('does not overwrite the code on cancel', async () => { + await uploadFile(fileOne); + await findConfirmationModal().vm.$emit('secondary'); + expect(findConfirmationModal().props('visible')).toBe(false); + expect(wrapper.emitted('changed')).toBeUndefined(); + }); + + it('uploads the file contents on confirm multiple times', async () => { + await uploadFile(fileOne); + await findConfirmationModal().vm.$emit('primary'); + await uploadFile(fileTwo); + await findConfirmationModal().vm.$emit('primary'); + expect(findConfirmationModal().props('visible')).toBe(false); + expect(findStatus().props('text')).toBe(`${fileTwoName} loaded succeeded.`); + expect(wrapper.emitted('changed')).toHaveLength(2); + expect(wrapper.emitted('changed')[1]).toStrictEqual([fileTwoText]); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f1e69d63ddd3..87908b840485 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -28729,6 +28729,9 @@ msgstr "" msgid "Load more users" msgstr "" +msgid "Load new file" +msgstr "" + msgid "Loading" msgstr "" @@ -42248,9 +42251,6 @@ msgstr "" msgid "ScanExecutionPolicy|DAST site profiles" msgstr "" -msgid "ScanExecutionPolicy|Execute a YAML code block" -msgstr "" - msgid "ScanExecutionPolicy|If there are any conflicting variables with the local pipeline configuration (Ex, gitlab-ci.yml) then variables defined here will take precedence. %{linkStart}Learn more%{linkEnd}." msgstr "" @@ -42275,6 +42275,9 @@ msgstr "" msgid "ScanExecutionPolicy|Only one variable can be added at a time." msgstr "" +msgid "ScanExecutionPolicy|Run CI/CD code" +msgstr "" + msgid "ScanExecutionPolicy|Run a %{scan} scan with the following options:" msgstr "" @@ -43164,6 +43167,12 @@ msgstr "" msgid "SecurityOrchestration|%{cadence} on %{branches}%{branchExceptionsString}" msgstr "" +msgid "SecurityOrchestration|%{fileName} loaded succeeded." +msgstr "" + +msgid "SecurityOrchestration|%{fileName} loading failed. Please try again." +msgstr "" + msgid "SecurityOrchestration|%{licenses} and %{lastLicense}" msgstr "" @@ -43383,6 +43392,9 @@ msgstr "" msgid "SecurityOrchestration|License Scan" msgstr "" +msgid "SecurityOrchestration|Load CI/CD code from file" +msgstr "" + msgid "SecurityOrchestration|Logic error" msgstr "" @@ -43436,6 +43448,9 @@ msgstr "" msgid "SecurityOrchestration|Override the following project settings:" msgstr "" +msgid "SecurityOrchestration|Overwrite the current CI/CD code with the new file's content?" +msgstr "" + msgid "SecurityOrchestration|Policies" msgstr "" -- GitLab