Commit b53f0806 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch...

Merge branch '267629-implementation-generate-a-commit-sha-specific-chain-of-custody-report-frontend' into 'master'

Generate a commit SHA-specific chain of custody report [Frontend]

See merge request gitlab-org/gitlab!46994
parents 5ef1af72 e7e244f1
...@@ -399,3 +399,15 @@ export const truncateNamespace = (string = '') => { ...@@ -399,3 +399,15 @@ export const truncateNamespace = (string = '') => {
* @returns {Boolean} * @returns {Boolean}
*/ */
export const hasContent = obj => isString(obj) && obj.trim() !== ''; 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> <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 { __ } from '~/locale';
import { INPUT_DEBOUNCE, CUSTODY_REPORT_PARAMETER } from '../../constants';
export default { export default {
components: { components: {
GlButton, GlButton,
GlDropdown,
GlDropdownForm,
GlForm,
GlFormGroup,
GlFormInput,
GlTooltip, GlTooltip,
}, },
props: { props: {
...@@ -13,33 +29,85 @@ export default { ...@@ -13,33 +29,85 @@ export default {
type: String, type: String,
}, },
}, },
strings: {
listMergeCommitsButtonText: __('List of all merge commits'),
exportAsCsv: __('Export as CSV'),
csvSizeLimit: __('(max size 15 MB)'),
},
data() { data() {
return { return {
button: null, validMergeCommitHash: null,
listMergeCommitsButton: null,
}; };
}, },
computed: {
mergeCommitButtonDisabled() {
return !this.validMergeCommitHash;
},
},
mounted() { 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> </script>
<template> <template>
<div> <div>
<gl-dropdown split>
<template #button-content>
<gl-button <gl-button
ref="button" ref="listMergeCommitsButton"
:href="mergeCommitsCsvExportPath" class="gl-p-0!"
category="tertiary"
icon="export" icon="export"
class="gl-align-self-center" :href="mergeCommitsCsvExportPath"
> >
{{ $options.strings.listMergeCommitsButtonText }} {{ $options.strings.listMergeCommitsButtonText }}
</gl-button> </gl-button>
<gl-tooltip v-if="button" :target="button" boundary="viewport" placement="top"> </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"
>
<p class="gl-my-0">{{ $options.strings.exportAsCsv }}</p> <p class="gl-my-0">{{ $options.strings.exportAsCsv }}</p>
<p class="gl-my-0">{{ $options.strings.csvSizeLimit }}</p> <p class="gl-my-0">{{ $options.strings.csvSizeLimit }}</p>
</gl-tooltip> </gl-tooltip>
......
export const PRESENTABLE_APPROVERS_LIMIT = 2; export const PRESENTABLE_APPROVERS_LIMIT = 2;
export const COMPLIANCE_TAB_COOKIE_KEY = 'compliance_dashboard_tabs'; 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 ...@@ -7,6 +7,7 @@ RSpec.describe 'Compliance Dashboard', :js do
let_it_be(:user) { current_user } let_it_be(:user) { current_user }
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, :public, namespace: group) } let_it_be(:project) { create(:project, :repository, :public, namespace: group) }
let_it_be(:project_2) { create(:project, :repository, :public, namespace: group) }
before do before do
stub_licensed_features(group_level_compliance_dashboard: true) stub_licensed_features(group_level_compliance_dashboard: true)
...@@ -22,10 +23,12 @@ RSpec.describe 'Compliance Dashboard', :js do ...@@ -22,10 +23,12 @@ RSpec.describe 'Compliance Dashboard', :js do
end end
context 'when there are merge requests' do 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 before_all do
create(:event, :merged, project: project, target: merge_request, author: user, created_at: 10.minutes.ago) 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 end
it 'shows merge requests with details' do it 'shows merge requests with details' do
...@@ -33,5 +36,26 @@ RSpec.describe 'Compliance Dashboard', :js 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('merged 10 minutes ago')
expect(page).to have_content('no approvers') expect(page).to have_content('no approvers')
end 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
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 { mount, shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui'; import { GlFormInput, GlForm, GlFormGroup } from '@gitlab/ui';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue'; 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'; const CSV_EXPORT_PATH = '/merge_commit_reports';
describe('MergeCommitsExportButton component', () => { describe('MergeCommitsExportButton component', () => {
let wrapper; 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 = {}) => { const createComponent = ({ mountFn = shallowMount, data = {} } = {}) => {
return shallowMount(MergeCommitsExportButton, { return mountFn(MergeCommitsExportButton, {
propsData: { propsData: {
mergeCommitsCsvExportPath: CSV_EXPORT_PATH, mergeCommitsCsvExportPath: CSV_EXPORT_PATH,
...props,
}, },
data: () => data,
}); });
}; };
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('Merge commit CSV export button', () => { describe('Merge commit CSV export all button', () => {
it('matches the snapshot', () => { beforeEach(() => {
expect(wrapper.element).toMatchSnapshot(); wrapper = createComponent({ mountFn: mount });
}); });
it('renders the merge commits csv export button', () => { it('renders the button', () => {
expect(findCsvExportButton().exists()).toBe(true); expect(findCsvExportButton().exists()).toBe(true);
}); });
...@@ -44,4 +46,66 @@ describe('MergeCommitsExportButton component', () => { ...@@ -44,4 +46,66 @@ describe('MergeCommitsExportButton component', () => {
expect(findCsvExportButton().attributes('href')).toEqual(CSV_EXPORT_PATH); 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 "" ...@@ -11172,6 +11172,9 @@ msgstr ""
msgid "Export as CSV" msgid "Export as CSV"
msgstr "" msgstr ""
msgid "Export commit custody report"
msgstr ""
msgid "Export group" msgid "Export group"
msgstr "" msgstr ""
...@@ -14780,6 +14783,9 @@ msgstr "" ...@@ -14780,6 +14783,9 @@ msgstr ""
msgid "Invalid file." msgid "Invalid file."
msgstr "" msgstr ""
msgid "Invalid hash"
msgstr ""
msgid "Invalid import params" msgid "Invalid import params"
msgstr "" msgstr ""
...@@ -16854,6 +16860,9 @@ msgstr "" ...@@ -16854,6 +16860,9 @@ msgstr ""
msgid "Merge automatically (%{strategy})" msgid "Merge automatically (%{strategy})"
msgstr "" msgstr ""
msgid "Merge commit SHA"
msgstr ""
msgid "Merge commit message" msgid "Merge commit message"
msgstr "" msgstr ""
......
...@@ -325,4 +325,19 @@ describe('text_utility', () => { ...@@ -325,4 +325,19 @@ describe('text_utility', () => {
expect(textUtils.hasContent(txt)).toEqual(result); 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