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