Commit 495f80a7 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '323375-move-security-config-status-strings-to-frontend' into 'master'

Move Security Configuration scanner status strings to frontend

See merge request gitlab-org/gitlab!56555
parents 2ab8ab30 ff560f08
/**
* Return the union of the given components' props options. Required props take
* precendence over non-required props of the same name.
*
* This makes two assumptions:
* - All given components define their props in verbose object format.
* - The components all agree on the `type` of a common prop.
*
* @param {object[]} components The components to derive the union from.
* @returns {object} The union of the props of the given components.
*/
export const propsUnion = (components) =>
components.reduce((acc, component) => {
Object.entries(component.props ?? {}).forEach(([propName, propOptions]) => {
if (process.env.NODE_ENV !== 'production') {
if (typeof propOptions !== 'object' || !('type' in propOptions)) {
throw new Error(
`Cannot create props union: expected verbose prop options for prop "${propName}"`,
);
}
if (propName in acc && acc[propName]?.type !== propOptions?.type) {
throw new Error(
`Cannot create props union: incompatible prop types for prop "${propName}"`,
);
}
}
if (!(propName in acc) || propOptions.required) {
acc[propName] = propOptions;
}
});
return acc;
}, {});
...@@ -88,6 +88,7 @@ export default { ...@@ -88,6 +88,7 @@ export default {
:feature="item" :feature="item"
:gitlab-ci-present="gitlabCiPresent" :gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath" :gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled"
/> />
</template> </template>
......
<script> <script>
import { GlLink } from '@gitlab/ui'; import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST_PROFILES,
} from '~/vue_shared/security_reports/constants';
import StatusDastProfiles from './status_dast_profiles.vue';
import StatusGeneric from './status_generic.vue';
import StatusSast from './status_sast.vue';
const scannerComponentMap = {
[REPORT_TYPE_SAST]: StatusSast,
[REPORT_TYPE_DAST_PROFILES]: StatusDastProfiles,
};
export default { export default {
components: { inheritAttrs: false,
GlLink, props: propsUnion([StatusGeneric, ...Object.values(scannerComponentMap)]),
},
props: {
feature: {
type: Object,
required: true,
},
gitlabCiPresent: {
type: Boolean,
required: false,
default: false,
},
gitlabCiHistoryPath: {
type: String,
required: false,
default: '',
},
},
computed: { computed: {
canViewCiHistory() { statusComponent() {
const { type, configured } = this.feature; return scannerComponentMap[this.feature.type] ?? StatusGeneric;
return type === 'sast' && configured && this.gitlabCiPresent;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div> <component :is="statusComponent" v-bind="$props" />
{{ feature.status }}
<template v-if="canViewCiHistory">
<br />
<gl-link :href="gitlabCiHistoryPath">{{ s__('SecurityConfiguration|View history') }}</gl-link>
</template>
</div>
</template> </template>
<script>
import { s__ } from '~/locale';
export default {
inheritAttrs: false,
i18n: {
availableForOnDemand: s__('SecurityConfiguration|Available for on-demand DAST'),
},
};
</script>
<template>
<div>{{ $options.i18n.availableForOnDemand }}</div>
</template>
<script>
import { s__ } from '~/locale';
export default {
inheritAttrs: false,
props: {
feature: {
type: Object,
required: true,
},
autoDevopsEnabled: {
type: Boolean,
required: true,
},
},
computed: {
status() {
if (this.feature.configured) {
return this.autoDevopsEnabled
? this.$options.i18n.enabledWithAutoDevOps
: this.$options.i18n.enabled;
}
return this.$options.i18n.notEnabled;
},
},
i18n: {
enabled: s__('SecurityConfiguration|Enabled'),
enabledWithAutoDevOps: s__('SecurityConfiguration|Enabled with Auto DevOps'),
notEnabled: s__('SecurityConfiguration|Not enabled'),
},
};
</script>
<template>
<div>{{ status }}</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import StatusGeneric from './status_generic.vue';
export default {
components: {
GlLink,
StatusGeneric,
},
inheritAttrs: false,
props: {
feature: {
type: Object,
required: true,
},
autoDevopsEnabled: {
type: Boolean,
required: true,
},
gitlabCiPresent: {
type: Boolean,
required: false,
default: false,
},
gitlabCiHistoryPath: {
type: String,
required: false,
default: '',
},
},
computed: {
canViewCiHistory() {
return this.feature.configured && this.gitlabCiPresent;
},
},
};
</script>
<template>
<div>
<status-generic :feature="feature" :auto-devops-enabled="autoDevopsEnabled" />
<gl-link v-if="canViewCiHistory" :href="gitlabCiHistoryPath">{{
s__('SecurityConfiguration|View history')
}}</gl-link>
</div>
</template>
...@@ -58,14 +58,11 @@ module Projects ...@@ -58,14 +58,11 @@ module Projects
def features def features
scans = scan_types.map do |scan_type| scans = scan_types.map do |scan_type|
if scanner_enabled?(scan_type) scan(scan_type, configured: scanner_enabled?(scan_type))
scan(scan_type, configured: true, status: auto_devops_source? ? s_('SecurityConfiguration|Enabled with Auto DevOps') : s_('SecurityConfiguration|Enabled'))
else
scan(scan_type, configured: false, status: s_('SecurityConfiguration|Not enabled'))
end
end end
dast_profiles_insert(scans) # DAST On-demand scans is a static (non job) entry. Add it manually.
scans << scan(:dast_profiles, configured: true)
end end
def latest_pipeline_path def latest_pipeline_path
...@@ -74,23 +71,10 @@ module Projects ...@@ -74,23 +71,10 @@ module Projects
project_pipeline_path(self, latest_default_branch_pipeline) project_pipeline_path(self, latest_default_branch_pipeline)
end end
# DAST On-demand scans is a static (non job) entry. Add it manually following DAST def scan(type, configured: false)
# TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/323375
def dast_profiles_insert(scans)
index = scans.index { |scan| scan[:type] == :dast }
unless index.nil?
scans.insert(index + 1, scan(:dast_profiles, configured: true, status: s_('SecurityConfiguration|Available for on-demand DAST')))
end
scans
end
def scan(type, configured: false, status:)
{ {
type: type, type: type,
configured: configured, configured: configured,
status: status,
configuration_path: configuration_path(type) configuration_path: configuration_path(type)
} }
end end
......
...@@ -65,6 +65,7 @@ describe('ConfigurationTable component', () => { ...@@ -65,6 +65,7 @@ describe('ConfigurationTable component', () => {
feature, feature,
gitlabCiPresent: propsData.gitlabCiPresent, gitlabCiPresent: propsData.gitlabCiPresent,
gitlabCiHistoryPath: propsData.gitlabCiHistoryPath, gitlabCiHistoryPath: propsData.gitlabCiHistoryPath,
autoDevopsEnabled: propsData.autoDevopsEnabled,
}); });
expect(manage.find(ManageFeature).props()).toEqual({ expect(manage.find(ManageFeature).props()).toEqual({
feature, feature,
......
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { pick } from 'lodash';
import FeatureStatus from 'ee/security_configuration/components/feature_status.vue'; import FeatureStatus from 'ee/security_configuration/components/feature_status.vue';
import StatusDastProfiles from 'ee/security_configuration/components/status_dast_profiles.vue';
import StatusGeneric from 'ee/security_configuration/components/status_generic.vue';
import StatusSast from 'ee/security_configuration/components/status_sast.vue';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST_PROFILES,
} from '~/vue_shared/security_reports/constants';
import { generateFeatures } from './helpers'; import { generateFeatures } from './helpers';
const gitlabCiHistoryPath = '/ci/history'; const props = {
gitlabCiPresent: true,
gitlabCiHistoryPath: '/ci-history',
autoDevopsEnabled: false,
};
const attrs = {
'data-foo': 'bar',
};
describe('FeatureStatus component', () => { describe('FeatureStatus component', () => {
let wrapper; let wrapper;
let feature;
const createComponent = (options) => { const createComponent = (options) => {
wrapper = shallowMount(FeatureStatus, options); wrapper = shallowMount(FeatureStatus, options);
...@@ -15,39 +29,37 @@ describe('FeatureStatus component', () => { ...@@ -15,39 +29,37 @@ describe('FeatureStatus component', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
feature = undefined;
}); });
const findHistoryLink = () => wrapper.find(GlLink);
describe.each` describe.each`
context | type | configured | gitlabCiPresent | shouldShowHistory type | expectedComponent
${'no CI with sast disabled'} | ${'sast'} | ${false} | ${false} | ${false} ${REPORT_TYPE_SAST} | ${StatusSast}
${'CI with sast disabled'} | ${'sast'} | ${false} | ${true} | ${false} ${REPORT_TYPE_DAST_PROFILES} | ${StatusDastProfiles}
${'no CI with sast enabled'} | ${'sast'} | ${true} | ${false} | ${false} ${'foo'} | ${StatusGeneric}
${'CI with foo enabled'} | ${'foo'} | ${true} | ${true} | ${false} `('given a $type feature', ({ type, expectedComponent }) => {
${'CI with sast enabled'} | ${'sast'} | ${true} | ${true} | ${true} let feature;
`('given $context', ({ type, configured, gitlabCiPresent, shouldShowHistory }) => { let component;
beforeEach(() => { beforeEach(() => {
[feature] = generateFeatures(1, { type, configured }); [feature] = generateFeatures(1, { type });
createComponent({ createComponent({
propsData: { feature, gitlabCiPresent, gitlabCiHistoryPath }, propsData: { feature, ...props },
attrs,
}); });
});
it('shows feature status text', () => { component = wrapper.findComponent(expectedComponent);
expect(wrapper.text()).toContain(feature.status);
}); });
it(`${shouldShowHistory ? 'shows' : 'does not show'} the history link`, () => { it('renders expected component', () => {
expect(findHistoryLink().exists()).toBe(shouldShowHistory); expect(component.exists()).toBe(true);
}); });
if (shouldShowHistory) { it('passes through props to expected component', () => {
it("sets the link's href correctly", () => { // Exclude props not defined on the expected component, since
expect(findHistoryLink().attributes('href')).toBe(gitlabCiHistoryPath); // @vue/test-utils won't include them in `Wrapper#props`.
}); const expectedProps = pick({ feature, ...props }, Object.keys(expectedComponent.props ?? {}));
} expect(component.props()).toEqual(expectedProps);
});
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import StatusDastProfiles from 'ee/security_configuration/components/status_dast_profiles.vue';
describe('StatusDastProfiles component', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(StatusDastProfiles);
};
afterEach(() => {
wrapper.destroy();
});
it('renders the fixed DAST Profiles status', () => {
createComponent();
expect(wrapper.element).toMatchInlineSnapshot(`
<div>
Available for on-demand DAST
</div>
`);
});
});
import { shallowMount } from '@vue/test-utils';
import StatusGeneric from 'ee/security_configuration/components/status_generic.vue';
import { generateFeatures } from './helpers';
describe('StatusGeneric component', () => {
let wrapper;
const createComponent = (options) => {
wrapper = shallowMount(StatusGeneric, options);
};
afterEach(() => {
wrapper.destroy();
});
describe.each`
context | configured | autoDevopsEnabled | status
${'not configured'} | ${false} | ${false} | ${StatusGeneric.i18n.notEnabled}
${'not configured, but Auto DevOps is enabled'} | ${false} | ${true} | ${StatusGeneric.i18n.notEnabled}
${'configured'} | ${true} | ${false} | ${StatusGeneric.i18n.enabled}
${'configured with Auto DevOps'} | ${true} | ${true} | ${StatusGeneric.i18n.enabledWithAutoDevOps}
`('given the feature is $context', ({ configured, autoDevopsEnabled, status }) => {
let feature;
beforeEach(() => {
[feature] = generateFeatures(1, { configured });
createComponent({
propsData: { feature, autoDevopsEnabled },
});
});
it(`shows the status "${status}"`, () => {
expect(wrapper.text()).toBe(status);
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusGeneric from 'ee/security_configuration/components/status_generic.vue';
import StatusSast from 'ee/security_configuration/components/status_sast.vue';
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { generateFeatures } from './helpers';
const gitlabCiHistoryPath = '/ci/history';
const autoDevopsEnabled = true;
describe('StatusSast component', () => {
let wrapper;
const createComponent = (options) => {
wrapper = shallowMount(StatusSast, options);
};
afterEach(() => {
wrapper.destroy();
});
const findHistoryLink = () => wrapper.find(GlLink);
describe.each`
context | configured | gitlabCiPresent | shouldShowHistory
${'no CI with sast disabled'} | ${false} | ${false} | ${false}
${'CI with sast disabled'} | ${false} | ${true} | ${false}
${'no CI with sast enabled'} | ${true} | ${false} | ${false}
${'CI with sast enabled'} | ${true} | ${true} | ${true}
`('given $context', ({ configured, gitlabCiPresent, shouldShowHistory }) => {
let feature;
beforeEach(() => {
[feature] = generateFeatures(1, { type: REPORT_TYPE_SAST, configured });
createComponent({
propsData: { feature, gitlabCiPresent, gitlabCiHistoryPath, autoDevopsEnabled },
});
});
it('shows the generic status', () => {
const genericComponent = wrapper.findComponent(StatusGeneric);
expect(genericComponent.exists()).toBe(true);
expect(genericComponent.props()).toEqual({
feature,
autoDevopsEnabled,
});
});
it(`${shouldShowHistory ? 'shows' : 'does not show'} the history link`, () => {
expect(findHistoryLink().exists()).toBe(shouldShowHistory);
});
if (shouldShowHistory) {
it("sets the link's href correctly", () => {
expect(findHistoryLink().attributes('href')).toBe(gitlabCiHistoryPath);
});
}
});
});
...@@ -80,15 +80,15 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -80,15 +80,15 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
it 'reports that all scanners are configured for which latest pipeline has builds' do it 'reports that all scanners are configured for which latest pipeline has builds' do
expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( expect(Gitlab::Json.parse(subject[:features])).to contain_exactly(
security_scan(:dast, configured: true, auto_dev_ops_enabled: true), security_scan(:dast, configured: true),
security_scan(:dast_profiles, configured: true, auto_dev_ops_enabled: true), security_scan(:sast, configured: true),
security_scan(:sast, configured: true, auto_dev_ops_enabled: true), security_scan(:container_scanning, configured: false),
security_scan(:container_scanning, configured: false, auto_dev_ops_enabled: true), security_scan(:dependency_scanning, configured: false),
security_scan(:dependency_scanning, configured: false, auto_dev_ops_enabled: true), security_scan(:license_scanning, configured: false),
security_scan(:license_scanning, configured: false, auto_dev_ops_enabled: true), security_scan(:secret_detection, configured: true),
security_scan(:secret_detection, configured: true, auto_dev_ops_enabled: true), security_scan(:coverage_fuzzing, configured: false),
security_scan(:coverage_fuzzing, configured: false, auto_dev_ops_enabled: true), security_scan(:api_fuzzing, configured: false),
security_scan(:api_fuzzing, configured: false, auto_dev_ops_enabled: true) security_scan(:dast_profiles, configured: true)
) )
end end
end end
...@@ -105,14 +105,14 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -105,14 +105,14 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
it 'reports all security jobs as unconfigured' do it 'reports all security jobs as unconfigured' do
expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( expect(Gitlab::Json.parse(subject[:features])).to contain_exactly(
security_scan(:dast, configured: false), security_scan(:dast, configured: false),
security_scan(:dast_profiles, configured: true),
security_scan(:sast, configured: false), security_scan(:sast, configured: false),
security_scan(:container_scanning, configured: false), security_scan(:container_scanning, configured: false),
security_scan(:dependency_scanning, configured: false), security_scan(:dependency_scanning, configured: false),
security_scan(:license_scanning, configured: false), security_scan(:license_scanning, configured: false),
security_scan(:secret_detection, configured: false), security_scan(:secret_detection, configured: false),
security_scan(:coverage_fuzzing, configured: false), security_scan(:coverage_fuzzing, configured: false),
security_scan(:api_fuzzing, configured: false) security_scan(:api_fuzzing, configured: false),
security_scan(:dast_profiles, configured: true)
) )
end end
end end
...@@ -254,15 +254,12 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -254,15 +254,12 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
end end
end end
def security_scan(type, configured:, auto_dev_ops_enabled: false) def security_scan(type, configured:)
configuration_path = configuration_path(type) configuration_path = configuration_path(type)
status_str = scan_status(type, configured, auto_dev_ops_enabled)
{ {
"type" => type.to_s, "type" => type.to_s,
"configured" => configured, "configured" => configured,
"status" => status_str,
"configuration_path" => configuration_path "configuration_path" => configuration_path
} }
end end
...@@ -274,16 +271,4 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -274,16 +271,4 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
api_fuzzing: project_security_configuration_api_fuzzing_path(project) api_fuzzing: project_security_configuration_api_fuzzing_path(project)
}[type] }[type]
end end
def scan_status(type, configured, auto_dev_ops_enabled)
if type == :dast_profiles
"Available for on-demand DAST"
elsif configured && auto_dev_ops_enabled
"Enabled with Auto DevOps"
elsif configured
"Enabled"
else
"Not enabled"
end
end
end end
import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
describe('propsUnion', () => {
const stringRequired = {
type: String,
required: true,
};
const stringOptional = {
type: String,
required: false,
};
const numberOptional = {
type: Number,
required: false,
};
const booleanRequired = {
type: Boolean,
required: true,
};
const FooComponent = {
props: { foo: stringRequired },
};
const BarComponent = {
props: { bar: numberOptional },
};
const FooBarComponent = {
props: {
foo: stringRequired,
bar: numberOptional,
},
};
const FooOptionalComponent = {
props: {
foo: stringOptional,
},
};
const QuxComponent = {
props: {
foo: booleanRequired,
qux: stringRequired,
},
};
it('returns an empty object given no components', () => {
expect(propsUnion([])).toEqual({});
});
it('merges non-overlapping props', () => {
expect(propsUnion([FooComponent, BarComponent])).toEqual({
...FooComponent.props,
...BarComponent.props,
});
});
it('merges overlapping props', () => {
expect(propsUnion([FooComponent, BarComponent, FooBarComponent])).toEqual({
...FooComponent.props,
...BarComponent.props,
...FooBarComponent.props,
});
});
it.each`
components
${[FooComponent, FooOptionalComponent]}
${[FooOptionalComponent, FooComponent]}
`('prefers required props over non-required props', ({ components }) => {
expect(propsUnion(components)).toEqual(FooComponent.props);
});
it('throws if given props with conflicting types', () => {
expect(() => propsUnion([FooComponent, QuxComponent])).toThrow(/incompatible prop types/);
});
it.each`
components
${[{ props: ['foo', 'bar'] }]}
${[{ props: { foo: String, bar: Number } }]}
${[{ props: { foo: {}, bar: {} } }]}
`('throw if given a non-verbose props object', ({ components }) => {
expect(() => propsUnion(components)).toThrow(/expected verbose prop/);
});
});
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