Commit e7e244f1 authored by Jiaan Louw's avatar Jiaan Louw Committed by Brandon Labuschagne

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.
parent 27736c38
......@@ -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);
};
<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>
......
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';
---
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
......@@ -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
// 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>
`;
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);
});
});
});
});
......@@ -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 ""
......
......@@ -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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment