Commit 90e016fb authored by Jeremy Wu's avatar Jeremy Wu Committed by Phil Hughes

Feat: setup audit event streaming in UI

Add audit event external stream destination components
Add graphqls for requests
Update group template according to the change
parent 2f30afbb
...@@ -25,6 +25,24 @@ GitLab can stream a single event more than once to the same destination. Use the ...@@ -25,6 +25,24 @@ GitLab can stream a single event more than once to the same destination. Use the
WARNING: WARNING:
Event streaming destinations will receive **all** audit event data, which could include sensitive information. Make sure you trust the destination endpoint. Event streaming destinations will receive **all** audit event data, which could include sensitive information. Make sure you trust the destination endpoint.
### Add event streaming destination using GitLab UI
Users with at least the Owner role of a group can add event streaming destinations for it:
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Security & Compliance > Audit events**
1. On the main area, select **Streams** tab.
- When the destination list is empty, select **Add stream** activate edit mode and add a new destination.
- When the destination list is not empty, select **{plus}** under the **Streams** tab to activate edit mode.
1. Enter the endpoint you wish to add and select **Add**.
Event streaming is enabled if:
- No warning is shown.
- The added endpoint is displayed in the UI.
### Add event streaming destination using the API
To enable event streaming, a group owner must add a new event streaming destination using the `externalAuditEventDestinationCreate` mutation To enable event streaming, a group owner must add a new event streaming destination using the `externalAuditEventDestinationCreate` mutation
in the GraphQL API. in the GraphQL API.
...@@ -50,7 +68,17 @@ Event streaming is enabled if: ...@@ -50,7 +68,17 @@ Event streaming is enabled if:
## List currently enabled streaming destinations ## List currently enabled streaming destinations
Group owners can view a list of event streaming destinations at any time using the `externalAuditEventDesinations` query type. ### List currently enabled streaming destination using GitLab UI
Users with at least the Owner role of a group can list event streaming destinations:
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Security & Compliance > Audit events**
1. On the main area, select **Streams** tab.
### List currently enabled streaming destinations using the API
Group owners can view a list of event streaming destinations at any time using the `externalAuditEventDestinations` query type.
```graphql ```graphql
query { query {
...@@ -69,6 +97,45 @@ query { ...@@ -69,6 +97,45 @@ query {
If the resulting list is empty, then audit event streaming is not enabled for that group. If the resulting list is empty, then audit event streaming is not enabled for that group.
## Delete currently enabled streaming destination
Group Owners can delete event streaming destinations at any time using the `deleteAuditEventDestinations` mutation type.
### Delete currently enabled streaming using GitLab UI
Uses with at least the Owner role of a group can delete event streaming destination.
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Security & Compliance > Audit events**
1. On the main area, select **Streams** tab.
1. Select **{remove}** at the right side of each item.
The external streaming destination is delete when:
- No warning is shown.
- The deleted endpoint is not displayed in the UI.
### Delete currently enabled streaming using the API
You can delete an event streaming destination by specifying an ID. Get the required ID by [listing the details](audit_event_streaming.md#list-currently-enabled-streaming-destinations-using-the-api) of event streaming destinations.
```graphql
mutation{
externalAuditEventDestinationDestroy(input: { id: destination }) {
errors
}
}
```
Destination is deleted if:
- The returned `errors` object is empty.
- The API responds with `200 OK`.
When the last destination is successfully deleted, event streaming is disabled for the group.
## Verify event authenticity ## Verify event authenticity
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345424) in GitLab 14.8. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345424) in GitLab 14.8.
......
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.7911 35.417C7.7911 44.0254 14.7911 51.0039 23.4259 51.0039H63.4902C65.4968 51.0039 67.4026 50.5732 69.1194 49.7994C66.7886 54.0027 62.297 57 57.1385 57H17.0743C8.43941 57 1.43945 50.0215 1.43945 41.4131C1.43945 35.3198 4.94651 29.8922 10.058 27.329C8.61956 29.6869 7.7911 32.4555 7.7911 35.417Z"
fill="#AC7FFF" fill-opacity="0.24" />
<path
d="M25 56.9987H56.7053C58.515 57.0249 60.3105 56.6489 61.9786 55.9361C63.6466 55.2233 65.1512 54.1675 66.3974 52.8354C67.6437 51.5033 68.6047 49.9237 69.2198 48.196C69.8349 46.4684 70.0908 44.6301 69.9715 42.7971C69.8521 40.9642 69.3601 39.1761 68.5263 37.5456C67.6925 35.9152 66.5351 34.4776 65.127 33.3236C63.7188 32.1695 62.0905 31.3239 60.3445 30.8401C58.5985 30.3563 56.7727 30.2447 54.9821 30.5124C54.7354 26.3838 53.0621 22.4757 50.2565 19.4758C47.4509 16.4759 43.6928 14.5764 39.6434 14.1114C35.5941 13.6465 31.5129 14.6459 28.1179 16.9339C24.7229 19.2218 22.2317 22.6517 21.0826 26.6201C19.8187 26.2873 18.5182 26.1186 17.2124 26.1183C15.2147 26.1183 13.2365 26.5177 11.3909 27.2937C9.54522 28.0696 7.86821 29.2069 6.45561 30.6407C5.04301 32.0744 3.92247 33.7765 3.15797 35.6498C2.39348 37.5231 2 39.5309 2 41.5585C2 43.5862 2.39348 45.5939 3.15797 47.4672C3.92247 49.3405 5.04301 51.0426 6.45561 52.4764C7.86821 53.9101 9.54522 55.0475 11.3909 55.8234C13.1326 56.5556 15.1172 56.9576 17 57"
stroke="#6E49CB" stroke-width="4" stroke-linecap="round" />
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2362_40692)">
<path
d="M5.7714 7.57776C6.081 7.77412 6.081 8.22588 5.7714 8.42224L1.7678 10.9615C1.4349 11.1726 1 10.9334 1 10.5392V5.46077C1 5.06657 1.4349 4.8274 1.7678 5.03853L5.7714 7.57776Z"
fill="#666666" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10.4807 1.54229C11.3532 2.37335 12.0479 3.37293 12.5226 4.4804C12.9973 5.58787 13.2421 6.78013 13.2422 7.98488C13.2423 9.18963 12.9977 10.3817 12.5232 11.4889C12.0488 12.5961 11.3543 13.5953 10.482 14.4259C10.182 14.7116 10.1704 15.1863 10.456 15.4863C10.7416 15.7863 11.2164 15.7979 11.5163 15.5122C12.5358 14.5415 13.3474 13.3737 13.902 12.0798C14.4565 10.7858 14.7424 9.39265 14.7422 7.98475C14.7421 6.57686 14.456 5.18359 13.9013 3.88945C13.3466 2.59531 12.5348 1.42725 11.5152 0.456129C11.2153 0.170451 10.7406 0.182007 10.4549 0.481941C10.1692 0.781876 10.1808 1.25661 10.4807 1.54229ZM7.16578 4.88284C7.598 5.27586 7.94331 5.75487 8.17956 6.28913C8.41582 6.82338 8.53778 7.40107 8.53765 7.98513C8.53753 8.56918 8.41531 9.1467 8.17885 9.68064C7.9424 10.2146 7.59693 10.6931 7.16461 11.0856C6.85794 11.3641 6.83504 11.8384 7.11347 12.1451C7.3919 12.4518 7.86622 12.4746 8.1729 12.1962C8.76005 11.6631 9.22925 11.0132 9.55038 10.288C9.87151 9.56288 10.0375 8.77858 10.0377 7.98545C10.0378 7.19233 9.8722 6.4079 9.55142 5.68248C9.23064 4.95707 8.76178 4.30668 8.17493 3.77305C7.86847 3.49438 7.39413 3.51691 7.11546 3.82337C6.83679 4.12983 6.85932 4.60417 7.16578 4.88284Z"
fill="#666666" />
</g>
<defs>
<clipPath id="clip0_2362_40692">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { GlTabs, GlTab } from '@gitlab/ui';
import AuditEventsExportButton from './audit_events_export_button.vue'; import { AUDIT_EVENTS_TAB_TITLES } from '../constants';
import AuditEventsFilter from './audit_events_filter.vue'; import AuditEventsLog from './audit_events_log.vue';
import AuditEventsTable from './audit_events_table.vue'; import AuditEventsStream from './audit_events_stream.vue';
import DateRangeField from './date_range_field.vue';
import SortingField from './sorting_field.vue';
export default { export default {
components: { components: {
AuditEventsFilter, GlTabs,
DateRangeField, GlTab,
SortingField, AuditEventsLog,
AuditEventsTable, AuditEventsStream,
AuditEventsExportButton,
},
props: {
events: {
type: Array,
required: false,
default: () => [],
},
isLastPage: {
type: Boolean,
required: false,
default: false,
},
filterTokenOptions: {
type: Array,
required: true,
},
exportUrl: {
type: String,
required: false,
default: '',
},
showFilter: {
type: Boolean,
required: false,
default: true,
},
}, },
inject: ['isProject', 'showStreams'],
computed: { computed: {
...mapState(['filterValue', 'startDate', 'endDate', 'sortBy']), showTabs() {
...mapGetters(['buildExportHref']), return !this.isProject && this.showStreams;
exportHref() {
return this.buildExportHref(this.exportUrl);
}, },
hasExportUrl() {
return this.exportUrl.length;
},
},
methods: {
...mapActions(['setDateRange', 'setFilterValue', 'setSortBy', 'searchForAuditEvents']),
}, },
i18n: AUDIT_EVENTS_TAB_TITLES,
}; };
</script> </script>
<template> <template>
<div> <gl-tabs v-if="showTabs" content-class="gl-pt-5">
<header> <gl-tab :title="$options.i18n.LOG">
<div class="gl-my-5 gl-display-flex gl-flex-direction-row gl-justify-content-end"> <audit-events-log />
<audit-events-export-button v-if="hasExportUrl" :export-href="exportHref" /> </gl-tab>
</div> <gl-tab :title="$options.i18n.STREAM" lazy>
</header> <audit-events-stream />
<div class="audit-log-filter row-content-block second-block gl-pb-0"> </gl-tab>
<div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row!"> </gl-tabs>
<audit-events-filter <audit-events-log v-else />
v-if="showFilter"
:filter-token-options="filterTokenOptions"
:value="filterValue"
class="gl-mr-5 gl-mb-5"
@selected="setFilterValue"
@submit="searchForAuditEvents"
/>
<sorting-field :sort-by="sortBy" @selected="setSortBy" />
</div>
<date-range-field :start-date="startDate" :end-date="endDate" @selected="setDateRange" />
</div>
<audit-events-table :events="events" :is-last-page="isLastPage" />
</div>
</template> </template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import AuditEventsExportButton from './audit_events_export_button.vue';
import AuditEventsFilter from './audit_events_filter.vue';
import AuditEventsTable from './audit_events_table.vue';
import DateRangeField from './date_range_field.vue';
import SortingField from './sorting_field.vue';
export default {
components: {
AuditEventsFilter,
DateRangeField,
SortingField,
AuditEventsTable,
AuditEventsExportButton,
},
inject: ['events', 'isLastPage', 'filterTokenOptions', 'exportUrl', 'showFilter'],
computed: {
...mapState(['filterValue', 'startDate', 'endDate', 'sortBy']),
...mapGetters(['buildExportHref']),
exportHref() {
return this.buildExportHref(this.exportUrl);
},
hasExportUrl() {
return this.exportUrl.length;
},
},
methods: {
...mapActions(['setDateRange', 'setFilterValue', 'setSortBy', 'searchForAuditEvents']),
},
};
</script>
<template>
<div>
<header v-if="hasExportUrl">
<div class="gl-my-5 gl-display-flex gl-flex-direction-row gl-justify-content-end">
<audit-events-export-button :export-href="exportHref" />
</div>
</header>
<div class="audit-log-filter row-content-block second-block gl-pb-0">
<div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row!">
<audit-events-filter
v-if="showFilter"
:filter-token-options="filterTokenOptions"
:value="filterValue"
class="gl-mr-5 gl-mb-5"
@selected="setFilterValue"
@submit="searchForAuditEvents"
/>
<sorting-field :sort-by="sortBy" @selected="setSortBy" />
</div>
<date-range-field :start-date="startDate" :end-date="endDate" @selected="setDateRange" />
</div>
<audit-events-table :events="events" :is-last-page="isLastPage" />
</div>
</template>
<script>
import { GlButton, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import createFlash from '~/flash';
import {
ACTIVE_STREAM,
ADD_STREAM,
AUDIT_STREAMS_NETWORK_ERRORS,
STREAM_COUNT_ICON_ALT,
} from '../constants';
import externalDestinationsQuery from '../graphql/get_external_destinations.query.graphql';
import StreamEmptyState from './stream/stream_empty_state.vue';
import StreamDestinationEditor from './stream/stream_destination_editor.vue';
import StreamItem from './stream/stream_item.vue';
const { FETCHING_ERROR } = AUDIT_STREAMS_NETWORK_ERRORS;
export default {
components: {
GlButton,
StreamDestinationEditor,
StreamEmptyState,
StreamItem,
},
directives: {
SafeHtml,
},
inject: ['groupPath', 'streamsIconSvgPath'],
data() {
return {
externalAuditEventDestinations: null,
isEditing: false,
};
},
computed: {
shouldShowEmptyMode() {
return !this.externalAuditEventDestinations?.length && !this.isEditing;
},
destinationsCount() {
return this.externalAuditEventDestinations?.length ?? 0;
},
},
methods: {
setEditMode(state) {
this.isEditing = state;
},
refreshDestinations() {
return this.$apollo.queries.externalAuditEventDestinations.refetch();
},
async onAddedDestinationUrl() {
await this.refreshDestinations();
this.setEditMode(false);
},
},
apollo: {
externalAuditEventDestinations: {
query: externalDestinationsQuery,
context: {
isSingleRequest: true,
},
variables() {
return {
fullPath: this.groupPath,
};
},
skip() {
return !this.groupPath;
},
update(data) {
return data.group.externalAuditEventDestinations.nodes;
},
error() {
createFlash({
message: FETCHING_ERROR,
});
},
},
},
i18n: {
ACTIVE_STREAM,
ADD_STREAM,
FETCHING_ERROR,
STREAM_COUNT_ICON_ALT,
},
};
</script>
<template>
<div class="gl-bg-gray-10 gl-rounded-base gl-border">
<div class="gl-display-flex gl-align-items-center gl-pl-5 gl-py-3 gl-border-b gl-h-9">
<img
:alt="$options.i18n.STREAM_COUNT_ICON_ALT"
:src="streamsIconSvgPath"
class="gl-mr-2 gl-h-5 gl-w-5"
/>
<span class="gl-mr-4">{{ destinationsCount }}</span>
<gl-button
v-if="destinationsCount"
:aria-label="$options.i18n.ADD_STREAM"
icon="plus"
@click="setEditMode(true)"
/>
</div>
<div v-if="isEditing" class="gl-p-4 gl-bg-gray-10">
<stream-destination-editor @added="onAddedDestinationUrl" @cancel="setEditMode(false)" />
</div>
<stream-empty-state v-if="shouldShowEmptyMode" @add="setEditMode(true)" />
<div v-if="destinationsCount" class="gl-p-4">
<label class="gl-mb-3">{{ $options.i18n.ACTIVE_STREAM }}</label>
<ul class="content-list bordered-box gl-bg-white">
<stream-item
v-for="item in externalAuditEventDestinations"
:key="item.id"
:item="item"
@delete="refreshDestinations"
/>
</ul>
</div>
</div>
</template>
<script>
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import createFlash from '~/flash';
import externalAuditEventDestinationCreate from '../../graphql/create_external_destination.mutation.graphql';
import { ADD_STREAM_EDITOR_I18N, AUDIT_STREAMS_NETWORK_ERRORS } from '../../constants';
const { CREATING_ERROR } = AUDIT_STREAMS_NETWORK_ERRORS;
export default {
components: {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
},
inject: ['groupPath'],
data() {
return { destinationUrl: '', loading: false };
},
methods: {
async addDestinationUrl() {
this.loading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: externalAuditEventDestinationCreate,
variables: {
destinationUrl: this.destinationUrl,
fullPath: this.groupPath,
},
context: {
isSingleRequest: true,
},
});
const { errors } = data.externalAuditEventDestinationCreate;
if (errors.length > 0) {
createFlash({
message: errors[0],
});
} else {
this.$emit('added');
}
} catch (e) {
createFlash({
message: CREATING_ERROR,
});
} finally {
this.loading = false;
}
},
},
i18n: { ...ADD_STREAM_EDITOR_I18N, CREATING_ERROR },
};
</script>
<template>
<div class="gl-p-4 gl-bg-white gl-border gl-rounded-base">
<gl-alert
:title="$options.i18n.WARNING_TITLE"
:dismissible="false"
class="gl-mb-5"
variant="warning"
>
{{ $options.i18n.WARNING_CONTENT }}
</gl-alert>
<gl-form @submit.prevent="addDestinationUrl">
<gl-form-group :label="$options.i18n.DESTINATION_URL_LABEL">
<gl-form-input v-model="destinationUrl" :placeholder="$options.i18n.PLACEHOLDER" />
</gl-form-group>
<div class="gl-display-flex">
<gl-button
:disabled="!destinationUrl"
:loading="loading"
:name="$options.i18n.ADD_BUTTON_NAME"
class="gl-mr-3"
variant="info"
type="submit"
data-testid="stream-destination-add-button"
>{{ $options.i18n.ADD_BUTTON_TEXT }}</gl-button
>
<gl-button
:name="$options.i18n.CANCEL_BUTTON_NAME"
data-testid="stream-destination-cancel-button"
@click="$emit('cancel')"
>{{ $options.i18n.CANCEL_BUTTON_TEXT }}</gl-button
>
</div>
</gl-form>
</div>
</template>
<script>
import { GlButton, GlSafeHtmlDirective as SafeHtml, GlEmptyState } from '@gitlab/ui';
import { ADD_STREAM, AUDIT_STREAMS_EMPTY_STATE_I18N } from '../../constants';
export default {
components: {
GlButton,
GlEmptyState,
},
directives: {
SafeHtml,
},
inject: ['emptyStateSvgPath'],
i18n: {
...AUDIT_STREAMS_EMPTY_STATE_I18N,
ADD_STREAM,
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.TITLE"
:svg-path="emptyStateSvgPath"
:svg-height="72"
class="gl-mt-5"
>
<template #title>
<h3 class="h4 gl-font-size-h-display gl-line-height-36 gl-mt-n3">
{{ $options.i18n.TITLE }}
</h3>
</template>
<template #description>
<p>{{ $options.i18n.DESCRIPTION_1 }}</p>
<p>{{ $options.i18n.DESCRIPTION_2 }}</p>
</template>
<template #actions>
<gl-button variant="info" @click="$emit('add')">
{{ $options.i18n.ADD_STREAM }}
</gl-button>
</template>
</gl-empty-state>
</template>
<script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
import deleteExternalDestination from '../../graphql/delete_external_destination.mutation.graphql';
import { AUDIT_STREAMS_NETWORK_ERRORS } from '../../constants';
export default {
components: {
GlButton,
},
directives: {
GlTooltip,
},
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
isDeleting: false,
};
},
computed: {
deleteButtonLabel() {
return sprintf(s__('AuditStreams|Delete %{link}'), { link: this.item.destinationUrl });
},
},
methods: {
async deleteDestination() {
this.isDeleting = true;
try {
const { data } = await this.$apollo.mutate({
mutation: deleteExternalDestination,
variables: {
id: this.item.id,
},
context: {
isSingleRequest: true,
},
});
const { errors } = data.externalAuditEventDestinationDestroy;
if (errors.length > 0) {
createFlash({
message: errors[0],
});
} else {
this.$emit('delete');
}
} catch (e) {
createFlash({
message: AUDIT_STREAMS_NETWORK_ERRORS.DELETING_ERROR,
});
} finally {
this.isDeleting = false;
}
},
},
i18n: AUDIT_STREAMS_NETWORK_ERRORS,
};
</script>
<template>
<li class="list-item py-0">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-px-5 gl-py-3 gl-rounded-base"
>
<div class="gl-h-4">{{ item.destinationUrl }}</div>
<div class="actions-button">
<gl-button
v-gl-tooltip
:aria-label="deleteButtonLabel"
:loading="isDeleting"
:title="__('Delete')"
category="tertiary"
icon="remove"
@click="deleteDestination"
/>
</div>
</div>
</li>
</template>
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import GroupToken from './components/tokens/group_token.vue'; import GroupToken from './components/tokens/group_token.vue';
import MemberToken from './components/tokens/member_token.vue'; import MemberToken from './components/tokens/member_token.vue';
...@@ -63,3 +63,46 @@ export const SAME_DAY_OFFSET = 1; ...@@ -63,3 +63,46 @@ export const SAME_DAY_OFFSET = 1;
// This creates a date with zero time, making it simpler to match to the query date values // This creates a date with zero time, making it simpler to match to the query date values
export const CURRENT_DATE = new Date(new Date().toDateString()); export const CURRENT_DATE = new Date(new Date().toDateString());
export const AUDIT_EVENTS_TAB_TITLES = {
LOG: s__('AuditLogs|Log'),
STREAM: s__('AuditStreams|Streams'),
};
export const ADD_STREAM = s__('AuditStreams|Add stream');
export const ACTIVE_STREAM = s__('AuditStreams|Active');
export const STREAM_COUNT_ICON_ALT = s__('AuditStreams|Stream count icon');
export const ADD_STREAM_EDITOR_I18N = {
WARNING_TITLE: s__('AuditStreams|Destinations receive all audit event data'),
WARNING_CONTENT: s__(
'AuditStreams|This could include sensitive information. Make sure you trust the destination endpoint.',
),
// This is intended since this is used for input.placeholder
PLACEHOLDER: 'https://api.gitlab.com',
DESTINATION_URL_LABEL: s__('AuditStreams|Destination URL'),
ADD_BUTTON_TEXT: __('Add'),
ADD_BUTTON_NAME: s__('AuditStreams|Add external stream destination'),
CANCEL_BUTTON_TEXT: __('Cancel'),
CANCEL_BUTTON_NAME: __('AuditStreams|Cancel editing'),
};
export const AUDIT_STREAMS_EMPTY_STATE_I18N = {
TITLE: s__('AuditStreams|Setup streaming for audit events'),
DESCRIPTION_1: s__(
'AuditStreams|Add an HTTP endpoint to manage audit logs in third-party systems.',
),
DESCRIPTION_2: s__('AuditStreams|This is great for keeping everything one place.'),
};
export const AUDIT_STREAMS_NETWORK_ERRORS = {
FETCHING_ERROR: s__(
'AuditStreams|An error occurred when fetching external audit event streams. Please try it again',
),
CREATING_ERROR: s__(
'AuditStreams|An error occurred when creating external audit event stream destination. Please try it again.',
),
DELETING_ERROR: s__(
'AuditStreams|An error occurred when deleting external audit event stream destination. Please try it again.',
),
};
mutation createExternalDestination($fullPath: ID!, $destinationUrl: String!) {
externalAuditEventDestinationCreate(
input: { destinationUrl: $destinationUrl, groupPath: $fullPath }
) {
errors
externalAuditEventDestination {
destinationUrl
id
group {
id
name
}
}
}
}
mutation deleteExternalDestination($id: AuditEventsExternalAuditEventDestinationID!) {
externalAuditEventDestinationDestroy(input: { id: $id }) {
errors
}
}
query getExternalDestinations($fullPath: ID!) {
group(fullPath: $fullPath) {
id
externalAuditEventDestinations {
nodes {
destinationUrl
id
}
}
}
}
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import AuditEventsApp from './components/audit_events_app.vue'; import AuditEventsApp from './components/audit_events_app.vue';
import createStore from './store'; import createStore from './store';
Vue.use(VueApollo);
export default (selector) => { export default (selector) => {
const el = document.querySelector(selector); const el = document.querySelector(selector);
const { events, isLastPage, filterTokenOptions, exportUrl, showFilter = true } = el.dataset; const {
events,
isLastPage,
filterTokenOptions,
exportUrl = '',
showFilter,
showStreams,
groupPath,
emptyStateSvgPath,
streamsIconSvgPath,
} = el.dataset;
const store = createStore(); const store = createStore();
const parsedFilterTokenOptions = JSON.parse(filterTokenOptions).map((filterTokenOption) =>
convertObjectPropsToCamelCase(filterTokenOption),
);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
store.dispatch('initializeAuditEvents'); store.dispatch('initializeAuditEvents');
return new Vue({ return new Vue({
el, el,
store, store,
render: (createElement) => provide: {
createElement(AuditEventsApp, { events: JSON.parse(events) || [],
props: { isLastPage: parseBoolean(isLastPage) || false,
events: JSON.parse(events), filterTokenOptions: parsedFilterTokenOptions,
isLastPage: parseBoolean(isLastPage), exportUrl,
filterTokenOptions: JSON.parse(filterTokenOptions).map((filterTokenOption) => showFilter: parseBoolean(showFilter) || true,
convertObjectPropsToCamelCase(filterTokenOption), showStreams: parseBoolean(showStreams),
), groupPath,
exportUrl, // group level and project level are mutually exclusive.
showFilter: parseBoolean(showFilter), isProject: !groupPath,
}, emptyStateSvgPath,
}), streamsIconSvgPath,
},
apolloProvider,
render: (createElement) => createElement(AuditEventsApp),
}); });
}; };
...@@ -8,5 +8,9 @@ ...@@ -8,5 +8,9 @@
#js-group-audit-events-app{ data: { form_path: group_audit_events_path(@group), #js-group-audit-events-app{ data: { form_path: group_audit_events_path(@group),
events: @events.to_json, events: @events.to_json,
is_last_page: @is_last_page.to_json, is_last_page: @is_last_page.to_json,
group_path: @group.full_path,
show_streams: can?(current_user, :admin_external_audit_events, @group).to_s,
empty_state_svg_path: image_path('illustrations/cloud.svg'),
streams_icon_svg_path: image_path('illustrations/live_stream.svg'),
filter_token_options: group_audit_event_tokens(@group.id).to_json, filter_token_options: group_audit_event_tokens(@group.id).to_json,
show_filter: show_filter_for_group?(@group).to_json } } show_filter: show_filter_for_group?(@group).to_json } }
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuditEventsApp when initialized matches the snapshot 1`] = ` exports[`AuditEventsLog when initialized matches the snapshot 1`] = `
<div> <div>
<header> <header>
<div <div
...@@ -18,21 +18,11 @@ exports[`AuditEventsApp when initialized matches the snapshot 1`] = ` ...@@ -18,21 +18,11 @@ exports[`AuditEventsApp when initialized matches the snapshot 1`] = `
<div <div
class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row!" class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row!"
> >
<div <audit-events-filter-stub
class="input-group bg-white flex-grow-1 gl-mr-5 gl-mb-5" class="gl-mr-5 gl-mb-5"
data-testid="audit-events-filter" filtertokenoptions="[object Object],[object Object],[object Object],[object Object]"
> value="[object Object]"
<gl-filtered-search-stub />
availabletokens="[object Object],[object Object],[object Object],[object Object]"
class="gl-h-32 w-100"
clearbuttontitle="Clear"
close-button-title="Close"
placeholder="Search"
searchbuttonattributes="[object Object]"
searchinputattributes="[object Object]"
value="[object Object]"
/>
</div>
<sorting-field-stub <sorting-field-stub
sortby="created_asc" sortby="created_asc"
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlTabs } from '@gitlab/ui';
import AuditEventsApp from 'ee/audit_events/components/audit_events_app.vue'; import AuditEventsApp from 'ee/audit_events/components/audit_events_app.vue';
import AuditEventsExportButton from 'ee/audit_events/components/audit_events_export_button.vue';
import AuditEventsFilter from 'ee/audit_events/components/audit_events_filter.vue';
import AuditEventsTable from 'ee/audit_events/components/audit_events_table.vue';
import DateRangeField from 'ee/audit_events/components/date_range_field.vue';
import SortingField from 'ee/audit_events/components/sorting_field.vue';
import { AVAILABLE_TOKEN_TYPES } from 'ee/audit_events/constants';
import createStore from 'ee/audit_events/store';
const TEST_SORT_BY = 'created_asc';
const TEST_START_DATE = new Date('2020-01-01');
const TEST_END_DATE = new Date('2020-02-02');
const TEST_FILTER_VALUE = [{ id: 50, type: 'User' }];
describe('AuditEventsApp', () => { describe('AuditEventsApp', () => {
let wrapper; let wrapper;
let store; const initComponent = (isProject = false, showStreams = false) => {
const events = [{ foo: 'bar' }];
const filterTokenOptions = AVAILABLE_TOKEN_TYPES.map((type) => ({ type }));
const exportUrl = 'http://example.com/audit_log_reports.csv';
const initComponent = (props = {}) => {
wrapper = shallowMount(AuditEventsApp, { wrapper = shallowMount(AuditEventsApp, {
store, provide: {
propsData: { isProject,
isLastPage: true, showStreams,
filterTokenOptions,
events,
exportUrl,
showFilter: true,
...props,
},
stubs: {
AuditEventsFilter,
}, },
}); });
}; };
beforeEach(() => {
store = createStore();
Object.assign(store.state, {
startDate: TEST_START_DATE,
endDate: TEST_END_DATE,
sortBy: TEST_SORT_BY,
filterValue: TEST_FILTER_VALUE,
});
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
store = null;
}); });
describe('when initialized', () => { describe('when initialized', () => {
beforeEach(() => { describe('renders group audit events', () => {
initComponent(); it('as group owner', () => {
}); initComponent(false, true);
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders audit events table', () => {
expect(wrapper.findComponent(AuditEventsTable).props()).toEqual({
events,
isLastPage: true,
});
});
it('renders audit events filter', () => {
expect(wrapper.findComponent(AuditEventsFilter).props()).toEqual({
filterTokenOptions,
value: TEST_FILTER_VALUE,
});
});
it('renders date range field', () => { expect(wrapper.findComponent(GlTabs).exists()).toBe(true);
expect(wrapper.findComponent(DateRangeField).props()).toEqual({
startDate: TEST_START_DATE,
endDate: TEST_END_DATE,
}); });
});
it('renders sorting field', () => { it('as group maintainer', () => {
expect(wrapper.findComponent(SortingField).props()).toEqual({ sortBy: TEST_SORT_BY }); initComponent();
});
it('renders the audit events export button', () => { expect(wrapper.findComponent(GlTabs).exists()).toBe(false);
expect(wrapper.findComponent(AuditEventsExportButton).props()).toEqual({
exportHref:
'http://example.com/audit_log_reports.csv?created_after=2020-01-01&created_before=2020-02-02',
}); });
}); });
}); it('should render as project audit events', () => {
initComponent(true);
describe('when a field is selected', () => {
beforeEach(() => {
jest.spyOn(store, 'dispatch').mockImplementation();
initComponent();
});
it.each`
name | field | action | payload
${'date range'} | ${DateRangeField} | ${'setDateRange'} | ${'test'}
${'sort by'} | ${SortingField} | ${'setSortBy'} | ${'test'}
${'events filter'} | ${AuditEventsFilter} | ${'setFilterValue'} | ${'test'}
`('for $name, it calls $handler', ({ field, action, payload }) => {
expect(store.dispatch).not.toHaveBeenCalled();
wrapper.findComponent(field).vm.$emit('selected', payload);
expect(store.dispatch).toHaveBeenCalledWith(action, payload);
});
});
describe('when the audit events export link is not present', () => {
beforeEach(() => {
initComponent({ exportUrl: '' });
});
it('does not render the audit events export button', () => {
expect(wrapper.findComponent(AuditEventsExportButton).exists()).toBe(false);
});
});
describe('when the show filter flag is disabled', () => {
beforeEach(() => {
initComponent({ showFilter: false });
});
it('does not render the audit events filter', () => { expect(wrapper.findComponent(GlTabs).exists()).toBe(false);
expect(wrapper.findComponent(AuditEventsFilter).exists()).toBe(false);
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import AuditEventsLog from 'ee/audit_events/components/audit_events_log.vue';
import AuditEventsExportButton from 'ee/audit_events/components/audit_events_export_button.vue';
import AuditEventsFilter from 'ee/audit_events/components/audit_events_filter.vue';
import AuditEventsTable from 'ee/audit_events/components/audit_events_table.vue';
import DateRangeField from 'ee/audit_events/components/date_range_field.vue';
import SortingField from 'ee/audit_events/components/sorting_field.vue';
import { AVAILABLE_TOKEN_TYPES } from 'ee/audit_events/constants';
import createStore from 'ee/audit_events/store';
const TEST_SORT_BY = 'created_asc';
const TEST_START_DATE = new Date('2020-01-01');
const TEST_END_DATE = new Date('2020-02-02');
const TEST_FILTER_VALUE = [{ id: 50, type: 'User' }];
describe('AuditEventsLog', () => {
let wrapper;
let store;
const events = [{ foo: 'bar' }];
const filterTokenOptions = AVAILABLE_TOKEN_TYPES.map((type) => ({ type }));
const exportUrl = 'http://example.com/audit_log_reports.csv';
const initComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(AuditEventsLog, {
store,
provide: {
isLastPage: true,
filterTokenOptions,
events,
exportUrl,
showFilter: true,
...props,
},
});
};
beforeEach(() => {
store = createStore();
Object.assign(store.state, {
startDate: TEST_START_DATE,
endDate: TEST_END_DATE,
sortBy: TEST_SORT_BY,
filterValue: TEST_FILTER_VALUE,
});
});
afterEach(() => {
wrapper.destroy();
});
describe('when initialized', () => {
beforeEach(() => {
initComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders audit events table', () => {
expect(wrapper.findComponent(AuditEventsTable).props()).toEqual({
events,
isLastPage: true,
});
});
it('renders audit events filter', () => {
expect(wrapper.findComponent(AuditEventsFilter).props()).toEqual({
filterTokenOptions,
value: TEST_FILTER_VALUE,
});
});
it('renders date range field', () => {
expect(wrapper.findComponent(DateRangeField).props()).toEqual({
startDate: TEST_START_DATE,
endDate: TEST_END_DATE,
});
});
it('renders sorting field', () => {
expect(wrapper.findComponent(SortingField).props()).toEqual({ sortBy: TEST_SORT_BY });
});
it('renders the audit events export button', () => {
expect(wrapper.findComponent(AuditEventsExportButton).props()).toEqual({
exportHref:
'http://example.com/audit_log_reports.csv?created_after=2020-01-01&created_before=2020-02-02',
});
});
});
describe('when a field is selected', () => {
beforeEach(() => {
jest.spyOn(store, 'dispatch').mockImplementation();
initComponent();
});
it.each`
name | field | action | payload
${'date range'} | ${DateRangeField} | ${'setDateRange'} | ${'test'}
${'sort by'} | ${SortingField} | ${'setSortBy'} | ${'test'}
${'events filter'} | ${AuditEventsFilter} | ${'setFilterValue'} | ${'test'}
`('for $name, it calls $handler', ({ field, action, payload }) => {
expect(store.dispatch).not.toHaveBeenCalled();
wrapper.findComponent(field).vm.$emit('selected', payload);
expect(store.dispatch).toHaveBeenCalledWith(action, payload);
});
});
describe('when the audit events export link is not present', () => {
beforeEach(() => {
initComponent({ props: { exportUrl: '' } });
});
it('does not render the audit events export button', () => {
expect(wrapper.findComponent(AuditEventsExportButton).exists()).toBe(false);
});
});
describe('when the show filter flag is disabled', () => {
beforeEach(() => {
initComponent({ props: { showFilter: false } });
});
it('does not render the audit events filter', () => {
expect(wrapper.findComponent(AuditEventsFilter).exists()).toBe(false);
});
});
});
import VueApollo from 'vue-apollo';
import { GlButton } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import createFlash from '~/flash';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import externalDestinationsQuery from 'ee/audit_events/graphql/get_external_destinations.query.graphql';
import { AUDIT_STREAMS_NETWORK_ERRORS } from 'ee/audit_events/constants';
import AuditEventsStream from 'ee/audit_events/components/audit_events_stream.vue';
import StreamDestinationEditor from 'ee/audit_events/components/stream/stream_destination_editor.vue';
import StreamEmptyState from 'ee/audit_events/components/stream/stream_empty_state.vue';
import {
mockExternalDestinations,
groupPath,
destinationDataPopulator,
mockSvgPath,
} from '../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('AuditEventsStream', () => {
let wrapper;
const externalDestinationsQuerySpy = jest
.fn()
.mockResolvedValue(destinationDataPopulator(mockExternalDestinations));
const createComponent = (destinationQuerySpy = externalDestinationsQuerySpy) => {
const mockApollo = createMockApollo([[externalDestinationsQuery, destinationQuerySpy]]);
wrapper = shallowMountExtended(AuditEventsStream, {
provide: {
groupPath,
streamsIconSvgPath: mockSvgPath,
},
apolloProvider: mockApollo,
localVue,
});
};
const findAddDestinationButton = () => wrapper.findComponent(GlButton);
const findStreamDestinationEditor = () => wrapper.findComponent(StreamDestinationEditor);
const findStreamEmptyState = () => wrapper.findComponent(StreamEmptyState);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
createFlash.mockClear();
externalDestinationsQuerySpy.mockClear();
});
describe('when initialized', () => {
it('should render empty state when no data is returned', async () => {
const destinationQuerySpy = jest.fn().mockResolvedValue(destinationDataPopulator([]));
createComponent(destinationQuerySpy);
await waitForPromises();
expect(findStreamEmptyState().exists()).toBe(true);
});
it('should report error when server error occurred', async () => {
const destinationQuerySpy = jest.fn().mockRejectedValue({});
createComponent(destinationQuerySpy);
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: AUDIT_STREAMS_NETWORK_ERRORS.FETCHING_ERROR,
});
});
});
describe('when edit mode entered', () => {
it('shows destination editor', async () => {
await waitForPromises();
expect(findStreamDestinationEditor().exists()).toBe(false);
await findAddDestinationButton().vm.$emit('click');
expect(findStreamDestinationEditor().exists()).toBe(true);
});
it('refreshes the query and exit edit mode when external destination url added', async () => {
await waitForPromises();
expect(externalDestinationsQuerySpy).toHaveBeenCalledTimes(1);
expect(findStreamDestinationEditor().exists()).toBe(false);
await findAddDestinationButton().vm.$emit('click');
const streamDestinationEditorComponent = findStreamDestinationEditor();
expect(streamDestinationEditorComponent.exists()).toBe(true);
await streamDestinationEditorComponent.vm.$emit('added');
await waitForPromises();
expect(externalDestinationsQuerySpy).toHaveBeenCalledTimes(2);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StreamDestinationEditor when initialized should render correctly 1`] = `
<div
class="gl-p-4 gl-bg-white gl-border gl-rounded-base"
>
<div
class="gl-mb-5 gl-alert gl-alert-not-dismissible gl-alert-warning"
>
<svg
aria-hidden="true"
class="gl-icon s16 gl-alert-icon"
data-testid="warning-icon"
role="img"
>
<use
href="#warning"
/>
</svg>
<!---->
<div
class="gl-alert-content"
role="alert"
>
<h4
class="gl-alert-title"
>
Destinations receive all audit event data
</h4>
<div
class="gl-alert-body"
>
This could include sensitive information. Make sure you trust the destination endpoint.
</div>
<!---->
</div>
</div>
<form
class=""
>
<fieldset
aria-describedby=""
class="form-group gl-form-group"
id="__BVID__8"
>
<legend
class="bv-no-focus-ring col-form-label pt-0 col-form-label"
id="__BVID__8__BV_label_"
tabindex="-1"
>
Destination URL
<!---->
<!---->
</legend>
<div
aria-labelledby="__BVID__8__BV_label_"
class="bv-no-focus-ring"
role="group"
tabindex="-1"
>
<input
class="gl-form-input form-control"
id="__BVID__10"
placeholder="https://api.gitlab.com"
type="text"
/>
<!---->
<!---->
<!---->
</div>
</fieldset>
<div
class="gl-display-flex"
>
<button
class="btn gl-mr-3 btn-info btn-md disabled gl-button"
data-testid="stream-destination-add-button"
disabled="disabled"
name="Add external stream destination"
type="submit"
>
<!---->
<!---->
<span
class="gl-button-text"
>
Add
</span>
</button>
<button
class="btn btn-default btn-md gl-button"
data-testid="stream-destination-cancel-button"
name="AuditStreams|Cancel editing"
type="button"
>
<!---->
<!---->
<span
class="gl-button-text"
>
Cancel
</span>
</button>
</div>
</form>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StreamEmptyState should render correctly 1`] = `
<gl-empty-state-stub
class="gl-mt-5"
svgheight="72"
svgpath="mock/path"
title="Setup streaming for audit events"
>
<h3
class="h4 gl-font-size-h-display gl-line-height-36 gl-mt-n3"
>
Setup streaming for audit events
</h3>
<p>
Add an HTTP endpoint to manage audit logs in third-party systems.
</p>
<p>
This is great for keeping everything one place.
</p>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
variant="info"
>
Add stream
</gl-button-stub>
</gl-empty-state-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StreamItem render should render correctly 1`] = `
<li
class="list-item py-0"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-px-5 gl-py-3 gl-rounded-base"
>
<div
class="gl-h-4"
>
https://api.gitlab.com
</div>
<div
class="actions-button"
>
<b-button-stub
aria-label="Delete https://api.gitlab.com"
class="gl-button btn-default-tertiary btn-icon"
size="md"
tag="button"
title="Delete"
type="button"
variant="default"
>
<!---->
<gl-icon-stub
class="gl-button-icon"
name="remove"
size="16"
/>
<!---->
</b-button-stub>
</div>
</div>
</li>
`;
import VueApollo from 'vue-apollo';
import { createLocalVue } from '@vue/test-utils';
import { GlFormInput, GlForm } from '@gitlab/ui';
import createFlash from '~/flash';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import externalAuditEventDestinationCreate from 'ee/audit_events/graphql/create_external_destination.mutation.graphql';
import StreamDestinationEditor from 'ee/audit_events/components/stream/stream_destination_editor.vue';
import { AUDIT_STREAMS_NETWORK_ERRORS, ADD_STREAM_EDITOR_I18N } from 'ee/audit_events/constants';
import { destinationCreateMutationPopulator, groupPath } from '../../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const externalAuditEventDestinationCreateSpy = jest
.fn()
.mockResolvedValue(destinationCreateMutationPopulator());
describe('StreamDestinationEditor', () => {
let wrapper;
const createComponent = (
destinationCreateMutationSpy = externalAuditEventDestinationCreateSpy,
) => {
const mockApollo = createMockApollo([
[externalAuditEventDestinationCreate, destinationCreateMutationSpy],
]);
wrapper = mountExtended(StreamDestinationEditor, {
provide: {
groupPath,
},
apolloProvider: mockApollo,
localVue,
});
};
const findDestinationForm = () => wrapper.findComponent(GlForm);
const findCancelBtn = () => wrapper.findByTestId('stream-destination-cancel-button');
const setDestinationUrl = () =>
wrapper.findComponent(GlFormInput).setValue(ADD_STREAM_EDITOR_I18N.PLACEHOLDER);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
externalAuditEventDestinationCreateSpy.mockClear();
createFlash.mockClear();
});
describe('when initialized', () => {
it('should render correctly', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('add destination event', () => {
it('should emit add event after destination added', async () => {
await setDestinationUrl();
await findDestinationForm().trigger('submit');
await waitForPromises();
expect(createFlash).not.toHaveBeenCalled();
expect(wrapper.emitted('added')).toBeDefined();
});
it('should not emit add destination event and reports error when server returns error', async () => {
const errorMsg = 'Destination hosts limit exceeded';
createComponent(jest.fn().mockResolvedValue(destinationCreateMutationPopulator([errorMsg])));
await setDestinationUrl();
await findDestinationForm().trigger('submit');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: errorMsg,
});
expect(wrapper.emitted('added')).not.toBeDefined();
});
it('should not emit add destination event and reports error when network error occurs', async () => {
createComponent(jest.fn().mockRejectedValue());
await setDestinationUrl();
await findDestinationForm().trigger('submit');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: AUDIT_STREAMS_NETWORK_ERRORS.CREATING_ERROR,
});
expect(wrapper.emitted('added')).not.toBeDefined();
});
});
describe('cancel event', () => {
it('should emit cancel event correctly', async () => {
await findCancelBtn().trigger('click');
expect(wrapper.emitted('cancel')).toBeDefined();
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import StreamEmptyState from 'ee/audit_events/components/stream/stream_empty_state.vue';
import { mockSvgPath } from '../../mock_data';
describe('StreamEmptyState', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(StreamEmptyState, {
provide: {
emptyStateSvgPath: mockSvgPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should render correctly', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('should emit add event', () => {
wrapper.findComponent(GlButton).vm.$emit('click');
expect(wrapper.emitted('add')).toBeDefined();
});
});
import VueApollo from 'vue-apollo';
import { createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import deleteExternalDestination from 'ee/audit_events/graphql/delete_external_destination.mutation.graphql';
import { AUDIT_STREAMS_NETWORK_ERRORS } from 'ee/audit_events/constants';
import StreamItem from 'ee/audit_events/components/stream/stream_item.vue';
import { mockExternalDestinations } from '../../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('StreamItem', () => {
let wrapper;
const mutate = jest.fn().mockResolvedValue({
data: {
externalAuditEventDestinationDestroy: {
errors: [],
},
},
});
const createComponent = (deleteExternalDestinationSpy = mutate) => {
const mockApollo = createMockApollo([
[deleteExternalDestination, deleteExternalDestinationSpy],
]);
wrapper = shallowMountExtended(StreamItem, {
apolloProvider: mockApollo,
propsData: {
item: mockExternalDestinations[0],
},
stubs: {
GlButton,
},
localVue,
});
};
const findButton = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
createFlash.mockClear();
});
describe('render', () => {
it('should render correctly', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
describe('events', () => {
it('should emit delete with item id', async () => {
createComponent();
const button = findButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
await waitForPromises();
expect(wrapper.emitted('delete')).toBeDefined();
expect(button.props('loading')).toBe(false);
expect(createFlash).not.toHaveBeenCalled();
});
it('should not emit delete when backend error occurs', async () => {
const errorMsg = 'Random Error message';
const deleteExternalDestinationErrorSpy = jest.fn().mockResolvedValue({
data: {
externalAuditEventDestinationDestroy: {
errors: [errorMsg],
},
},
});
createComponent(deleteExternalDestinationErrorSpy);
const button = findButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
await waitForPromises();
expect(wrapper.emitted('delete')).not.toBeDefined();
expect(button.props('loading')).toBe(false);
expect(createFlash).toHaveBeenCalledWith({
message: errorMsg,
});
});
it('should not emit delete when network error occurs', async () => {
const deleteExternalDestinationErrorSpy = jest.fn().mockRejectedValue({
data: {
externalAuditEventDestinationDestroy: {
errors: [],
},
},
});
createComponent(deleteExternalDestinationErrorSpy);
const button = findButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
await waitForPromises();
expect(wrapper.emitted('delete')).not.toBeDefined();
expect(button.props('loading')).toBe(false);
expect(createFlash).toHaveBeenCalledWith({
message: AUDIT_STREAMS_NETWORK_ERRORS.DELETING_ERROR,
});
});
});
});
...@@ -33,3 +33,60 @@ export default () => [ ...@@ -33,3 +33,60 @@ export default () => [
populateEvent('User 3', true, false), populateEvent('User 3', true, false),
populateEvent('User 4', false, false), populateEvent('User 4', false, false),
]; ];
export const mockExternalDestinationUrl = 'https://api.gitlab.com';
export const mockExternalDestinations = [
{
destinationUrl: mockExternalDestinationUrl,
id: 'test_id1',
},
{
destinationUrl: 'https://apiv2.gitlab.com',
id: 'test_id2',
},
];
export const groupPath = 'test-group';
export const testGroupId = 'test-group-id';
export const destinationDataPopulator = (nodes) => ({
data: {
group: { id: testGroupId, externalAuditEventDestinations: { nodes } },
},
});
export const destinationCreateMutationPopulator = (errors = []) => {
const correctData = {
errors,
externalAuditEventDestination: {
id: 'test-create-id',
destinationUrl: mockExternalDestinationUrl,
group: {
name: groupPath,
id: testGroupId,
},
},
};
const errorData = {
errors,
externalAuditEventDestination: {
id: null,
destinationUrl: null,
group: {
name: null,
id: testGroupId,
},
},
};
return {
data: {
externalAuditEventDestinationCreate: errors.length > 0 ? errorData : correctData,
},
};
};
export const mockSvgPath = 'mock/path';
...@@ -5066,6 +5066,9 @@ msgstr "" ...@@ -5066,6 +5066,9 @@ msgstr ""
msgid "AuditLogs|IP Address" msgid "AuditLogs|IP Address"
msgstr "" msgstr ""
msgid "AuditLogs|Log"
msgstr ""
msgid "AuditLogs|Member Events" msgid "AuditLogs|Member Events"
msgstr "" msgstr ""
...@@ -5087,6 +5090,54 @@ msgstr "" ...@@ -5087,6 +5090,54 @@ msgstr ""
msgid "AuditLogs|User Events" msgid "AuditLogs|User Events"
msgstr "" msgstr ""
msgid "AuditStreams|Active"
msgstr ""
msgid "AuditStreams|Add an HTTP endpoint to manage audit logs in third-party systems."
msgstr ""
msgid "AuditStreams|Add external stream destination"
msgstr ""
msgid "AuditStreams|Add stream"
msgstr ""
msgid "AuditStreams|An error occurred when creating external audit event stream destination. Please try it again."
msgstr ""
msgid "AuditStreams|An error occurred when deleting external audit event stream destination. Please try it again."
msgstr ""
msgid "AuditStreams|An error occurred when fetching external audit event streams. Please try it again"
msgstr ""
msgid "AuditStreams|Cancel editing"
msgstr ""
msgid "AuditStreams|Delete %{link}"
msgstr ""
msgid "AuditStreams|Destination URL"
msgstr ""
msgid "AuditStreams|Destinations receive all audit event data"
msgstr ""
msgid "AuditStreams|Setup streaming for audit events"
msgstr ""
msgid "AuditStreams|Stream count icon"
msgstr ""
msgid "AuditStreams|Streams"
msgstr ""
msgid "AuditStreams|This could include sensitive information. Make sure you trust the destination endpoint."
msgstr ""
msgid "AuditStreams|This is great for keeping everything one place."
msgstr ""
msgid "Aug" msgid "Aug"
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