Commit d746c810 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '341379-dast-view-scans-saved-scans' into 'master'

Add "Scan library" tab

See merge request gitlab-org/gitlab!77107
parents a3f963d9 e9855d3a
......@@ -969,6 +969,8 @@ To view running completed and scheduled on-demand DAST scans for a project, go t
failed, or was canceled.
- To view scheduled scans, select **Scheduled**. It shows on-demand scans that have a schedule
set up. Those are _not_ included in the **All** tab.
- To view saved on-demand scan profiles, select **Scan library**.
Those are _not_ included in the **All** tab.
#### Cancel an on-demand scan
......
......@@ -18,6 +18,7 @@ import AllTab from './tabs/all.vue';
import RunningTab from './tabs/running.vue';
import FinishedTab from './tabs/finished.vue';
import ScheduledTab from './tabs/scheduled.vue';
import SavedTab from './tabs/saved.vue';
import EmptyState from './empty_state.vue';
export default {
......@@ -98,6 +99,10 @@ export default {
component: ScheduledTab,
itemsCount: this.onDemandScanCounts.scheduled,
},
saved: {
component: SavedTab,
itemsCount: this.onDemandScanCounts.saved,
},
};
},
activeTab: {
......
......@@ -257,8 +257,11 @@ export default {
</div>
</template>
<template #cell(name)="{ value }">
<template #cell(name)="{ value, item }">
<gl-truncate v-if="value" :text="value" with-tooltip />
<div v-if="$scopedSlots['after-name']">
<slot name="after-name" v-bind="item"></slot>
</div>
</template>
<template #cell(scanType)>
......
<script>
import { GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: dastProfilesQuery,
components: {
GlIcon,
BaseTab,
ScanTypeBadge,
},
tableFields: SAVED_TAB_TABLE_FIELDS,
i18n: {
title: s__('OnDemandScans|Scan library'),
emptyStateTitle: s__('OnDemandScans|There are no saved scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
};
</script>
<template>
<base-tab
:query="$options.query"
:query-variables="$options.queryVariables"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
>
<template #after-name="item"><gl-icon name="branch" /> {{ item.branch.name }}</template>
<template #cell(scanType)="{ value }">
<scan-type-badge :scan-type="value" />
</template>
</base-tab>
</template>
......@@ -9,7 +9,7 @@ export const LEARN_MORE_TEXT = s__(
'OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}.',
);
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled'];
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled', 'saved'];
export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
......@@ -43,6 +43,10 @@ const TARGET_COLUMN = {
label: s__('OnDemandScans|Target'),
key: 'targetUrl',
};
const TARGET_COLUMN_DAST_PROFILE = {
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastSiteProfile.targetUrl,
};
const START_DATE_COLUMN = {
label: __('Start date'),
key: 'createdAt',
......@@ -89,10 +93,7 @@ export const SCHEDULED_TAB_TABLE_FIELDS = [
},
NAME_COLUMN,
SCAN_TYPE_COLUMN,
{
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastSiteProfile.targetUrl,
},
TARGET_COLUMN_DAST_PROFILE,
{
label: __('Next scan'),
key: 'nextRun',
......@@ -118,3 +119,13 @@ export const SCHEDULED_TAB_TABLE_FIELDS = [
key: 'dastProfileSchedule',
},
];
export const SAVED_TAB_TABLE_FIELDS = [
NAME_COLUMN,
TARGET_COLUMN_DAST_PROFILE,
{
label: s__('DastProfiles|Scan mode'),
key: 'scanType',
formatter: (_value, _key, item) => item.dastScannerProfile?.scanType,
},
];
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query SavedScans($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) {
id
pipelines: dastProfiles(after: $after, before: $before, first: $first, last: $last)
@connection(key: "dastProfiles") {
pageInfo {
...PageInfo
}
nodes {
id
name
dastSiteProfile {
id
targetUrl
}
dastScannerProfile {
id
scanType
}
branch {
name
exists
}
editPath
}
}
}
}
......@@ -27,4 +27,10 @@ query onDemandScanCounts(
count
}
}
saved: project(fullPath: $fullPath) {
id
pipelines: dastProfiles {
count
}
}
}
......@@ -4,16 +4,18 @@ module Projects::OnDemandScansHelper
# rubocop: disable CodeReuse/ActiveRecord
def on_demand_scans_data(project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan])
running_scan_count, finished_scan_count = count_running_and_finished_scans(on_demand_scans)
scheduled_scans = ::Dast::ProfilesFinder.new({ project_id: project.id, has_dast_profile_schedule: true }).execute
running_scans_count, finished_scans_count = count_running_and_finished_scans(on_demand_scans)
saved_scans = ::Dast::ProfilesFinder.new({ project_id: project.id }).execute
scheduled_scans_count = saved_scans.count { |scan| scan.dast_profile_schedule }
common_data(project).merge({
'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project),
'on-demand-scan-counts' => {
all: on_demand_scans.length,
running: running_scan_count,
finished: finished_scan_count,
scheduled: scheduled_scans.length
running: running_scans_count,
finished: finished_scans_count,
scheduled: scheduled_scans_count,
saved: saved_scans.count
}.to_json,
'new-dast-scan-path' => new_project_on_demand_scan_path(project),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'),
......@@ -43,17 +45,17 @@ module Projects::OnDemandScansHelper
end
def count_running_and_finished_scans(on_demand_scans)
running_scan_count = 0
finished_scan_count = 0
running_scans_count = 0
finished_scans_count = 0
on_demand_scans.each do |pipeline|
if %w[success failed canceled].include?(pipeline.status)
finished_scan_count += 1
finished_scans_count += 1
elsif pipeline.status == "running"
running_scan_count += 1
running_scans_count += 1
end
end
[running_scan_count, finished_scan_count]
[running_scans_count, finished_scans_count]
end
end
......@@ -219,5 +219,25 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do
expect(graphql_data_at(:project, :pipelines, :nodes)).to have_attributes(size: 1)
end
end
describe 'dast_profiles' do
path = 'on_demand_scans/graphql/dast_profiles.query.graphql'
let_it_be(:dast_profiles) do
create_list(:dast_profile, 3, project: project)
end
it "graphql/#{path}.json" do
query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path,
first: 20
})
expect_graphql_errors_to_be_empty
expect(graphql_data_at(:project, :pipelines, :nodes)).to have_attributes(size: dast_profiles.size)
end
end
end
end
......@@ -9,6 +9,10 @@ import { PIPELINE_TABS_KEYS } from 'ee/on_demand_scans/constants';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { createRouter } from 'ee/on_demand_scans/router';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import RunningTab from 'ee/on_demand_scans/components/tabs/running.vue';
import FinishedTab from 'ee/on_demand_scans/components/tabs/finished.vue';
import ScheduledTab from 'ee/on_demand_scans/components/tabs/scheduled.vue';
import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue';
import EmptyState from 'ee/on_demand_scans/components/empty_state.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import onDemandScansCounts from 'ee/on_demand_scans/graphql/on_demand_scan_counts.query.graphql';
......@@ -38,6 +42,10 @@ describe('OnDemandScans', () => {
const findHelpPageLink = () => wrapper.findByTestId('help-page-link');
const findTabs = () => wrapper.findComponent(GlTabs);
const findAllTab = () => wrapper.findComponent(AllTab);
const findRunningTab = () => wrapper.findComponent(RunningTab);
const findFinishedTab = () => wrapper.findComponent(FinishedTab);
const findScheduledTab = () => wrapper.findComponent(ScheduledTab);
const findSavedTab = () => wrapper.findComponent(SavedTab);
const findEmptyState = () => wrapper.findComponent(EmptyState);
// Helpers
......@@ -132,6 +140,10 @@ describe('OnDemandScans', () => {
it('renders the tabs', () => {
expect(findAllTab().exists()).toBe(true);
expect(findRunningTab().exists()).toBe(true);
expect(findFinishedTab().exists()).toBe(true);
expect(findScheduledTab().exists()).toBe(true);
expect(findSavedTab().exists()).toBe(true);
});
it('sets the initial route to /all', () => {
......
......@@ -305,6 +305,23 @@ describe('BaseTab', () => {
});
});
it('renders the after-name slot', async () => {
createFullComponent({
propsData: {
itemsCount: 30,
},
stubs: {
GlTable: false,
},
scopedSlots: {
'after-name': '<div data-testid="after-name-content" />',
},
});
await waitForPromises();
expect(wrapper.findByTestId('after-name-content').exists()).toBe(true);
});
describe("when a scan's DAST profile got deleted", () => {
beforeEach(() => {
const allPipelinesWithPipelinesMockCopy = cloneDeep(allPipelinesWithPipelinesMock);
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { merge } from 'lodash';
import dastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.graphql';
import { createRouter } from 'ee/on_demand_scans/router';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
import { s__ } from '~/locale';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
jest.mock('~/lib/utils/common_utils');
Vue.use(VueApollo);
describe('Saved tab', () => {
let wrapper;
let router;
let requestHandler;
// Props
const projectPath = '/namespace/project';
const itemsCount = 12;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const findFirstRow = () => wrapper.find('tbody > tr');
const findCellAt = (index) => findFirstRow().findAll('td').at(index);
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[dastProfilesQuery, requestHandler]]);
};
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter();
wrapper = mountFn(
SavedTab,
merge(
{
apolloProvider: createMockApolloProvider(),
router,
propsData: {
isActive: true,
itemsCount,
},
provide: {
projectPath,
},
stubs: {
BaseTab,
},
},
options,
),
);
};
const createComponent = createComponentFactory();
const createFullComponent = createComponentFactory(mountExtended);
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(dastProfilesMock);
});
afterEach(() => {
wrapper.destroy();
router = null;
requestHandler = null;
});
it('renders the base tab with the correct props', () => {
createComponent();
expect(findBaseTab().props('title')).toBe(s__('OnDemandScans|Scan library'));
expect(findBaseTab().props('itemsCount')).toBe(itemsCount);
expect(findBaseTab().props('query')).toBe(dastProfilesQuery);
expect(findBaseTab().props('emptyStateTitle')).toBe(
s__('OnDemandScans|There are no saved scans.'),
);
expect(findBaseTab().props('emptyStateText')).toBe(LEARN_MORE_TEXT);
expect(findBaseTab().props('fields')).toBe(SAVED_TAB_TABLE_FIELDS);
});
it('fetches the profiles', () => {
createComponent();
expect(requestHandler).toHaveBeenCalledWith({
after: null,
before: null,
first: 20,
fullPath: projectPath,
last: null,
});
});
describe('custom table cells', () => {
const [firstProfile] = dastProfilesMock.data.project.pipelines.nodes;
beforeEach(() => {
createFullComponent();
});
it('renders the branch name in the name cell', () => {
const nameCell = findCellAt(0);
expect(nameCell.text()).toContain(firstProfile.branch.name);
});
it('renders the scan type', () => {
const firstScanTypeBadge = wrapper.findComponent(ScanTypeBadge);
expect(firstScanTypeBadge.exists()).toBe(true);
expect(firstScanTypeBadge.props('scanType')).toBe(firstProfile.dastScannerProfile.scanType);
});
});
});
......@@ -14,7 +14,8 @@ RSpec.describe Projects::OnDemandScansHelper do
describe '#on_demand_scans_data' do
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile)}
let_it_be(:dast_profile_with_schedule) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile_with_schedule)}
before do
allow(helper).to receive(:timezone_data).with(format: :abbr).and_return(timezones)
......@@ -33,7 +34,8 @@ RSpec.describe Projects::OnDemandScansHelper do
all: 12,
running: 4,
finished: 8,
scheduled: 1
scheduled: 1,
saved: 2
}.to_json,
'timezones' => timezones.to_json
)
......
......@@ -24588,6 +24588,9 @@ msgstr ""
msgid "OnDemandScans|Save scan"
msgstr ""
msgid "OnDemandScans|Scan library"
msgstr ""
msgid "OnDemandScans|Scan name"
msgstr ""
......@@ -24624,6 +24627,9 @@ msgstr ""
msgid "OnDemandScans|There are no running scans."
msgstr ""
msgid "OnDemandScans|There are no saved scans."
msgstr ""
msgid "OnDemandScans|There are no scheduled scans."
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