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