From e7e244f1c4527d577f1f738d041fda6c9f6f3fc7 Mon Sep 17 00:00:00 2001
From: Jiaan Louw <3468028-jiaan@users.noreply.gitlab.com>
Date: Mon, 16 Nov 2020 13:15:04 +0000
Subject: [PATCH] Add export for merge commit custody report

This adds a export dropdown to the existing list all
merge commits button in the Compliance Dashboard, it
allows for a merge commit-specific chain of custody
report to be generate for the provided commit hash.
---
 .../javascripts/lib/utils/text_utility.js     |  12 +++
 .../merge_commits_export_button.vue           | 100 +++++++++++++++---
 .../compliance_dashboard/constants.js         |   4 +
 ...-commit-sha-specific-chain-of-custody-.yml |   6 ++
 .../security/compliance_dashboards_spec.rb    |  26 ++++-
 .../merge_commits_export_button_spec.js.snap  |  37 -------
 .../merge_commits_export_button_spec.js       |  92 +++++++++++++---
 locale/gitlab.pot                             |   9 ++
 spec/frontend/lib/utils/text_utility_spec.js  |  15 +++
 9 files changed, 233 insertions(+), 68 deletions(-)
 create mode 100644 ee/changelogs/unreleased/267629-implementation-generate-a-commit-sha-specific-chain-of-custody-.yml
 delete mode 100644 ee/spec/frontend/compliance_dashboard/components/merge_requests/__snapshots__/merge_commits_export_button_spec.js.snap

diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 8ac6a44cba9..a81ca3f211f 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -399,3 +399,15 @@ export const truncateNamespace = (string = '') => {
  * @returns {Boolean}
  */
 export const hasContent = obj => isString(obj) && obj.trim() !== '';
+
+/**
+ * A utility function that validates if a
+ * string is valid SHA1 hash format.
+ *
+ * @param {String} hash to validate
+ *
+ * @return {Boolean} true if valid
+ */
+export const isValidSha1Hash = str => {
+  return /^[0-9a-f]{5,40}$/.test(str);
+};
diff --git a/ee/app/assets/javascripts/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue b/ee/app/assets/javascripts/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue
index 7dd9658d02b..b5f9ded2703 100644
--- a/ee/app/assets/javascripts/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue
+++ b/ee/app/assets/javascripts/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue
@@ -1,10 +1,26 @@
 <script>
-import { GlButton, GlTooltip } from '@gitlab/ui';
+import {
+  GlButton,
+  GlDropdown,
+  GlDropdownForm,
+  GlForm,
+  GlFormGroup,
+  GlFormInput,
+  GlTooltip,
+} from '@gitlab/ui';
+
+import { isValidSha1Hash } from '~/lib/utils/text_utility';
 import { __ } from '~/locale';
+import { INPUT_DEBOUNCE, CUSTODY_REPORT_PARAMETER } from '../../constants';
 
 export default {
   components: {
     GlButton,
+    GlDropdown,
+    GlDropdownForm,
+    GlForm,
+    GlFormGroup,
+    GlFormInput,
     GlTooltip,
   },
   props: {
@@ -13,33 +29,85 @@ export default {
       type: String,
     },
   },
-  strings: {
-    listMergeCommitsButtonText: __('List of all merge commits'),
-    exportAsCsv: __('Export as CSV'),
-    csvSizeLimit: __('(max size 15 MB)'),
-  },
   data() {
     return {
-      button: null,
+      validMergeCommitHash: null,
+      listMergeCommitsButton: null,
     };
   },
+  computed: {
+    mergeCommitButtonDisabled() {
+      return !this.validMergeCommitHash;
+    },
+  },
   mounted() {
-    this.button = this.$refs.button;
+    this.listMergeCommitsButton = this.$refs.listMergeCommitsButton;
+  },
+  methods: {
+    onInput(value) {
+      this.validMergeCommitHash = isValidSha1Hash(value);
+    },
+  },
+  strings: {
+    mergeCommitInputLabel: __('Merge commit SHA'),
+    mergeCommitInvalidMessage: __('Invalid hash'),
+    mergeCommitButtonText: __('Export commit custody report'),
+    listMergeCommitsButtonText: __('List of all merge commits'),
+    exportAsCsv: __('Export as CSV'),
+    csvSizeLimit: __('(max size 15 MB)'),
   },
+  inputDebounce: INPUT_DEBOUNCE,
+  custodyReportParamater: CUSTODY_REPORT_PARAMETER,
 };
 </script>
 
 <template>
   <div>
-    <gl-button
-      ref="button"
-      :href="mergeCommitsCsvExportPath"
-      icon="export"
-      class="gl-align-self-center"
+    <gl-dropdown split>
+      <template #button-content>
+        <gl-button
+          ref="listMergeCommitsButton"
+          class="gl-p-0!"
+          category="tertiary"
+          icon="export"
+          :href="mergeCommitsCsvExportPath"
+        >
+          {{ $options.strings.listMergeCommitsButtonText }}
+        </gl-button>
+      </template>
+      <gl-dropdown-form>
+        <gl-form :action="mergeCommitsCsvExportPath" method="GET">
+          <gl-form-group
+            :label="$options.strings.mergeCommitInputLabel"
+            :invalid-feedback="$options.strings.mergeCommitInvalidMessage"
+            :state="validMergeCommitHash"
+            label-size="sm"
+            label-for="merge-commits-export-custody-report"
+          >
+            <gl-form-input
+              id="merge-commits-export-custody-report"
+              :name="$options.custodyReportParamater"
+              :debounce="$options.inputDebounce"
+              @input="onInput"
+            />
+          </gl-form-group>
+          <gl-button
+            :disabled="mergeCommitButtonDisabled"
+            type="submit"
+            variant="success"
+            data-test-id="merge-commit-submit-button"
+            class="gl-hover-text-white!"
+            >{{ $options.strings.mergeCommitButtonText }}</gl-button
+          >
+        </gl-form>
+      </gl-dropdown-form>
+    </gl-dropdown>
+    <gl-tooltip
+      v-if="listMergeCommitsButton"
+      :target="listMergeCommitsButton"
+      boundary="viewport"
+      placement="top"
     >
-      {{ $options.strings.listMergeCommitsButtonText }}
-    </gl-button>
-    <gl-tooltip v-if="button" :target="button" boundary="viewport" placement="top">
       <p class="gl-my-0">{{ $options.strings.exportAsCsv }}</p>
       <p class="gl-my-0">{{ $options.strings.csvSizeLimit }}</p>
     </gl-tooltip>
diff --git a/ee/app/assets/javascripts/compliance_dashboard/constants.js b/ee/app/assets/javascripts/compliance_dashboard/constants.js
index d4c8c7326a2..be0e2fd5aee 100644
--- a/ee/app/assets/javascripts/compliance_dashboard/constants.js
+++ b/ee/app/assets/javascripts/compliance_dashboard/constants.js
@@ -1,3 +1,7 @@
 export const PRESENTABLE_APPROVERS_LIMIT = 2;
 
 export const COMPLIANCE_TAB_COOKIE_KEY = 'compliance_dashboard_tabs';
+
+export const INPUT_DEBOUNCE = 500;
+
+export const CUSTODY_REPORT_PARAMETER = 'commit_sha';
diff --git a/ee/changelogs/unreleased/267629-implementation-generate-a-commit-sha-specific-chain-of-custody-.yml b/ee/changelogs/unreleased/267629-implementation-generate-a-commit-sha-specific-chain-of-custody-.yml
new file mode 100644
index 00000000000..a14fd43f9c0
--- /dev/null
+++ b/ee/changelogs/unreleased/267629-implementation-generate-a-commit-sha-specific-chain-of-custody-.yml
@@ -0,0 +1,6 @@
+---
+title: Chain of custody reports in the compliance dashboard can now also be generated
+  for a specific merge commit.
+merge_request: 46994
+author:
+type: changed
diff --git a/ee/spec/features/groups/security/compliance_dashboards_spec.rb b/ee/spec/features/groups/security/compliance_dashboards_spec.rb
index ed90d044103..51275d76361 100644
--- a/ee/spec/features/groups/security/compliance_dashboards_spec.rb
+++ b/ee/spec/features/groups/security/compliance_dashboards_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe 'Compliance Dashboard', :js do
   let_it_be(:user) { current_user }
   let_it_be(:group) { create(:group) }
   let_it_be(:project) { create(:project, :repository, :public, namespace: group) }
+  let_it_be(:project_2) { create(:project, :repository, :public, namespace: group) }
 
   before do
     stub_licensed_features(group_level_compliance_dashboard: true)
@@ -22,10 +23,12 @@ RSpec.describe 'Compliance Dashboard', :js do
   end
 
   context 'when there are merge requests' do
-    let_it_be(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
+    let_it_be(:merge_request) { create(:merge_request, source_project: project, state: :merged, merge_commit_sha: 'b71a6483b96dc303b66fdcaa212d9db6b10591ce') }
+    let_it_be(:merge_request_2) { create(:merge_request, source_project: project_2, state: :merged, merge_commit_sha: '24327319d067f4101cd3edd36d023ab5e49a8579') }
 
     before_all do
       create(:event, :merged, project: project, target: merge_request, author: user, created_at: 10.minutes.ago)
+      create(:event, :merged, project: project_2, target: merge_request_2, author: user, created_at: 15.minutes.ago)
     end
 
     it 'shows merge requests with details' do
@@ -33,5 +36,26 @@ RSpec.describe 'Compliance Dashboard', :js do
       expect(page).to have_content('merged 10 minutes ago')
       expect(page).to have_content('no approvers')
     end
+
+    context 'chain of custody report' do
+      it 'exports a merge commit-specific CSV' do
+        find('.dropdown-toggle').click
+
+        requests = inspect_requests do
+          page.within('.dropdown-menu') do
+            find('input[name="commit_sha"]').set(merge_request.merge_commit_sha)
+            find('button[type="submit"]').click
+          end
+        end
+
+        csv_request = requests.find { |req| req.url.match(%r{.csv}) }
+
+        expect(csv_request.response_headers['Content-Disposition']).to match(%r{.csv})
+        expect(csv_request.response_headers['Content-Type']).to eq("text/csv; charset=utf-8")
+        expect(csv_request.response_headers['Content-Transfer-Encoding']).to eq("binary")
+        expect(csv_request.body).to match(%r{#{merge_request.merge_commit_sha}})
+        expect(csv_request.body).not_to match(%r{#{merge_request_2.merge_commit_sha}})
+      end
+    end
   end
 end
diff --git a/ee/spec/frontend/compliance_dashboard/components/merge_requests/__snapshots__/merge_commits_export_button_spec.js.snap b/ee/spec/frontend/compliance_dashboard/components/merge_requests/__snapshots__/merge_commits_export_button_spec.js.snap
deleted file mode 100644
index 231c127283f..00000000000
--- a/ee/spec/frontend/compliance_dashboard/components/merge_requests/__snapshots__/merge_commits_export_button_spec.js.snap
+++ /dev/null
@@ -1,37 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`MergeCommitsExportButton component Merge commit CSV export button matches the snapshot 1`] = `
-<div>
-  <gl-button-stub
-    buttontextclasses=""
-    category="primary"
-    class="gl-align-self-center"
-    href="/merge_commit_reports"
-    icon="export"
-    size="medium"
-    variant="default"
-  >
-    
-    List of all merge commits
-  
-  </gl-button-stub>
-   
-  <gl-tooltip-stub
-    boundary="viewport"
-    placement="top"
-    target="[object Object]"
-  >
-    <p
-      class="gl-my-0"
-    >
-      Export as CSV
-    </p>
-     
-    <p
-      class="gl-my-0"
-    >
-      (max size 15 MB)
-    </p>
-  </gl-tooltip-stub>
-</div>
-`;
diff --git a/ee/spec/frontend/compliance_dashboard/components/merge_requests/merge_commits_export_button_spec.js b/ee/spec/frontend/compliance_dashboard/components/merge_requests/merge_commits_export_button_spec.js
index 7eeaecae304..9506ec57088 100644
--- a/ee/spec/frontend/compliance_dashboard/components/merge_requests/merge_commits_export_button_spec.js
+++ b/ee/spec/frontend/compliance_dashboard/components/merge_requests/merge_commits_export_button_spec.js
@@ -1,38 +1,40 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import { GlFormInput, GlForm, GlFormGroup } from '@gitlab/ui';
 
 import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue';
+import { INPUT_DEBOUNCE, CUSTODY_REPORT_PARAMETER } from 'ee/compliance_dashboard/constants';
 
 const CSV_EXPORT_PATH = '/merge_commit_reports';
 
 describe('MergeCommitsExportButton component', () => {
   let wrapper;
 
-  const findCsvExportButton = () => wrapper.find(GlButton);
+  const findCommitForm = () => wrapper.find(GlForm);
+  const findCommitInput = () => wrapper.find(GlFormInput);
+  const findCommitInputGroup = () => wrapper.find(GlFormGroup);
+  const findCommitInputFeedback = () => wrapper.find('.invalid-feedback');
+  const findCommitExportButton = () => wrapper.find('[data-test-id="merge-commit-submit-button"]');
+  const findCsvExportButton = () => wrapper.find({ ref: 'listMergeCommitsButton' });
 
-  const createComponent = (props = {}) => {
-    return shallowMount(MergeCommitsExportButton, {
+  const createComponent = ({ mountFn = shallowMount, data = {} } = {}) => {
+    return mountFn(MergeCommitsExportButton, {
       propsData: {
         mergeCommitsCsvExportPath: CSV_EXPORT_PATH,
-        ...props,
       },
+      data: () => data,
     });
   };
 
-  beforeEach(() => {
-    wrapper = createComponent();
-  });
-
   afterEach(() => {
     wrapper.destroy();
   });
 
-  describe('Merge commit CSV export button', () => {
-    it('matches the snapshot', () => {
-      expect(wrapper.element).toMatchSnapshot();
+  describe('Merge commit CSV export all button', () => {
+    beforeEach(() => {
+      wrapper = createComponent({ mountFn: mount });
     });
 
-    it('renders the merge commits csv export button', () => {
+    it('renders the button', () => {
       expect(findCsvExportButton().exists()).toBe(true);
     });
 
@@ -44,4 +46,66 @@ describe('MergeCommitsExportButton component', () => {
       expect(findCsvExportButton().attributes('href')).toEqual(CSV_EXPORT_PATH);
     });
   });
+
+  describe('Merge commit custody report', () => {
+    beforeEach(() => {
+      wrapper = createComponent();
+    });
+
+    it('renders the input label', () => {
+      expect(findCommitInputGroup().attributes('label')).toBe('Merge commit SHA');
+    });
+
+    it('sets the input debounce time', () => {
+      expect(findCommitInput().attributes('debounce')).toEqual(INPUT_DEBOUNCE.toString());
+    });
+
+    it('sets the input name', () => {
+      expect(findCommitInput().attributes('name')).toEqual(CUSTODY_REPORT_PARAMETER);
+    });
+
+    it('sets the form action to the csv download path', () => {
+      expect(findCommitForm().attributes('action')).toEqual(CSV_EXPORT_PATH);
+    });
+
+    it('sets the invalid input feedback message', () => {
+      wrapper = createComponent({ mountFn: mount });
+
+      expect(findCommitInputFeedback().text()).toBe('Invalid hash');
+    });
+
+    describe('when the commit input is valid', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          mountFn: mount,
+          data: { validMergeCommitHash: true },
+        });
+      });
+
+      it('shows that the input is valid', () => {
+        expect(findCommitInputGroup().classes('is-invalid')).toBe(false);
+      });
+
+      it('enables the submit button', () => {
+        expect(findCommitExportButton().props('disabled')).toBe(false);
+      });
+    });
+
+    describe('when the commit input is invalid', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          mountFn: mount,
+          data: { validMergeCommitHash: false },
+        });
+      });
+
+      it('shows that the input is invalid', () => {
+        expect(findCommitInputGroup().classes('is-invalid')).toBe(true);
+      });
+
+      it('disables the submit button', () => {
+        expect(findCommitExportButton().props('disabled')).toBe(true);
+      });
+    });
+  });
 });
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index cf92cee430c..98a72047f9a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -11172,6 +11172,9 @@ msgstr ""
 msgid "Export as CSV"
 msgstr ""
 
+msgid "Export commit custody report"
+msgstr ""
+
 msgid "Export group"
 msgstr ""
 
@@ -14768,6 +14771,9 @@ msgstr ""
 msgid "Invalid file."
 msgstr ""
 
+msgid "Invalid hash"
+msgstr ""
+
 msgid "Invalid import params"
 msgstr ""
 
@@ -16842,6 +16848,9 @@ msgstr ""
 msgid "Merge automatically (%{strategy})"
 msgstr ""
 
+msgid "Merge commit SHA"
+msgstr ""
+
 msgid "Merge commit message"
 msgstr ""
 
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 6fef5f6b63c..d7cedb939d2 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -325,4 +325,19 @@ describe('text_utility', () => {
       expect(textUtils.hasContent(txt)).toEqual(result);
     });
   });
+
+  describe('isValidSha1Hash', () => {
+    const validSha1Hash = '92d10c15';
+    const stringOver40 = new Array(42).join('a');
+
+    it.each`
+      hash              | valid
+      ${validSha1Hash}  | ${true}
+      ${'__characters'} | ${false}
+      ${'abc'}          | ${false}
+      ${stringOver40}   | ${false}
+    `(`returns $valid for $hash`, ({ hash, valid }) => {
+      expect(textUtils.isValidSha1Hash(hash)).toBe(valid);
+    });
+  });
 });
-- 
2.30.9