Commit b097c121 authored by Mark Florian's avatar Mark Florian Committed by Nicolò Maria Mezzopera

Populate SAST Configuration via GraphQL

Part of the [SAST Configuration UI][epic] feature, this adds a GraphQL
query to the SAST Configuration UI application, and feeds a transformed
copy of the response to the [`DynamicFields`][1] component in order to
render the form fields.

The usage of `DynamicFields` in this change is not ideal, but is only
written this way for now to demonstrate the data flow. This will be
improved in a related MR soon. Specifically, what's not ideal:

 - It currently mutates the fetched GraphQL response, rather than
   working with a copy of it.
 - It's not contained within a `form` element, and there's no way (yet)
   to _submit_ the form data.

Other changes include:

 - Replace Bootstrap utility class with one from GitLab UI.
 - Extract helper for populating data for the frontend.
 - Change subheading text to match latest design.
 - Move documentation link into subheading.
 - Add error state for app component.

Addresses https://gitlab.com/gitlab-org/gitlab/-/issues/231372.

[epic]: https://gitlab.com/groups/gitlab-org/-/epics/3659
[1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38162
parent 5a27361c
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import sastCiConfigurationQuery from '../graphql/sast_ci_configuration.query.graphql';
import DynamicFields from './dynamic_fields.vue';
import { extractSastConfigurationEntities } from './utils';
export default {
components: {
GlIcon,
DynamicFields,
GlAlert,
GlLink,
GlLoadingIcon,
GlSprintf,
},
props: {
inject: {
sastDocumentationPath: {
type: String,
required: true,
from: 'sastDocumentationPath',
default: '',
},
projectPath: {
from: 'projectPath',
default: '',
},
},
apollo: {
sastConfigurationEntities: {
query: sastCiConfigurationQuery,
variables() {
return {
fullPath: this.projectPath,
};
},
update: extractSastConfigurationEntities,
result({ loading }) {
if (!loading && this.sastConfigurationEntities.length === 0) {
this.onError();
}
},
error() {
this.onError();
},
},
},
data() {
return {
sastConfigurationEntities: [],
hasLoadingError: false,
};
},
methods: {
onError() {
this.hasLoadingError = true;
},
},
i18n: {
helpText: s__(
`SecurityConfiguration|Customize common SAST settings to suit your
requirements. More advanced configuration options exist, which you can add
to the configuration file this tool generates. It's important to note that
if you make any configurations, they will be saved as overrides and will be
%{strongStart}excluded from automatic updates%{strongEnd}.`,
requirements. More advanced configuration options exist, which you can
add to the configuration file this tool generates. It's important to note
that if you make any configurations, they will be saved as overrides and
will be excluded from automatic updates. We've provided guidance for some
easily configurable variables below, but our docs go into even more
depth. %{linkStart}Read more%{linkEnd}`,
),
loadingErrorText: s__(
`SecurityConfiguration|There was an error loading the configuration.
Please reload the page to try again.`,
),
},
};
</script>
<template>
<article>
<header class="my-3 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
<header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
<h2 class="h4">
{{ __('SAST Configuration') }}
<gl-link
:href="sastDocumentationPath"
class="vertical-align-middle"
target="_blank"
:aria-label="__('Help')"
>
<gl-icon name="question" />
</gl-link>
{{ s__('SecurityConfiguration|SAST Configuration') }}
</h2>
<p>
<gl-sprintf :message="$options.helpText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="sastDocumentationPath" target="_blank" v-text="content" />
</template>
</gl-sprintf>
</p>
</header>
<gl-loading-icon v-if="$apollo.loading" size="lg" />
<gl-alert v-else-if="hasLoadingError" variant="danger" :dismissible="false">{{
$options.i18n.loadingErrorText
}}</gl-alert>
<dynamic-fields v-else v-model="sastConfigurationEntities" />
</article>
</template>
......@@ -10,6 +10,10 @@ export default {
GlLink,
GlSprintf,
},
// The DynamicFields component v-binds the configuration entity to this
// component. This ensures extraneous keys/values are not added as attributes
// to the underlying GlFormGroup.
inheritAttrs: false,
model: {
prop: 'value',
event: 'input',
......
const isString = value => typeof value === 'string';
// eslint-disable-next-line import/prefer-default-export
export const isValidConfigurationEntity = object => {
if (object == null) {
return false;
......@@ -17,3 +16,12 @@ export const isValidConfigurationEntity = object => {
value !== undefined
);
};
export const extractSastConfigurationEntities = ({ project }) => {
if (!project?.sastCiConfiguration) {
return [];
}
const { global, pipeline } = project.sastCiConfiguration;
return [...global.nodes, ...pipeline.nodes];
};
#import "./sast_ci_configuration_entity.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
}
}
fragment SastCiConfigurationEntityFragment on SastCiConfigurationEntity {
defaultValue
description
field
label
type
value
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import SASTConfigurationApp from './components/app.vue';
export default function init() {
......@@ -8,16 +10,23 @@ export default function init() {
return undefined;
}
const { sastDocumentationPath } = el.dataset;
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { projectPath, sastDocumentationPath } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(SASTConfigurationApp, {
props: {
apolloProvider,
provide: {
projectPath,
sastDocumentationPath,
},
});
render(createElement) {
return createElement(SASTConfigurationApp);
},
});
}
# frozen_string_literal: true
module Projects::Security::SastConfigurationHelper
def sast_configuration_data(project)
{
sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration'),
project_path: project.full_path
}
end
end
......@@ -2,4 +2,4 @@
- breadcrumb_title _("SAST Configuration")
- page_title _("SAST Configuration")
.js-sast-configuration{ data: { sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration') } }
.js-sast-configuration{ data: sast_configuration_data(@project) }
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlSprintf } from '@gitlab/ui';
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import SASTConfigurationApp from 'ee/security_configuration/sast/components/app.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeEntities } from './helpers';
const sastDocumentationPath = '/help/sast';
const projectPath = 'namespace/project';
describe('SAST Configuration App', () => {
let wrapper;
const createComponent = ({ props = {}, stubs = {} } = {}) => {
const createComponent = ({
provide = {},
stubs = {},
loading = false,
hasLoadingError = false,
sastConfigurationEntities = [],
} = {}) => {
wrapper = shallowMount(SASTConfigurationApp, {
mocks: { $apollo: { loading } },
stubs,
propsData: {
...props,
provide: {
sastDocumentationPath,
projectPath,
...provide,
},
});
// While setData is usually frowned upon, it is the documented way of
// mocking GraphQL response data:
// https://docs.gitlab.com/ee/development/fe_guide/graphql.html#testing
wrapper.setData({
hasLoadingError,
sastConfigurationEntities,
});
};
const findHeader = () => wrapper.find('header');
const findSubHeading = () => findHeader().find('p');
const findLink = (container = wrapper) => container.find(GlLink);
const findDynamicFields = () => wrapper.find(DynamicFields);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findErrorAlert = () => wrapper.find(GlAlert);
afterEach(() => {
wrapper.destroy();
......@@ -28,7 +51,6 @@ describe('SAST Configuration App', () => {
describe('header', () => {
beforeEach(() => {
createComponent({
props: { sastDocumentationPath },
stubs: { GlSprintf },
});
});
......@@ -38,7 +60,86 @@ describe('SAST Configuration App', () => {
});
it('displays the subheading', () => {
expect(findSubHeading().text()).toMatchInterpolatedText(SASTConfigurationApp.helpText);
expect(findSubHeading().text()).toMatchInterpolatedText(SASTConfigurationApp.i18n.helpText);
});
});
describe('when loading', () => {
beforeEach(() => {
createComponent({
loading: true,
});
});
it('displays a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the dynamic fields component', () => {
expect(findDynamicFields().exists()).toBe(false);
});
it('does not display an alert message', () => {
expect(findErrorAlert().exists()).toBe(false);
});
});
describe('when loading failed', () => {
beforeEach(() => {
createComponent({
hasLoadingError: true,
});
});
it('does not display a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('does not display the dynamic fields component', () => {
expect(findDynamicFields().exists()).toBe(false);
});
it('displays an alert message', () => {
expect(findErrorAlert().exists()).toBe(true);
});
});
describe('when loaded', () => {
const entities = makeEntities(3);
beforeEach(() => {
createComponent({
sastConfigurationEntities: entities,
});
});
it('does not display a loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays the dynamic fields component', () => {
const dynamicFields = findDynamicFields();
expect(dynamicFields.exists()).toBe(true);
expect(dynamicFields.props('entities')).toBe(entities);
});
it('does not display an alert message', () => {
expect(findErrorAlert().exists()).toBe(false);
});
describe('when the dynamic fields component emits an input event', () => {
let dynamicFields;
let newEntities;
beforeEach(() => {
dynamicFields = findDynamicFields();
newEntities = makeEntities(3, { value: 'foo' });
dynamicFields.vm.$emit(DynamicFields.model.event, newEntities);
});
it('updates the entities binding', () => {
expect(dynamicFields.props('entities')).toBe(newEntities);
});
});
});
});
......@@ -10,11 +10,11 @@
// eslint-disable-next-line import/prefer-default-export
export const makeEntities = (count, changes) =>
[...Array(count).keys()].map(i => ({
defaultValue: `defaultValue${i}`,
description: `description${i}`,
field: `field${i}`,
label: `label${i}`,
description: `description${i}`,
defaultValue: `defaultValue${i}`,
value: `defaultValue${i}`,
type: 'string',
value: `defaultValue${i}`,
...changes,
}));
import { isValidConfigurationEntity } from 'ee/security_configuration/sast/components/utils';
import {
isValidConfigurationEntity,
extractSastConfigurationEntities,
} from 'ee/security_configuration/sast/components/utils';
import { makeEntities } from './helpers';
describe('isValidConfigurationEntity', () => {
......@@ -25,3 +28,37 @@ describe('isValidConfigurationEntity', () => {
expect(isValidConfigurationEntity(invalidEntity)).toBe(false);
});
});
describe('extractSastConfigurationEntities', () => {
describe.each`
context | response
${'which is empty'} | ${{}}
${'with no project'} | ${{ project: null }}
${'with no configuration'} | ${{ project: {} }}
`('given a response $context', ({ response }) => {
it('returns an empty array', () => {
expect(extractSastConfigurationEntities(response)).toEqual([]);
});
});
describe('given a valid response', () => {
it('returns an array of entities from the global and pipeline sections', () => {
const globalEntities = makeEntities(3, { description: 'global' });
const pipelineEntities = makeEntities(3, { description: 'pipeline' });
const response = {
project: {
sastCiConfiguration: {
global: { nodes: globalEntities },
pipeline: { nodes: pipelineEntities },
},
},
};
expect(extractSastConfigurationEntities(response)).toEqual([
...globalEntities,
...pipelineEntities,
]);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Security::SastConfigurationHelper do
let_it_be(:project) { create(:project) }
let(:project_path) { project.full_path }
let(:docs_path) { help_page_path('user/application_security/sast/index', anchor: 'configuration') }
describe '#sast_configuration_data' do
subject { helper.sast_configuration_data(project) }
it {
is_expected.to eq({
sast_documentation_path: docs_path,
project_path: project_path
})
}
end
end
......@@ -21286,7 +21286,7 @@ msgstr ""
msgid "SecurityConfiguration|Configure"
msgstr ""
msgid "SecurityConfiguration|Customize common SAST settings to suit your requirements. More advanced configuration options exist, which you can add to the configuration file this tool generates. It's important to note that if you make any configurations, they will be saved as overrides and will be %{strongStart}excluded from automatic updates%{strongEnd}."
msgid "SecurityConfiguration|Customize common SAST settings to suit your requirements. More advanced configuration options exist, which you can add to the configuration file this tool generates. It's important to note that if you make any configurations, they will be saved as overrides and will be excluded from automatic updates. We've provided guidance for some easily configurable variables below, but our docs go into even more depth. %{linkStart}Read more%{linkEnd}"
msgstr ""
msgid "SecurityConfiguration|Enable via Merge Request"
......@@ -21307,6 +21307,9 @@ msgstr ""
msgid "SecurityConfiguration|Not enabled"
msgstr ""
msgid "SecurityConfiguration|SAST Configuration"
msgstr ""
msgid "SecurityConfiguration|Security Control"
msgstr ""
......@@ -21319,6 +21322,9 @@ msgstr ""
msgid "SecurityConfiguration|Testing & Compliance"
msgstr ""
msgid "SecurityConfiguration|There was an error loading the configuration. Please reload the page to try again."
msgstr ""
msgid "SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}"
msgstr ""
......
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