Commit f48d1a32 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Natalia Tepluhina

Add saved scans actions

parent 3d2e8da5
<script> <script>
import { GlButton, GlLink, GlSprintf, GlTabs } from '@gitlab/ui'; import { GlButton, GlLink, GlSprintf, GlScrollableTabs } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue'; import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { import {
...@@ -27,7 +27,7 @@ export default { ...@@ -27,7 +27,7 @@ export default {
GlButton, GlButton,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlTabs, GlScrollableTabs,
ConfigurationPageLayout, ConfigurationPageLayout,
AllTab, AllTab,
RunningTab, RunningTab,
...@@ -159,7 +159,7 @@ export default { ...@@ -159,7 +159,7 @@ export default {
</template> </template>
</gl-sprintf> </gl-sprintf>
</template> </template>
<gl-tabs v-model="activeTab"> <gl-scrollable-tabs v-model="activeTab" data-testid="on-demand-scans-tabs">
<component <component
:is="tab.component" :is="tab.component"
v-for="(tab, key, index) in tabs" v-for="(tab, key, index) in tabs"
...@@ -167,7 +167,7 @@ export default { ...@@ -167,7 +167,7 @@ export default {
:items-count="tab.itemsCount" :items-count="tab.itemsCount"
:is-active="activeTab === index" :is-active="activeTab === index"
/> />
</gl-tabs> </gl-scrollable-tabs>
</configuration-page-layout> </configuration-page-layout>
<empty-state v-else /> <empty-state v-else />
</template> </template>
<script> <script>
import * as Sentry from '@sentry/browser';
import { import {
GlTab, GlTab,
GlBadge, GlBadge,
...@@ -15,7 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; ...@@ -15,7 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DAST_SHORT_NAME } from '~/security_configuration/components/constants'; import { DAST_SHORT_NAME } from '~/security_configuration/components/constants';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { scrollToElement } from '~/lib/utils/common_utils'; import handlesErrors from '../../mixins/handles_errors';
import Actions from '../actions.vue'; import Actions from '../actions.vue';
import EmptyState from '../empty_state.vue'; import EmptyState from '../empty_state.vue';
import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL, ACTION_COLUMN } from '../../constants'; import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL, ACTION_COLUMN } from '../../constants';
...@@ -45,6 +44,7 @@ export default { ...@@ -45,6 +44,7 @@ export default {
Actions, Actions,
EmptyState, EmptyState,
}, },
mixins: [handlesErrors],
inject: ['projectPath'], inject: ['projectPath'],
props: { props: {
isActive: { isActive: {
...@@ -127,7 +127,6 @@ export default { ...@@ -127,7 +127,6 @@ export default {
return { return {
cursor, cursor,
hasError: false, hasError: false,
actionErrorMessage: '',
}; };
}, },
computed: { computed: {
...@@ -189,19 +188,6 @@ export default { ...@@ -189,19 +188,6 @@ export default {
}); });
this.resetActionError(); this.resetActionError();
}, },
handleActionError(message, exception = null) {
this.actionErrorMessage = message;
this.scrollToTop();
if (exception !== null) {
Sentry.captureException(exception);
}
},
resetActionError() {
this.actionErrorMessage = '';
},
scrollToTop() {
scrollToElement(this.$el);
},
}, },
i18n: { i18n: {
previousPage: __('Prev'), previousPage: __('Prev'),
...@@ -216,8 +202,10 @@ export default { ...@@ -216,8 +202,10 @@ export default {
<template> <template>
<gl-tab v-bind="$attrs"> <gl-tab v-bind="$attrs">
<template #title> <template #title>
{{ title }} <span class="gl-white-space-nowrap">
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge> {{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge>
</span>
</template> </template>
<template v-if="$apollo.queries.pipelines.loading || hasPipelines"> <template v-if="$apollo.queries.pipelines.loading || hasPipelines">
<gl-table <gl-table
...@@ -243,10 +231,10 @@ export default { ...@@ -243,10 +231,10 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</template> </template>
<template v-if="actionErrorMessage" #top-row> <template v-if="hasActionError || $scopedSlots.error" #top-row>
<td :colspan="tableFields.length"> <td :colspan="tableFields.length">
<gl-alert class="gl-my-4" variant="danger" :dismissible="false"> <gl-alert class="gl-my-4" variant="danger" :dismissible="false">
{{ actionErrorMessage }} <slot name="error">{{ actionErrorMessage }}</slot>
</gl-alert> </gl-alert>
</td> </td>
</template> </template>
...@@ -308,6 +296,8 @@ export default { ...@@ -308,6 +296,8 @@ export default {
@next="nextPage" @next="nextPage"
/> />
</div> </div>
<slot></slot>
</template> </template>
<gl-alert <gl-alert
v-else-if="hasError" v-else-if="hasError"
......
<script> <script>
import { GlIcon } from '@gitlab/ui'; import {
import { s__ } from '~/locale'; GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlModal,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
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 dastProfileDelete from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql';
import handlesErrors from '../../mixins/handles_errors';
import { removeProfile } from '../../graphql/cache_utils';
import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql'; import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants'; import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue'; import BaseTab from './base_tab.vue';
...@@ -9,15 +21,124 @@ import BaseTab from './base_tab.vue'; ...@@ -9,15 +21,124 @@ import BaseTab from './base_tab.vue';
export default { export default {
query: dastProfilesQuery, query: dastProfilesQuery,
components: { components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon, GlIcon,
GlModal,
BaseTab, BaseTab,
ScanTypeBadge, ScanTypeBadge,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [handlesErrors],
inject: ['projectPath'],
tableFields: SAVED_TAB_TABLE_FIELDS, tableFields: SAVED_TAB_TABLE_FIELDS,
deleteScanModalId: `delete-scan-modal`,
i18n: { i18n: {
title: s__('OnDemandScans|Scan library'), title: s__('OnDemandScans|Scan library'),
emptyStateTitle: s__('OnDemandScans|There are no saved scans.'), emptyStateTitle: s__('OnDemandScans|There are no saved scans.'),
emptyStateText: LEARN_MORE_TEXT, emptyStateText: LEARN_MORE_TEXT,
actions: __('Actions'),
moreActions: __('More actions'),
runScan: s__('OnDemandScans|Run scan'),
runScanError: s__('OnDemandScans|Could not run the scan. Please try again.'),
editProfile: s__('OnDemandScans|Edit profile'),
editButtonLabel: __('Edit'),
deleteModalTitle: s__('OnDemandScans|Are you sure you want to delete this scan?'),
deleteButtonLabel: __('Delete'),
deleteProfile: s__('OnDemandScans|Delete profile'),
deletionError: s__(
'OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later.',
),
},
data() {
return {
runningScanId: null,
deletingScanId: null,
};
},
methods: {
async runScan({ id }) {
this.resetActionError();
this.runningScanId = id;
try {
const {
data: {
dastProfileRun: { pipelineUrl, errors },
},
} = await this.$apollo.mutate({
mutation: dastProfileRunMutation,
variables: {
input: {
id,
},
},
});
if (errors.length) {
this.handleActionError(errors[0]);
this.runningScanId = null;
} else {
redirectTo(pipelineUrl);
}
} catch (exception) {
this.handleActionError(this.$options.i18n.runScanError, exception);
this.runningScanId = null;
}
},
prepareProfileDeletion(profileId) {
this.deletingScanId = profileId;
this.$refs[this.$options.deleteScanModalId].show();
},
async deleteProfile() {
this.resetActionError();
try {
await this.$apollo.mutate({
mutation: dastProfileDelete,
variables: {
input: {
id: this.deletingScanId,
},
},
update: (store, { data = {} }) => {
const errors = data.dastProfileDelete?.errors ?? [];
if (errors.length) {
this.handleActionError(errors[0]);
} else {
removeProfile({
profileId: this.deletingScanId,
store,
queryBody: {
query: dastProfilesQuery,
variables: {
fullPath: this.projectPath,
},
},
});
}
},
optimisticResponse: {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
dastProfileDelete: {
__typename: 'DastProfileDeletePayload',
errors: [],
},
},
});
} catch (exception) {
this.handleActionError(this.$options.i18n.deletionError, exception);
}
},
cancelDeletion() {
this.deletingScanId = null;
},
}, },
}; };
</script> </script>
...@@ -32,10 +153,95 @@ export default { ...@@ -32,10 +153,95 @@ export default {
:empty-state-text="$options.i18n.emptyStateText" :empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs" v-bind="$attrs"
> >
<template v-if="hasActionError" #error>
{{ actionErrorMessage }}
</template>
<template #after-name="item"><gl-icon name="branch" /> {{ item.branch.name }}</template> <template #after-name="item"><gl-icon name="branch" /> {{ item.branch.name }}</template>
<template #cell(scanType)="{ value }"> <template #cell(scanType)="{ value }">
<scan-type-badge :scan-type="value" /> <scan-type-badge :scan-type="value" />
</template> </template>
<template #cell(actions)="{ item }">
<div class="gl-text-right">
<gl-button
size="small"
data-testid="dast-scan-run-button"
:loading="runningScanId === item.id"
:disabled="Boolean(runningScanId)"
@click="runScan(item)"
>
{{ $options.i18n.runScan }}
</gl-button>
<!-- More actions for desktop -->
<gl-dropdown
v-gl-tooltip
:text="$options.i18n.moreActions"
:title="$options.i18n.moreActions"
category="tertiary"
size="small"
icon="ellipsis_v"
toggle-class="gl-border-0! gl-shadow-none! gl-pl-2! gl-pr-2!"
class="gl-display-none gl-md-display-inline-flex!"
no-caret
right
text-sr-only
>
<gl-dropdown-item
:href="item.editPath"
:aria-label="$options.i18n.editProfile"
data-testid="edit-scan-button-desktop"
>
{{ $options.i18n.editButtonLabel }}
</gl-dropdown-item>
<gl-dropdown-item
:aria-label="$options.i18n.deleteProfile"
boundary="viewport"
variant="danger"
data-testid="delete-scan-button-desktop"
@click="prepareProfileDeletion(item.id)"
>
{{ $options.i18n.deleteButtonLabel }}
</gl-dropdown-item>
</gl-dropdown>
<!-- More actions for mobile -->
<gl-button
:href="item.editPath"
:aria-label="$options.i18n.editProfile"
category="tertiary"
class="gl-md-display-none"
size="small"
data-testid="edit-scan-button-mobile"
>
{{ $options.i18n.editButtonLabel }}
</gl-button>
<gl-button
category="tertiary"
icon="remove"
variant="danger"
size="small"
class="gl-md-display-none"
data-testid="delete-scan-button-mobile"
:aria-label="$options.i18n.deleteProfile"
@click="prepareProfileDeletion(item.id)"
/>
</div>
</template>
<gl-modal
:ref="$options.deleteScanModalId"
:modal-id="$options.deleteScanModalId"
:title="$options.i18n.deleteModalTitle"
:ok-title="$options.i18n.deleteButtonLabel"
ok-variant="danger"
body-class="gl-display-none"
lazy
@ok="deleteProfile"
@cancel="cancelDeletion"
/>
</base-tab> </base-tab>
</template> </template>
import { produce } from 'immer';
export const removeProfile = ({ profileId, store, queryBody }) => {
const sourceData = store.readQuery(queryBody);
const data = produce(sourceData, (draftState) => {
draftState.project.pipelines.nodes = draftState.project.pipelines.nodes.filter(({ id }) => {
return id !== profileId;
});
});
store.writeQuery({ ...queryBody, data });
};
import * as Sentry from '@sentry/browser';
import { scrollToElement } from '~/lib/utils/common_utils';
export default {
data() {
return {
actionErrorMessage: '',
};
},
computed: {
hasActionError() {
return Boolean(this.actionErrorMessage.length);
},
},
methods: {
handleActionError(message, exception = null) {
this.actionErrorMessage = message;
this.scrollToTop();
if (exception !== null) {
Sentry.captureException(exception);
}
},
resetActionError() {
this.actionErrorMessage = '';
},
scrollToTop() {
scrollToElement(this.$el);
},
},
};
...@@ -40,7 +40,7 @@ describe('OnDemandScans', () => { ...@@ -40,7 +40,7 @@ describe('OnDemandScans', () => {
// Finders // Finders
const findNewScanLink = () => wrapper.findByTestId('new-scan-link'); const findNewScanLink = () => wrapper.findByTestId('new-scan-link');
const findHelpPageLink = () => wrapper.findByTestId('help-page-link'); const findHelpPageLink = () => wrapper.findByTestId('help-page-link');
const findTabs = () => wrapper.findComponent(GlTabs); const findTabs = () => wrapper.findByTestId('on-demand-scans-tabs');
const findAllTab = () => wrapper.findComponent(AllTab); const findAllTab = () => wrapper.findComponent(AllTab);
const findRunningTab = () => wrapper.findComponent(RunningTab); const findRunningTab = () => wrapper.findComponent(RunningTab);
const findFinishedTab = () => wrapper.findComponent(FinishedTab); const findFinishedTab = () => wrapper.findComponent(FinishedTab);
...@@ -68,7 +68,7 @@ describe('OnDemandScans', () => { ...@@ -68,7 +68,7 @@ describe('OnDemandScans', () => {
stubs: { stubs: {
ConfigurationPageLayout, ConfigurationPageLayout,
GlSprintf, GlSprintf,
GlTabs, GlScrollableTabs: GlTabs,
}, },
}, },
{ {
......
...@@ -305,21 +305,18 @@ describe('BaseTab', () => { ...@@ -305,21 +305,18 @@ describe('BaseTab', () => {
}); });
}); });
it('renders the after-name slot', async () => { it.each(['default', 'after-name', 'error'])('renders the %s slot', async (slot) => {
createFullComponent({ createFullComponent({
propsData: {
itemsCount: 30,
},
stubs: { stubs: {
GlTable: false, GlTable: false,
}, },
scopedSlots: { scopedSlots: {
'after-name': '<div data-testid="after-name-content" />', [slot]: `<div data-testid="${slot}-slot-content" />`,
}, },
}); });
await waitForPromises(); await waitForPromises();
expect(wrapper.findByTestId('after-name-content').exists()).toBe(true); expect(wrapper.findByTestId(`${slot}-slot-content`).exists()).toBe(true);
}); });
describe("when a scan's DAST profile got deleted", () => { describe("when a scan's DAST profile got deleted", () => {
......
...@@ -7,19 +7,35 @@ import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue'; ...@@ -7,19 +7,35 @@ 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 dastProfileDeleteMutation from 'ee/security_configuration/dast_profiles/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';
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 flushPromises from 'helpers/flush_promises';
jest.mock('~/lib/utils/common_utils'); import { redirectTo } from '~/lib/utils/url_utility';
Vue.use(VueApollo); Vue.use(VueApollo);
// Mocks
jest.mock('~/lib/utils/common_utils');
jest.mock('~/lib/utils/url_utility');
const [firstProfile] = dastProfilesMock.data.project.pipelines.nodes;
const GlTableMock = {
firstProfile,
template: `
<div>
<slot name="cell(actions)" :item="$options.firstProfile" />
<slot name="error" />
</div>`,
};
const errorAsDataMessage = 'Error-as-data message';
describe('Saved tab', () => { describe('Saved tab', () => {
let wrapper; let wrapper;
let router; let router;
let requestHandler; let requestHandlers;
// Props // Props
const projectPath = '/namespace/project'; const projectPath = '/namespace/project';
...@@ -29,11 +45,32 @@ describe('Saved tab', () => { ...@@ -29,11 +45,32 @@ describe('Saved tab', () => {
const findBaseTab = () => wrapper.findComponent(BaseTab); const findBaseTab = () => wrapper.findComponent(BaseTab);
const findFirstRow = () => wrapper.find('tbody > tr'); const findFirstRow = () => wrapper.find('tbody > tr');
const findCellAt = (index) => findFirstRow().findAll('td').at(index); const findCellAt = (index) => findFirstRow().findAll('td').at(index);
const findRunScanButton = () => wrapper.findByTestId('dast-scan-run-button');
const findDeleteModal = () => wrapper.findComponent({ ref: 'delete-scan-modal' });
// Helpers // Helpers
const createMockApolloProvider = () => { const createMockApolloProvider = () => {
return createMockApollo([[dastProfilesQuery, requestHandler]]); return createMockApollo([
[dastProfilesQuery, requestHandlers.dastProfilesQuery],
[dastProfileRunMutation, requestHandlers.dastProfileRunMutation],
[dastProfileDeleteMutation, requestHandlers.dastProfileDeleteMutation],
]);
}; };
const makeDastProfileRunResponse = (errors = []) => ({
data: {
dastProfileRun: {
pipelineUrl: '/pipelines/1',
errors,
},
},
});
const makeDastProfileDeleteResponse = (errors = []) => ({
data: {
dastProfileDelete: {
errors,
},
},
});
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => { const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter(); router = createRouter();
...@@ -63,13 +100,17 @@ describe('Saved tab', () => { ...@@ -63,13 +100,17 @@ describe('Saved tab', () => {
const createFullComponent = createComponentFactory(mountExtended); const createFullComponent = createComponentFactory(mountExtended);
beforeEach(() => { beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(dastProfilesMock); requestHandlers = {
dastProfilesQuery: jest.fn().mockResolvedValue(dastProfilesMock),
dastProfileRunMutation: jest.fn().mockResolvedValue(makeDastProfileRunResponse()),
dastProfileDeleteMutation: jest.fn().mockResolvedValue(makeDastProfileDeleteResponse()),
};
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
router = null; router = null;
requestHandler = null; requestHandlers = null;
}); });
it('renders the base tab with the correct props', () => { it('renders the base tab with the correct props', () => {
...@@ -88,7 +129,7 @@ describe('Saved tab', () => { ...@@ -88,7 +129,7 @@ describe('Saved tab', () => {
it('fetches the profiles', () => { it('fetches the profiles', () => {
createComponent(); createComponent();
expect(requestHandler).toHaveBeenCalledWith({ expect(requestHandlers.dastProfilesQuery).toHaveBeenCalledWith({
after: null, after: null,
before: null, before: null,
first: 20, first: 20,
...@@ -98,8 +139,6 @@ describe('Saved tab', () => { ...@@ -98,8 +139,6 @@ describe('Saved tab', () => {
}); });
describe('custom table cells', () => { describe('custom table cells', () => {
const [firstProfile] = dastProfilesMock.data.project.pipelines.nodes;
beforeEach(() => { beforeEach(() => {
createFullComponent(); createFullComponent();
}); });
...@@ -117,4 +156,177 @@ describe('Saved tab', () => { ...@@ -117,4 +156,177 @@ describe('Saved tab', () => {
expect(firstScanTypeBadge.props('scanType')).toBe(firstProfile.dastScannerProfile.scanType); expect(firstScanTypeBadge.props('scanType')).toBe(firstProfile.dastScannerProfile.scanType);
}); });
}); });
describe('edit button', () => {
beforeEach(() => {
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
});
it.each(['desktop', 'mobile'])('renders the %s edit button', (layout) => {
const editButton = wrapper.findByTestId(`edit-scan-button-${layout}`);
expect(editButton.exists()).toBe(true);
expect(editButton.attributes('href')).toBe(firstProfile.editPath);
});
});
describe('run scan button', () => {
describe('success', () => {
beforeEach(async () => {
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
});
it('renders the button', () => {
expect(findRunScanButton().exists()).toBe(true);
});
it('clicking on the button triggers the run scan mutation with the profile ID', () => {
findRunScanButton().vm.$emit('click');
expect(requestHandlers.dastProfileRunMutation).toHaveBeenCalledWith({
input: { id: firstProfile.id },
});
});
it('put the button in the loading and disabled state', async () => {
const runScanButton = findRunScanButton();
runScanButton.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(runScanButton.props('loading')).toBe(true);
expect(runScanButton.props('disabled')).toBe(true);
});
it("redirects to the pipeline's page once the mutation resolves", async () => {
findRunScanButton().vm.$emit('click');
await flushPromises();
expect(redirectTo).toHaveBeenCalledWith('/pipelines/1');
});
});
const topLevelErrorMessage = s__('OnDemandScans|Could not run the scan. Please try again.');
describe.each`
errorType | errorMessage | requestHander
${'error-as-data'} | ${errorAsDataMessage} | ${jest.fn().mockResolvedValue(makeDastProfileRunResponse([errorAsDataMessage]))}
${'top-level error'} | ${topLevelErrorMessage} | ${jest.fn().mockRejectedValue()}
`('when deletion fails with $errorType', ({ errorMessage, requestHander }) => {
beforeEach(async () => {
requestHandlers.dastProfileRunMutation = requestHander;
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
findRunScanButton().vm.$emit('click');
});
it('shows the error message', () => {
expect(wrapper.text()).toContain(errorMessage);
});
it('hides the error message when retrying the deletion', async () => {
findRunScanButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
it("resets the button's state", async () => {
const runScanButton = findRunScanButton();
expect(runScanButton.props('loading')).toBe(false);
expect(runScanButton.props('disabled')).toBe(false);
});
});
});
describe('delete button', () => {
describe.each(['desktop', 'mobile'])('%s layout', (layout) => {
let deleteButton;
beforeEach(() => {
createComponent({
stubs: {
GlTable: GlTableMock,
GlModal: {
template: '<div />',
methods: {
show: () => {},
},
},
},
});
deleteButton = wrapper.findByTestId(`delete-scan-button-${layout}`);
});
afterEach(() => {
deleteButton = null;
});
it('renders the button', () => {
expect(deleteButton.exists()).toBe(true);
});
it('clicking on the button opens the delete modal', () => {
jest.spyOn(wrapper.vm.$refs['delete-scan-modal'], 'show');
deleteButton.vm.$emit('click');
expect(wrapper.vm.$refs['delete-scan-modal'].show).toHaveBeenCalled();
});
it('confirming the deletion in the modal triggers the delete mutation with the profile ID', () => {
deleteButton.vm.$emit('click');
findDeleteModal().vm.$emit('ok');
expect(requestHandlers.dastProfileDeleteMutation).toHaveBeenCalledWith({
input: { id: firstProfile.id },
});
});
});
const topLevelErrorMessage = s__(
'OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later.',
);
describe.each`
errorType | errorMessage | requestHander
${'error-as-data'} | ${errorAsDataMessage} | ${jest.fn().mockResolvedValue(makeDastProfileDeleteResponse([errorAsDataMessage]))}
${'top-level error'} | ${topLevelErrorMessage} | ${jest.fn().mockRejectedValue()}
`('when deletion fails with $errorType', ({ errorMessage, requestHander }) => {
beforeEach(async () => {
requestHandlers.dastProfileDeleteMutation = requestHander;
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
findDeleteModal().vm.$emit('ok');
});
it('shows the error message', () => {
expect(wrapper.text()).toContain(errorMessage);
});
it('hides the error message when retrying the deletion', async () => {
findDeleteModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
});
});
}); });
import dastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json';
import { removeProfile } from 'ee/on_demand_scans/graphql/cache_utils';
const [firstProfile, ...otherProfiles] = dastProfilesMock.data.project.pipelines.nodes;
describe('EE - On-demand Scans GraphQL CacheUtils', () => {
describe('removeProfile', () => {
it('removes the profile with the given id from the cache', () => {
const mockQueryBody = { query: 'foo', variables: { foo: 'bar' } };
const mockStore = {
readQuery: () => dastProfilesMock.data,
writeQuery: jest.fn(),
};
removeProfile({
store: mockStore,
queryBody: mockQueryBody,
profileId: firstProfile.id,
});
expect(mockStore.writeQuery).toHaveBeenCalledWith({
...mockQueryBody,
data: {
project: {
id: dastProfilesMock.data.project.id,
pipelines: {
nodes: otherProfiles,
pageInfo: expect.any(Object),
},
},
},
});
});
});
});
...@@ -24516,6 +24516,12 @@ msgstr "" ...@@ -24516,6 +24516,12 @@ msgstr ""
msgid "OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}." msgid "OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}."
msgstr "" msgstr ""
msgid "OnDemandScans|Are you sure you want to delete this scan?"
msgstr ""
msgid "OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later."
msgstr ""
msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later." msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later."
msgstr "" msgstr ""
...@@ -24534,12 +24540,18 @@ msgstr "" ...@@ -24534,12 +24540,18 @@ msgstr ""
msgid "OnDemandScans|Create new site profile" msgid "OnDemandScans|Create new site profile"
msgstr "" msgstr ""
msgid "OnDemandScans|Delete profile"
msgstr ""
msgid "OnDemandScans|Description (optional)" msgid "OnDemandScans|Description (optional)"
msgstr "" msgstr ""
msgid "OnDemandScans|Edit on-demand DAST scan" msgid "OnDemandScans|Edit on-demand DAST scan"
msgstr "" msgstr ""
msgid "OnDemandScans|Edit profile"
msgstr ""
msgid "OnDemandScans|For example: Tests the login page for SQL injections" msgid "OnDemandScans|For example: Tests the login page for SQL injections"
msgstr "" msgstr ""
...@@ -24582,6 +24594,9 @@ msgstr "" ...@@ -24582,6 +24594,9 @@ msgstr ""
msgid "OnDemandScans|Repeats" msgid "OnDemandScans|Repeats"
msgstr "" msgstr ""
msgid "OnDemandScans|Run scan"
msgstr ""
msgid "OnDemandScans|Save and run scan" msgid "OnDemandScans|Save and run scan"
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