Commit 011bf15a authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Fabio Pitino

Remove "Saved scans" tab from DAST configuration

This removes the "Saved scans" tab from the DAST configuration page.
Saved scans are now available in the "On-demand Scans" page and can be
managed from there.

Changelog: removed
EE: true
parent 82253cc6
...@@ -76,10 +76,17 @@ export default { ...@@ -76,10 +76,17 @@ export default {
return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts; return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts;
}, },
hasData() { hasData() {
// Scheduled scans aren't included in the total count yet because they are dastProfiles and // Scheduled and saved scans aren't included in the total count because they are
// not pipelines. When https://gitlab.com/gitlab-org/gitlab/-/issues/342950 is addressed, we // dastProfiles, not pipelines.
// will be able to rely on the "all" count only here. // When https://gitlab.com/gitlab-org/gitlab/-/issues/342950 is addressed, we won't need to
return this.onDemandScanCounts.all + this.onDemandScanCounts.scheduled > 0; // include scheduled scans in the calculation. We'll still need to include saved scans as
// those will likely neverr be considered pipelines.
return (
this.onDemandScanCounts.all +
this.onDemandScanCounts.scheduled +
this.onDemandScanCounts.saved >
0
);
}, },
tabs() { tabs() {
return { return {
......
...@@ -10,8 +10,8 @@ import { ...@@ -10,8 +10,8 @@ import {
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue'; import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
import dastProfileRunMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_run.mutation.graphql'; import dastProfileRunMutation from '../../graphql/dast_profile_run.mutation.graphql';
import dastProfileDelete from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql'; import dastProfileDelete from '../../graphql/dast_profile_delete.mutation.graphql';
import handlesErrors from '../../mixins/handles_errors'; import handlesErrors from '../../mixins/handles_errors';
import { removeProfile } from '../../graphql/cache_utils'; import { removeProfile } from '../../graphql/cache_utils';
import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql'; import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql';
......
...@@ -104,7 +104,7 @@ export default { ...@@ -104,7 +104,7 @@ export default {
SITE_PROFILES_QUERY, SITE_PROFILES_QUERY,
), ),
}, },
inject: ['projectPath', 'profilesLibraryPath'], inject: ['projectPath', 'onDemandScansPath'],
props: { props: {
defaultBranch: { defaultBranch: {
type: String, type: String,
...@@ -268,7 +268,7 @@ export default { ...@@ -268,7 +268,7 @@ export default {
this.showErrors(ERROR_RUN_SCAN, errors); this.showErrors(ERROR_RUN_SCAN, errors);
this.loading = false; this.loading = false;
} else if (!runAfter) { } else if (!runAfter) {
redirectTo(this.profilesLibraryPath); redirectTo(this.onDemandScansPath);
this.clearStorage = true; this.clearStorage = true;
} else { } else {
this.clearStorage = true; this.clearStorage = true;
...@@ -283,7 +283,7 @@ export default { ...@@ -283,7 +283,7 @@ export default {
}, },
onCancelClicked() { onCancelClicked() {
this.clearStorage = true; this.clearStorage = true;
redirectTo(this.profilesLibraryPath); redirectTo(this.onDemandScansPath);
}, },
showErrors(errorType, errors = []) { showErrors(errorType, errors = []) {
this.errorType = errorType; this.errorType = errorType;
...@@ -330,7 +330,7 @@ export default { ...@@ -330,7 +330,7 @@ export default {
<header class="gl-mb-6"> <header class="gl-mb-6">
<div class="gl-mt-6 gl-display-flex"> <div class="gl-mt-6 gl-display-flex">
<h2 class="gl-flex-grow-1 gl-my-0">{{ title }}</h2> <h2 class="gl-flex-grow-1 gl-my-0">{{ title }}</h2>
<gl-button :href="profilesLibraryPath" data-testid="manage-profiles-link"> <gl-button :href="onDemandScansPath" data-testid="manage-profiles-link">
{{ s__('OnDemandScans|Manage DAST scans') }} {{ s__('OnDemandScans|Manage DAST scans') }}
</gl-button> </gl-button>
</div> </div>
......
...@@ -11,7 +11,7 @@ export default () => { ...@@ -11,7 +11,7 @@ export default () => {
const { const {
projectPath, projectPath,
defaultBranch, defaultBranch,
profilesLibraryPath, onDemandScansPath,
scannerProfilesLibraryPath, scannerProfilesLibraryPath,
siteProfilesLibraryPath, siteProfilesLibraryPath,
newSiteProfilePath, newSiteProfilePath,
...@@ -25,7 +25,7 @@ export default () => { ...@@ -25,7 +25,7 @@ export default () => {
apolloProvider, apolloProvider,
provide: { provide: {
projectPath, projectPath,
profilesLibraryPath, onDemandScansPath,
scannerProfilesLibraryPath, scannerProfilesLibraryPath,
siteProfilesLibraryPath, siteProfilesLibraryPath,
newScannerProfilePath, newScannerProfilePath,
......
<script>
import { GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { ERROR_RUN_SCAN, ERROR_MESSAGES } from 'ee/on_demand_scans_form/settings';
import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import dastProfileRunMutation from '../graphql/dast_profile_run.mutation.graphql';
import ProfilesList from './dast_profiles_list.vue';
import DastScanBranch from './dast_scan_branch.vue';
import ScanSchedule from './dast_scan_schedule.vue';
import ScanTypeBadge from './dast_scan_type_badge.vue';
export default {
components: {
GlButton,
ProfilesList,
DastScanBranch,
ScanSchedule,
ScanTypeBadge,
},
mixins: [glFeatureFlagsMixin()],
props: {
errorMessage: {
type: String,
required: false,
default: '',
},
errorDetails: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
isRunningScan: null,
hasRunScanError: false,
runScanErrors: [],
};
},
computed: {
error() {
if (this.hasRunScanError) {
return {
errorMessage: ERROR_MESSAGES[ERROR_RUN_SCAN],
errorDetails: this.runScanErrors,
};
}
const { errorMessage, errorDetails } = this;
return { errorMessage, errorDetails };
},
},
watch: {
errorMessage() {
this.hasRunScanError = false;
},
},
methods: {
async runScan({ id }) {
this.isRunningScan = id;
this.hasRunScanError = false;
try {
const {
data: {
dastProfileRun: { pipelineUrl, errors },
},
} = await this.$apollo.mutate({
mutation: dastProfileRunMutation,
variables: {
input: {
id,
},
},
});
if (errors.length) {
this.handleRunScanError({ errors });
} else {
redirectTo(pipelineUrl);
}
} catch (error) {
this.handleRunScanError(error);
}
},
handleRunScanError({ exception = null, errors = [] } = {}) {
this.isRunningScan = null;
this.hasRunScanError = true;
this.runScanErrors = errors;
if (exception !== null) {
Sentry.captureException(exception);
}
},
},
};
</script>
<template>
<profiles-list
:error-message="error.errorMessage"
:error-details="error.errorDetails"
v-bind="$attrs"
v-on="$listeners"
>
<template #cell(name)="{ item: { name, branch, editPath } }">
{{ name }}
<dast-scan-branch :branch="branch" :edit-path="editPath" />
</template>
<!-- eslint-disable-next-line vue/valid-v-slot -->
<template #cell(dastScannerProfile.scanType)="{ value }">
<scan-type-badge :scan-type="value" />
</template>
<!-- eslint-disable-next-line vue/valid-v-slot -->
<template #cell(dastProfileSchedule)="{ value }">
<scan-schedule :schedule="value || null" />
</template>
<template #actions="{ profile }">
<gl-button
size="small"
data-testid="dast-scan-run-button"
:loading="isRunningScan === profile.id"
:disabled="Boolean(isRunningScan)"
@click="runScan(profile)"
>{{ s__('DastProfiles|Run scan') }}</gl-button
>
</template>
</profiles-list>
</template>
...@@ -10,18 +10,11 @@ export default () => { ...@@ -10,18 +10,11 @@ export default () => {
} }
const { const {
dataset: { dataset: { newDastScannerProfilePath, newDastSiteProfilePath, projectFullPath, timezones },
newDastSavedScanPath,
newDastScannerProfilePath,
newDastSiteProfilePath,
projectFullPath,
timezones,
},
} = el; } = el;
const props = { const props = {
createNewProfilePaths: { createNewProfilePaths: {
savedScan: newDastSavedScanPath,
scannerProfile: newDastScannerProfilePath, scannerProfile: newDastScannerProfilePath,
siteProfile: newDastSiteProfilePath, siteProfile: newDastSiteProfilePath,
}, },
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query DastProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) {
id
dastProfiles(after: $after, before: $before, first: $first, last: $last) {
pageInfo {
...PageInfo
}
edges {
node {
id
name
dastSiteProfile {
id
targetUrl
}
dastScannerProfile {
id
scanType
}
dastProfileSchedule {
id
active
startsAt
timezone
cadence {
unit
duration
}
}
branch {
name
exists
}
editPath
}
}
}
}
}
import DastSavedScansList from 'ee/security_configuration/dast_profiles/components/dast_saved_scans_list.vue';
import DastScannerProfileList from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue'; import DastScannerProfileList from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue';
import DastSiteProfileList from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue'; import DastSiteProfileList from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue';
import { dastProfilesDeleteResponse } from 'ee/security_configuration/dast_profiles/graphql/cache_utils'; import { dastProfilesDeleteResponse } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import dastProfileDelete from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql';
import dastProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_profiles.query.graphql';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql'; import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
import dastScannerProfilesDelete from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles_delete.mutation.graphql'; import dastScannerProfilesDelete from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles_delete.mutation.graphql';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql'; import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
...@@ -11,54 +8,6 @@ import dastSiteProfilesDelete from 'ee/security_configuration/dast_profiles/grap ...@@ -11,54 +8,6 @@ import dastSiteProfilesDelete from 'ee/security_configuration/dast_profiles/grap
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const getProfileSettings = ({ createNewProfilePaths }) => ({ export const getProfileSettings = ({ createNewProfilePaths }) => ({
dastProfiles: {
profileType: 'dastProfiles',
tabName: 'saved-scans',
createNewProfilePath: createNewProfilePaths.savedScan,
graphQL: {
query: dastProfilesQuery,
deletion: {
mutation: dastProfileDelete,
optimisticResponse: dastProfilesDeleteResponse({
mutationName: 'dastProfileDelete',
payloadTypeName: 'DastProfileDeletePayload',
}),
},
},
component: DastSavedScansList,
tableFields: [
{
label: s__('DastProfiles|Scan'),
key: 'name',
},
{
label: s__('DastProfiles|Target'),
key: 'dastSiteProfile.targetUrl',
},
{
label: s__('DastProfiles|Scan mode'),
key: 'dastScannerProfile.scanType',
},
{
label: s__('DastProfiles|Schedule'),
key: 'dastProfileSchedule',
},
],
i18n: {
createNewLinkText: s__('DastProfiles|DAST Scan'),
name: s__('DastProfiles|Saved Scans'),
errorMessages: {
fetchNetworkError: s__(
'DastProfiles|Could not fetch saved scans. Please refresh the page, or try again later.',
),
deletionNetworkError: s__(
'DastProfiles|Could not delete saved scan. Please refresh the page, or try again later.',
),
deletionBackendError: s__('DastProfiles|Could not delete saved scans:'),
},
noProfilesMessage: s__('DastProfiles|No scans saved yet'),
},
},
siteProfiles: { siteProfiles: {
profileType: 'siteProfiles', profileType: 'siteProfiles',
tabName: 'site-profiles', tabName: 'site-profiles',
......
...@@ -27,7 +27,7 @@ module Projects::OnDemandScansHelper ...@@ -27,7 +27,7 @@ module Projects::OnDemandScansHelper
def on_demand_scans_form_data(project) def on_demand_scans_form_data(project)
common_data(project).merge({ common_data(project).merge({
'default-branch' => project.default_branch, 'default-branch' => project.default_branch,
'profiles-library-path' => project_security_configuration_dast_scans_path(project), 'on-demand-scans-path' => project_on_demand_scans_path(project, anchor: 'saved'),
'scanner-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles'), 'scanner-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles'),
'site-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'site-profiles'), 'site-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'site-profiles'),
'new-scanner-profile-path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project), 'new-scanner-profile-path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
module Projects::Security::DastProfilesHelper module Projects::Security::DastProfilesHelper
def dast_profiles_list_data(project) def dast_profiles_list_data(project)
{ {
'new_dast_saved_scan_path' => new_project_on_demand_scan_path(project),
'new_dast_site_profile_path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project), 'new_dast_site_profile_path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project),
'new_dast_scanner_profile_path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project), 'new_dast_scanner_profile_path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
'project_full_path' => project.path_with_namespace, 'project_full_path' => project.path_with_namespace,
......
...@@ -8,7 +8,7 @@ RSpec.describe 'User creates On-demand Scan' do ...@@ -8,7 +8,7 @@ RSpec.describe 'User creates On-demand Scan' do
let_it_be(:dast_site_profile) { create(:dast_site_profile, project: project) } let_it_be(:dast_site_profile) { create(:dast_site_profile, project: project) }
let_it_be(:dast_scanner_profile) { create(:dast_scanner_profile, project: project) } let_it_be(:dast_scanner_profile) { create(:dast_scanner_profile, project: project) }
let(:profile_library_path) { project_security_configuration_dast_scans_path(project) } let(:on_demand_scans_path) { project_on_demand_scans_path(project) }
before_all do before_all do
project.add_developer(user) project.add_developer(user)
...@@ -37,12 +37,12 @@ RSpec.describe 'User creates On-demand Scan' do ...@@ -37,12 +37,12 @@ RSpec.describe 'User creates On-demand Scan' do
click_button 'Save scan' click_button 'Save scan'
wait_for_requests wait_for_requests
expect(current_path).to eq(profile_library_path) expect(current_path).to eq(on_demand_scans_path)
end end
it 'on cancel', :js do it 'on cancel', :js do
click_button 'Cancel' click_button 'Cancel'
expect(current_path).to eq(profile_library_path) expect(current_path).to eq(on_demand_scans_path)
end end
end end
......
...@@ -329,34 +329,6 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do ...@@ -329,34 +329,6 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do
end end
end end
describe 'dast_profiles' do
path = 'security_configuration/dast_profiles/graphql/dast_profiles.query.graphql'
let_it_be(:dast_profiles) do
[
create(:dast_profile, project: project),
create(:dast_profile, project: project)
]
end
before do
dast_profiles.first.branch_name = SecureRandom.hex
dast_profiles.first.save!(validate: false)
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, :dastProfiles, :edges)).to have_attributes(size: dast_profiles.length)
end
end
describe 'dast_site_validations' do describe 'dast_site_validations' do
context 'failed site validations' do context 'failed site validations' do
path = 'security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql' path = 'security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql'
...@@ -405,6 +377,11 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do ...@@ -405,6 +377,11 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do
create_list(:dast_profile, 3, project: project) create_list(:dast_profile, 3, project: project)
end end
before do
dast_profiles.last.branch_name = SecureRandom.hex
dast_profiles.last.save!(validate: false)
end
it "graphql/#{path}.json" do it "graphql/#{path}.json" do
query = get_graphql_query_as_string(path, ee: true) query = get_graphql_query_as_string(path, ee: true)
......
...@@ -34,6 +34,7 @@ describe('OnDemandScans', () => { ...@@ -34,6 +34,7 @@ describe('OnDemandScans', () => {
running: 3, running: 3,
finished: 9, finished: 9,
scheduled: 5, scheduled: 5,
saved: 3,
}; };
const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0])); const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0]));
...@@ -53,6 +54,15 @@ describe('OnDemandScans', () => { ...@@ -53,6 +54,15 @@ describe('OnDemandScans', () => {
return createMockApollo([[onDemandScansCounts, requestHandler]]); return createMockApollo([[onDemandScansCounts, requestHandler]]);
}; };
// Assertions
const expectTabsToBeRendered = () => {
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);
};
const createComponent = (options = {}) => { const createComponent = (options = {}) => {
wrapper = shallowMountExtended( wrapper = shallowMountExtended(
OnDemandScans, OnDemandScans,
...@@ -117,6 +127,28 @@ describe('OnDemandScans', () => { ...@@ -117,6 +127,28 @@ describe('OnDemandScans', () => {
expect(findEmptyState().exists()).toBe(false); expect(findEmptyState().exists()).toBe(false);
}); });
describe('non-empty states', () => {
it.each`
description | counts
${'scheduled'} | ${{ scheduled: 0 }}
${'saved'} | ${{ saved: 0 }}
`(
'shows the tabs when there are no pipelines but there are $description scans',
({ counts }) => {
createComponent({
propsData: {
initialOnDemandScanCounts: {
all: 0,
...counts,
},
},
});
expectTabsToBeRendered();
},
);
});
describe('when there is data', () => { describe('when there is data', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -139,11 +171,7 @@ describe('OnDemandScans', () => { ...@@ -139,11 +171,7 @@ describe('OnDemandScans', () => {
}); });
it('renders the tabs', () => { it('renders the tabs', () => {
expect(findAllTab().exists()).toBe(true); expectTabsToBeRendered();
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', () => { it('sets the initial route to /all', () => {
......
...@@ -7,8 +7,8 @@ import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue'; ...@@ -7,8 +7,8 @@ import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue'; import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.graphql'; import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.graphql';
import dastProfileRunMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_run.mutation.graphql'; import dastProfileRunMutation from 'ee/on_demand_scans/graphql/dast_profile_run.mutation.graphql';
import dastProfileDeleteMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql'; import dastProfileDeleteMutation from 'ee/on_demand_scans/graphql/dast_profile_delete.mutation.graphql';
import { createRouter } from 'ee/on_demand_scans/router'; import { createRouter } from 'ee/on_demand_scans/router';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants'; import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
......
...@@ -31,7 +31,7 @@ const dastSiteValidationDocsPath = '/application_security/dast/index#dast-site-v ...@@ -31,7 +31,7 @@ const dastSiteValidationDocsPath = '/application_security/dast/index#dast-site-v
const projectPath = 'group/project'; const projectPath = 'group/project';
const defaultBranch = 'main'; const defaultBranch = 'main';
const selectedBranch = 'some-other-branch'; const selectedBranch = 'some-other-branch';
const profilesLibraryPath = '/security/configuration/dast_scans'; const onDemandScansPath = '/on_demand_scans#saved';
const scannerProfilesLibraryPath = '/security/configuration/dast_scans#scanner-profiles'; const scannerProfilesLibraryPath = '/security/configuration/dast_scans#scanner-profiles';
const siteProfilesLibraryPath = '/security/configuration/dast_scans#site-profiles'; const siteProfilesLibraryPath = '/security/configuration/dast_scans#site-profiles';
const newScannerProfilePath = '/security/configuration/dast_scans/dast_scanner_profile/new'; const newScannerProfilePath = '/security/configuration/dast_scans/dast_scanner_profile/new';
...@@ -162,7 +162,7 @@ describe('OnDemandScansForm', () => { ...@@ -162,7 +162,7 @@ describe('OnDemandScansForm', () => {
mocks: defaultMocks, mocks: defaultMocks,
provide: { provide: {
projectPath, projectPath,
profilesLibraryPath, onDemandScansPath,
scannerProfilesLibraryPath, scannerProfilesLibraryPath,
siteProfilesLibraryPath, siteProfilesLibraryPath,
newScannerProfilePath, newScannerProfilePath,
...@@ -357,7 +357,7 @@ describe('OnDemandScansForm', () => { ...@@ -357,7 +357,7 @@ describe('OnDemandScansForm', () => {
describe.each` describe.each`
action | actionFunction | submitButtonLoading | saveButtonLoading | runAfter | redirectPath action | actionFunction | submitButtonLoading | saveButtonLoading | runAfter | redirectPath
${'submit'} | ${submitForm} | ${true} | ${false} | ${true} | ${pipelineUrl} ${'submit'} | ${submitForm} | ${true} | ${false} | ${true} | ${pipelineUrl}
${'save'} | ${saveScan} | ${false} | ${true} | ${false} | ${profilesLibraryPath} ${'save'} | ${saveScan} | ${false} | ${true} | ${false} | ${onDemandScansPath}
`( `(
'on $action', 'on $action',
({ actionFunction, submitButtonLoading, saveButtonLoading, runAfter, redirectPath }) => { ({ actionFunction, submitButtonLoading, saveButtonLoading, runAfter, redirectPath }) => {
...@@ -517,7 +517,7 @@ describe('OnDemandScansForm', () => { ...@@ -517,7 +517,7 @@ describe('OnDemandScansForm', () => {
itClearsLocalStorage(); itClearsLocalStorage();
it('redirects to profiles library', () => { it('redirects to profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath); expect(redirectTo).toHaveBeenCalledWith(onDemandScansPath);
}); });
}); });
......
...@@ -7,7 +7,6 @@ import DastProfiles from 'ee/security_configuration/dast_profiles/components/das ...@@ -7,7 +7,6 @@ import DastProfiles from 'ee/security_configuration/dast_profiles/components/das
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
const TEST_NEW_DAST_SAVED_SCAN_PATH = '/-/on_demand_scans/new';
const TEST_NEW_DAST_SCANNER_PROFILE_PATH = '/-/on_demand_scans/scanner_profiles/new'; const TEST_NEW_DAST_SCANNER_PROFILE_PATH = '/-/on_demand_scans/scanner_profiles/new';
const TEST_NEW_DAST_SITE_PROFILE_PATH = '/-/on_demand_scans/site_profiles/new'; const TEST_NEW_DAST_SITE_PROFILE_PATH = '/-/on_demand_scans/site_profiles/new';
const TEST_PROJECT_FULL_PATH = '/namespace/project'; const TEST_PROJECT_FULL_PATH = '/namespace/project';
...@@ -22,7 +21,6 @@ describe('EE - DastProfiles', () => { ...@@ -22,7 +21,6 @@ describe('EE - DastProfiles', () => {
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => { const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultProps = { const defaultProps = {
createNewProfilePaths: { createNewProfilePaths: {
savedScan: TEST_NEW_DAST_SAVED_SCAN_PATH,
scannerProfile: TEST_NEW_DAST_SCANNER_PROFILE_PATH, scannerProfile: TEST_NEW_DAST_SCANNER_PROFILE_PATH,
siteProfile: TEST_NEW_DAST_SITE_PROFILE_PATH, siteProfile: TEST_NEW_DAST_SITE_PROFILE_PATH,
}, },
...@@ -104,7 +102,6 @@ describe('EE - DastProfiles', () => { ...@@ -104,7 +102,6 @@ describe('EE - DastProfiles', () => {
it.each` it.each`
itemName | href itemName | href
${'DAST Scan'} | ${TEST_NEW_DAST_SAVED_SCAN_PATH}
${'Site Profile'} | ${TEST_NEW_DAST_SITE_PROFILE_PATH} ${'Site Profile'} | ${TEST_NEW_DAST_SITE_PROFILE_PATH}
${'Scanner Profile'} | ${TEST_NEW_DAST_SCANNER_PROFILE_PATH} ${'Scanner Profile'} | ${TEST_NEW_DAST_SCANNER_PROFILE_PATH}
`('shows a "$itemName" dropdown item that links to $href', ({ itemName, href }) => { `('shows a "$itemName" dropdown item that links to $href', ({ itemName, href }) => {
...@@ -128,8 +125,7 @@ describe('EE - DastProfiles', () => { ...@@ -128,8 +125,7 @@ describe('EE - DastProfiles', () => {
it.each` it.each`
tabName | shouldBeSelectedByDefault tabName | shouldBeSelectedByDefault
${'Saved Scans'} | ${true} ${'Site Profiles'} | ${true}
${'Site Profiles'} | ${false}
${'Scanner Profiles'} | ${false} ${'Scanner Profiles'} | ${false}
`( `(
'shows a "$tabName" tab which has "selected" set to "$shouldBeSelectedByDefault"', 'shows a "$tabName" tab which has "selected" set to "$shouldBeSelectedByDefault"',
...@@ -146,9 +142,8 @@ describe('EE - DastProfiles', () => { ...@@ -146,9 +142,8 @@ describe('EE - DastProfiles', () => {
describe.each` describe.each`
tabName | index | givenLocationHash tabName | index | givenLocationHash
${'Saved Scans'} | ${0} | ${'#saved-scans'} ${'Site Profiles'} | ${0} | ${'#site-profiles'}
${'Site Profiles'} | ${1} | ${'#site-profiles'} ${'Scanner Profiles'} | ${1} | ${'#scanner-profiles'}
${'Scanner Profiles'} | ${2} | ${'#scanner-profiles'}
`('with location hash set to "$givenLocationHash"', ({ tabName, index, givenLocationHash }) => { `('with location hash set to "$givenLocationHash"', ({ tabName, index, givenLocationHash }) => {
beforeEach(() => { beforeEach(() => {
setWindowLocation(givenLocationHash); setWindowLocation(givenLocationHash);
...@@ -177,9 +172,6 @@ describe('EE - DastProfiles', () => { ...@@ -177,9 +172,6 @@ describe('EE - DastProfiles', () => {
it.each` it.each`
profileType | key | givenData | expectedValue | exposedAsProp profileType | key | givenData | expectedValue | exposedAsProp
${'dastProfiles'} | ${'errorMessage'} | ${{ errorMessage: 'foo' }} | ${'foo'} | ${true}
${'dastProfiles'} | ${'errorDetails'} | ${{ errorDetails: ['foo'] }} | ${['foo']} | ${true}
${'dastProfiles'} | ${'has-more-profiles-to-load'} | ${{ pageInfo: { hasNextPage: true } }} | ${'true'} | ${false}
${'siteProfiles'} | ${'error-message'} | ${{ errorMessage: 'foo' }} | ${'foo'} | ${false} ${'siteProfiles'} | ${'error-message'} | ${{ errorMessage: 'foo' }} | ${'foo'} | ${false}
${'siteProfiles'} | ${'error-details'} | ${{ errorDetails: ['foo'] }} | ${'foo'} | ${false} ${'siteProfiles'} | ${'error-details'} | ${{ errorDetails: ['foo'] }} | ${'foo'} | ${false}
${'siteProfiles'} | ${'has-more-profiles-to-load'} | ${{ pageInfo: { hasNextPage: true } }} | ${'true'} | ${false} ${'siteProfiles'} | ${'has-more-profiles-to-load'} | ${{ pageInfo: { hasNextPage: true } }} | ${'true'} | ${false}
...@@ -204,7 +196,6 @@ describe('EE - DastProfiles', () => { ...@@ -204,7 +196,6 @@ describe('EE - DastProfiles', () => {
describe.each` describe.each`
description | profileType description | profileType
${'Saved Scans List'} | ${'dastProfiles'}
${'Site Profiles List'} | ${'siteProfiles'} ${'Site Profiles List'} | ${'siteProfiles'}
${'Scanner Profiles List'} | ${'scannerProfiles'} ${'Scanner Profiles List'} | ${'scannerProfiles'}
`('$description', ({ profileType }) => { `('$description', ({ profileType }) => {
......
import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import { ERROR_RUN_SCAN, ERROR_MESSAGES } from 'ee/on_demand_scans_form/settings';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import Component from 'ee/security_configuration/dast_profiles/components/dast_saved_scans_list.vue';
import DastScanBranch from 'ee/security_configuration/dast_profiles/components/dast_scan_branch.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { savedScans } from '../mocks/mock_data';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/flash');
describe('EE - DastSavedScansList', () => {
let wrapper;
const defaultProps = {
profiles: [],
tableLabel: 'Saved scans',
fields: [
{ key: 'name' },
{ key: 'dastSiteProfile.targetUrl' },
{ key: 'dastScannerProfile.scanType' },
],
profilesPerPage: 10,
errorMessage: '',
errorDetails: [],
noProfilesMessage: 'No scans saved yet',
fullPath: '/namespace/project',
hasMoreProfilesToLoad: false,
isLoading: false,
};
const wrapperFactory = (mountFn = shallowMount) => (options = {}) => {
wrapper = extendedWrapper(
mountFn(
Component,
merge(
{
propsData: defaultProps,
},
options,
),
),
);
};
const createFullComponent = wrapperFactory(mount);
const findProfileList = () => wrapper.findComponent(ProfilesList);
afterEach(() => {
wrapper.destroy();
});
it('renders profile list properly', () => {
createFullComponent({
propsData: { profiles: savedScans },
});
expect(findProfileList().exists()).toBe(true);
});
it('renders branch information for each profile', () => {
createFullComponent({
propsData: { profiles: savedScans },
});
expect(wrapper.findAll(DastScanBranch)).toHaveLength(savedScans.length);
});
it('passes down the props properly', () => {
createFullComponent();
expect(findProfileList().props()).toEqual(defaultProps);
});
it('sets listeners on profile list component', () => {
const inputHandler = jest.fn();
createFullComponent({
listeners: {
input: inputHandler,
},
});
findProfileList().vm.$emit('input');
expect(inputHandler).toHaveBeenCalled();
});
describe('run scan', () => {
const pipelineUrl = '/pipeline/url';
const successHandler = jest.fn().mockResolvedValue({
data: {
dastProfileRun: {
pipelineUrl,
errors: [],
},
},
});
it('puts the clicked button in the loading state and disabled other buttons', async () => {
createFullComponent({
propsData: { profiles: savedScans },
mocks: {
$apollo: {
mutate: successHandler,
},
},
});
const buttons = wrapper.findAll('[data-testid="dast-scan-run-button"]');
expect(buttons.at(0).props('loading')).toBe(false);
expect(buttons.at(1).props('disabled')).toBe(false);
await buttons.at(0).trigger('click');
expect(buttons.at(0).props('loading')).toBe(true);
expect(buttons.at(1).props('disabled')).toBe(true);
});
it('redirects to the running pipeline page on success', async () => {
createFullComponent({
propsData: { profiles: savedScans },
mocks: {
$apollo: {
mutate: successHandler,
},
},
});
wrapper.findByTestId('dast-scan-run-button').trigger('click');
await waitForPromises();
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl);
expect(createFlash).not.toHaveBeenCalled();
});
it('passes the error message down to the list on failure but does not block errors passed by the parent', async () => {
const initialErrorMessage = 'Initial error message';
const finalErrorMessage = 'Final error message';
createFullComponent({
propsData: {
profiles: savedScans,
errorMessage: initialErrorMessage,
},
mocks: {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
},
});
const profilesList = findProfileList();
expect(profilesList.props('errorMessage')).toBe(initialErrorMessage);
wrapper.findByTestId('dast-scan-run-button').trigger('click');
await waitForPromises();
expect(profilesList.props('errorMessage')).toBe(ERROR_MESSAGES[ERROR_RUN_SCAN]);
expect(redirectTo).not.toHaveBeenCalled();
await wrapper.setProps({ errorMessage: finalErrorMessage });
expect(profilesList.props('errorMessage')).toBe(finalErrorMessage);
});
it('passes the error message and details down to the list if the API responds with errors-as-data', async () => {
const errors = ['error-as-data'];
createFullComponent({
propsData: { profiles: savedScans },
mocks: {
$apollo: {
mutate: jest.fn().mockResolvedValue({
data: {
dastProfileRun: {
pipelineUrl: null,
errors,
},
},
}),
},
},
});
wrapper.findByTestId('dast-scan-run-button').trigger('click');
await waitForPromises();
expect(findProfileList().props('errorMessage')).toBe(ERROR_MESSAGES[ERROR_RUN_SCAN]);
expect(findProfileList().props('errorDetails')).toBe(errors);
expect(redirectTo).not.toHaveBeenCalled();
});
});
});
import { GlIcon, GlLink } from '@gitlab/ui'; import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import profilesFixtures from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json';
import DastScanBranch from 'ee/security_configuration/dast_profiles/components/dast_scan_branch.vue'; import DastScanBranch from 'ee/security_configuration/dast_profiles/components/dast_scan_branch.vue';
import { savedScans } from '../mocks/mock_data';
const [scanWithExistingBranch, scanWithInexistingBranch] = savedScans; const [
scanWithInexistingBranch,
scanWithExistingBranch,
] = profilesFixtures.data.project.pipelines.nodes;
describe('EE - DastSavedScansList', () => { describe('EE - DastSavedScansList', () => {
let wrapper; let wrapper;
...@@ -31,6 +34,7 @@ describe('EE - DastSavedScansList', () => { ...@@ -31,6 +34,7 @@ describe('EE - DastSavedScansList', () => {
const { branch, editPath } = scanWithInexistingBranch; const { branch, editPath } = scanWithInexistingBranch;
createWrapper({ branch, editPath }); createWrapper({ branch, editPath });
}); });
it('renders a warning message', () => { it('renders a warning message', () => {
expect(wrapper.text()).toContain('Branch missing'); expect(wrapper.text()).toContain('Branch missing');
expect(wrapper.find(GlIcon).props('name')).toBe('warning'); expect(wrapper.find(GlIcon).props('name')).toBe('warning');
......
import siteProfilesFixture from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql.basic.json'; import siteProfilesFixture from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql.basic.json';
import scannerProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql.basic.json'; import scannerProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql.basic.json';
import profilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_profiles.query.graphql.json';
import policySiteProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql.from_policies.json'; import policySiteProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql.from_policies.json';
import policyScannerProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql.from_policies.json'; import policyScannerProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql.from_policies.json';
import dastFailedSiteValidationsFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql.json'; import dastFailedSiteValidationsFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql.json';
...@@ -28,7 +27,5 @@ export const scannerProfiles = scannerProfilesFixtures.data.project.scannerProfi ...@@ -28,7 +27,5 @@ export const scannerProfiles = scannerProfilesFixtures.data.project.scannerProfi
({ node }) => node, ({ node }) => node,
); );
export const savedScans = profilesFixtures.data.project.dastProfiles.edges.map(({ node }) => node);
export const failedSiteValidations = export const failedSiteValidations =
dastFailedSiteValidationsFixtures.data.project.validations.nodes; dastFailedSiteValidationsFixtures.data.project.validations.nodes;
...@@ -52,7 +52,7 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -52,7 +52,7 @@ RSpec.describe Projects::OnDemandScansHelper do
expect(helper.on_demand_scans_form_data(project)).to match( expect(helper.on_demand_scans_form_data(project)).to match(
'default-branch' => "default-branch", 'default-branch' => "default-branch",
'project-path' => "foo/bar", 'project-path' => "foo/bar",
'profiles-library-path' => "/#{project.full_path}/-/security/configuration/dast_scans", 'on-demand-scans-path' => "/#{project.full_path}/-/on_demand_scans#saved",
'scanner-profiles-library-path' => "/#{project.full_path}/-/security/configuration/dast_scans#scanner-profiles", 'scanner-profiles-library-path' => "/#{project.full_path}/-/security/configuration/dast_scans#scanner-profiles",
'site-profiles-library-path' => "/#{project.full_path}/-/security/configuration/dast_scans#site-profiles", 'site-profiles-library-path' => "/#{project.full_path}/-/security/configuration/dast_scans#site-profiles",
'new-scanner-profile-path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_scanner_profiles/new", 'new-scanner-profile-path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_scanner_profiles/new",
......
...@@ -15,7 +15,6 @@ RSpec.describe Projects::Security::DastProfilesHelper do ...@@ -15,7 +15,6 @@ RSpec.describe Projects::Security::DastProfilesHelper do
it 'returns proper data' do it 'returns proper data' do
expect(helper.dast_profiles_list_data(project)).to eq( expect(helper.dast_profiles_list_data(project)).to eq(
{ {
'new_dast_saved_scan_path' => "/#{project.full_path}/-/on_demand_scans/new",
'new_dast_site_profile_path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_site_profiles/new", 'new_dast_site_profile_path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_site_profiles/new",
'new_dast_scanner_profile_path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_scanner_profiles/new", 'new_dast_scanner_profile_path' => "/#{project.full_path}/-/security/configuration/dast_scans/dast_scanner_profiles/new",
'project_full_path' => "foo/bar", 'project_full_path' => "foo/bar",
......
...@@ -12,10 +12,6 @@ RSpec.describe "projects/security/dast_profiles/show", type: :view do ...@@ -12,10 +12,6 @@ RSpec.describe "projects/security/dast_profiles/show", type: :view do
expect(rendered).to have_selector('.js-dast-profiles') expect(rendered).to have_selector('.js-dast-profiles')
end end
it 'passes new dast saved scan path' do
expect(rendered).to include '/on_demand_scans/new'
end
it 'passes new dast site profile path' do it 'passes new dast site profile path' do
expect(rendered).to include '/security/configuration/dast_scans/dast_site_profiles/new' expect(rendered).to include '/security/configuration/dast_scans/dast_site_profiles/new'
end end
......
...@@ -10859,12 +10859,6 @@ msgstr "" ...@@ -10859,12 +10859,6 @@ msgstr ""
msgid "DastProfiles|Could not create the site profile. Please try again." msgid "DastProfiles|Could not create the site profile. Please try again."
msgstr "" msgstr ""
msgid "DastProfiles|Could not delete saved scan. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not delete saved scans:"
msgstr ""
msgid "DastProfiles|Could not delete scanner profile. Please refresh the page, or try again later." msgid "DastProfiles|Could not delete scanner profile. Please refresh the page, or try again later."
msgstr "" msgstr ""
...@@ -10877,9 +10871,6 @@ msgstr "" ...@@ -10877,9 +10871,6 @@ msgstr ""
msgid "DastProfiles|Could not delete site profiles:" msgid "DastProfiles|Could not delete site profiles:"
msgstr "" msgstr ""
msgid "DastProfiles|Could not fetch saved scans. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not fetch scanner profiles. Please refresh the page, or try again later." msgid "DastProfiles|Could not fetch scanner profiles. Please refresh the page, or try again later."
msgstr "" msgstr ""
...@@ -10892,9 +10883,6 @@ msgstr "" ...@@ -10892,9 +10883,6 @@ msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again." msgid "DastProfiles|Could not update the site profile. Please try again."
msgstr "" msgstr ""
msgid "DastProfiles|DAST Scan"
msgstr ""
msgid "DastProfiles|Debug messages" msgid "DastProfiles|Debug messages"
msgstr "" msgstr ""
...@@ -10967,9 +10955,6 @@ msgstr "" ...@@ -10967,9 +10955,6 @@ msgstr ""
msgid "DastProfiles|No scanner profiles created yet" msgid "DastProfiles|No scanner profiles created yet"
msgstr "" msgstr ""
msgid "DastProfiles|No scans saved yet"
msgstr ""
msgid "DastProfiles|No site profiles created yet" msgid "DastProfiles|No site profiles created yet"
msgstr "" msgstr ""
...@@ -10997,9 +10982,6 @@ msgstr "" ...@@ -10997,9 +10982,6 @@ msgstr ""
msgid "DastProfiles|Rest API" msgid "DastProfiles|Rest API"
msgstr "" msgstr ""
msgid "DastProfiles|Run scan"
msgstr ""
msgid "DastProfiles|Run the AJAX spider, in addition to the traditional spider, to crawl the target site." msgid "DastProfiles|Run the AJAX spider, in addition to the traditional spider, to crawl the target site."
msgstr "" msgstr ""
...@@ -11009,12 +10991,6 @@ msgstr "" ...@@ -11009,12 +10991,6 @@ msgstr ""
msgid "DastProfiles|Save profile" msgid "DastProfiles|Save profile"
msgstr "" msgstr ""
msgid "DastProfiles|Saved Scans"
msgstr ""
msgid "DastProfiles|Scan"
msgstr ""
msgid "DastProfiles|Scan mode" msgid "DastProfiles|Scan mode"
msgstr "" msgstr ""
...@@ -11027,9 +11003,6 @@ msgstr "" ...@@ -11027,9 +11003,6 @@ msgstr ""
msgid "DastProfiles|Scanner name" msgid "DastProfiles|Scanner name"
msgstr "" msgstr ""
msgid "DastProfiles|Schedule"
msgstr ""
msgid "DastProfiles|Select branch" msgid "DastProfiles|Select branch"
msgstr "" msgstr ""
...@@ -11051,9 +11024,6 @@ msgstr "" ...@@ -11051,9 +11024,6 @@ msgstr ""
msgid "DastProfiles|Spider timeout" msgid "DastProfiles|Spider timeout"
msgstr "" msgstr ""
msgid "DastProfiles|Target"
msgstr ""
msgid "DastProfiles|Target URL" msgid "DastProfiles|Target URL"
msgstr "" 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