diff --git a/doc/administration/audit_event_streaming.md b/doc/administration/audit_event_streaming.md
index 019032c8b98907296871604b1d380cf2aaec2bf1..817f22debbce841ddda4801434461b5e8c6ecd20 100644
--- a/doc/administration/audit_event_streaming.md
+++ b/doc/administration/audit_event_streaming.md
@@ -30,7 +30,7 @@ Event streaming destinations receive **all** audit event data, which could inclu
Users with at least the Owner role for 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 left sidebar, select **Security & Compliance > Audit events**.
1. On the main area, select **Streams** tab.
- When the destination list is empty, select **Add stream** to show the section for adding destinations.
- When the destination list is not empty, select **{plus}** to show the section for adding destinations.
@@ -77,7 +77,7 @@ Users with at least the Owner role for a group can list event streaming destinat
Users with at least the Owner role for 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 left sidebar, select **Security & Compliance > Audit events**.
1. On the main area, select **Streams** tab.
### Use the API
@@ -116,7 +116,7 @@ When the last destination is successfully deleted, event streaming is disabled f
Users with at least the Owner role for a group can delete 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 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.
@@ -185,7 +185,7 @@ not available. The UI for this feature is not ready for production use.
Users with at least the Owner role for a group can add event streaming destinations and custom HTTP headers 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 left sidebar, select **Security & Compliance > Audit events**.
1. On the main area, select **Streams** tab.
- When the destination list is empty, select **Add stream** to show the section for adding destinations.
- When the destination list is not empty, select **{plus}** to show the section for adding destinations.
@@ -221,7 +221,7 @@ mutation {
### Deleting custom HTTP headers
Group owners can remove a HTTP header using the GraphQL `auditEventsStreamingHeadersDestroy` mutation. You can retrieve the header ID
-by [listing all the custom headers](#list-all-custom-headers-with-the-api) on the group.
+by [listing all the custom headers](#list-all-custom-headers) on the group.
```graphql
mutation {
@@ -233,7 +233,11 @@ mutation {
The header is deleted if the returned `errors` object is empty.
-### List all custom headers with the API
+### List all custom headers
+
+List all custom HTTP headers with the API or GitLab UI.
+
+#### Use the API
You can list all custom headers for a top-level group as well as their value and ID using the GraphQL `externalAuditEventDestinations` query. The ID
value returned by this query is what you need to pass to the `deletion` mutation.
@@ -259,6 +263,22 @@ query {
}
```
+#### Use the GitLab UI
+
+FLAG:
+On self-managed GitLab, by default the UI for this feature is not available. To make it available per group, ask an administrator to
+[enable the feature flag](../administration/feature_flags.md) named `custom_headers_streaming_audit_events_ui`. On GitLab.com, the UI for this feature is
+not available. The UI for this feature is not ready for production use.
+
+Users with at least the Owner role for a group can add event streaming destinations and custom HTTP headers 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.
+1. Select **{pencil}** at the right side of an item.
+1. A read-only view of the items custom headers is shown. To track progress on adding editing functionality, see the [relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/361925).
+1. Select **Cancel** to close the read-only view.
+
## Verify event authenticity
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345424) in GitLab 14.8.
diff --git a/ee/app/assets/javascripts/audit_events/components/audit_events_stream.vue b/ee/app/assets/javascripts/audit_events/components/audit_events_stream.vue
index 973a8f657dda1be450da02505c8c074e507a59e1..2aa588f9d3f366eca7ab19f11bfe8055f090ab66 100644
--- a/ee/app/assets/javascripts/audit_events/components/audit_events_stream.vue
+++ b/ee/app/assets/javascripts/audit_events/components/audit_events_stream.vue
@@ -100,7 +100,7 @@ export default {
{{ destinationsCount }}
{{ $options.i18n.VERIFICATION_TOKEN_TOOLTIP }}:
{{ item.verificationToken }}
-
+
-
+
+
+
+
+
+`;
+
+exports[`StreamItem render should render correctly 1`] = `
+
+
+
+ https://api.gitlab.com
+
+
+
+
+ Verification token:
+
+
+ id5hzCbERzSkQ82tAs16tH5Y
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/ee/spec/frontend/audit_events/components/stream/stream_destination_editor_spec.js b/ee/spec/frontend/audit_events/components/stream/stream_destination_editor_spec.js
index a0eadcf9f1ec7bc17de6719c8d14e7de1c06b7bb..5797d014816f091aa7134ce8aa76019e8d561748 100644
--- a/ee/spec/frontend/audit_events/components/stream/stream_destination_editor_spec.js
+++ b/ee/spec/frontend/audit_events/components/stream/stream_destination_editor_spec.js
@@ -20,6 +20,8 @@ import {
destinationDeleteMutationPopulator,
destinationHeaderCreateMutationPopulator,
groupPath,
+ mockExternalDestinations,
+ mockExternalDestinationHeader,
} from '../../mock_data';
const localVue = createLocalVue();
@@ -33,6 +35,7 @@ describe('StreamDestinationEditor', () => {
const createComponent = (
mountFn = shallowMountExtended,
provide = {},
+ propsData = {},
apolloHandlers = [
[
externalAuditEventDestinationCreate,
@@ -48,6 +51,7 @@ describe('StreamDestinationEditor', () => {
maxHeaders,
...provide,
},
+ propsData,
apolloProvider: mockApollo,
localVue,
});
@@ -100,6 +104,7 @@ describe('StreamDestinationEditor', () => {
it('should render the destination URL input', () => {
expect(findDestinationUrlFormGroup().exists()).toBe(true);
+ expect(findDestinationUrl().props('disabled')).toBe(undefined);
expect(findDestinationUrl().attributes('placeholder')).toBe(
ADD_STREAM_EDITOR_I18N.DESTINATION_URL_PLACEHOLDER,
);
@@ -139,7 +144,7 @@ describe('StreamDestinationEditor', () => {
describe('add destination event without headers', () => {
it('should emit add event after destination added', async () => {
- createComponent(shallowMountExtended, {}, [
+ createComponent(shallowMountExtended, {}, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator()),
@@ -156,7 +161,7 @@ describe('StreamDestinationEditor', () => {
it('should not emit add destination event and reports error when server returns error', async () => {
const errorMsg = 'Destination hosts limit exceeded';
- createComponent(shallowMountExtended, {}, [
+ createComponent(shallowMountExtended, {}, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator([errorMsg])),
@@ -175,7 +180,7 @@ describe('StreamDestinationEditor', () => {
it('should not emit add destination event and reports error when network error occurs', async () => {
const sentryError = new Error('Network error');
const sentryCaptureExceptionSpy = jest.spyOn(Sentry, 'captureException');
- createComponent(shallowMountExtended, {}, [
+ createComponent(shallowMountExtended, {}, {}, [
[externalAuditEventDestinationCreate, jest.fn().mockRejectedValue(sentryError)],
]);
@@ -192,7 +197,7 @@ describe('StreamDestinationEditor', () => {
describe('add destination event with headers', () => {
it('should emit add event after destination and headers are added', async () => {
- createComponent(mountExtended, { showStreamsHeaders: true }, [
+ createComponent(mountExtended, { showStreamsHeaders: true }, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator()),
@@ -218,7 +223,7 @@ describe('StreamDestinationEditor', () => {
.fn()
.mockResolvedValue(destinationHeaderCreateMutationPopulator());
- createComponent(mountExtended, { showStreamsHeaders: true }, [
+ createComponent(mountExtended, { showStreamsHeaders: true }, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator()),
@@ -244,7 +249,7 @@ describe('StreamDestinationEditor', () => {
it('should not emit add destination event and reports error when server returns error while adding headers', async () => {
const errorMsg = 'Destination hosts limit exceeded';
- createComponent(mountExtended, { showStreamsHeaders: true }, [
+ createComponent(mountExtended, { showStreamsHeaders: true }, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator()),
@@ -276,7 +281,7 @@ describe('StreamDestinationEditor', () => {
it('should not emit add destination event and reports error when network error occurs while adding headers', async () => {
const sentryError = new Error('Network error');
const sentryCaptureExceptionSpy = jest.spyOn(Sentry, 'captureException');
- createComponent(mountExtended, { showStreamsHeaders: true }, [
+ createComponent(mountExtended, { showStreamsHeaders: true }, {}, [
[
externalAuditEventDestinationCreate,
jest.fn().mockResolvedValue(destinationCreateMutationPopulator()),
@@ -425,4 +430,39 @@ describe('StreamDestinationEditor', () => {
);
});
});
+
+ describe('when editing an existing destination', () => {
+ const item = {
+ ...mockExternalDestinations[0],
+ headers: { nodes: [mockExternalDestinationHeader(), mockExternalDestinationHeader()] },
+ };
+
+ beforeEach(() => {
+ createComponent(mountExtended, { showStreamsHeaders: true }, { item });
+ });
+
+ it('disables the destination URL field', () => {
+ expect(findDestinationUrl().element.value).toBe(mockExternalDestinations[0].destinationUrl);
+ expect(findDestinationUrl().attributes('disabled')).toBe('disabled');
+ });
+
+ it('disables the save button', () => {
+ expect(findAddBtn().attributes('disabled')).toBe('disabled');
+ });
+
+ it('changes the save button text', () => {
+ expect(findAddBtn().attributes('name')).toBe(ADD_STREAM_EDITOR_I18N.SAVE_BUTTON_NAME);
+ expect(findAddBtn().text()).toBe(ADD_STREAM_EDITOR_I18N.SAVE_BUTTON_TEXT);
+ });
+
+ it.each([0, 1])('disables the header inputs for row %i', (i) => {
+ expect(findHeaderNameInput(i).attributes('disabled')).toBe('disabled');
+ expect(findHeaderValueInput(i).attributes('disabled')).toBe('disabled');
+ });
+
+ it.each([0, 1])('sets the header input values for row %i', (i) => {
+ expect(findHeaderNameInput(i).element.value).toBe(item.headers.nodes[i].key);
+ expect(findHeaderValueInput(i).element.value).toBe(item.headers.nodes[i].value);
+ });
+ });
});
diff --git a/ee/spec/frontend/audit_events/components/stream/stream_item_spec.js b/ee/spec/frontend/audit_events/components/stream/stream_item_spec.js
index e8c11faeee2a2c84fcc96b7699a3fac4c472e352..e0f7e7a1eb421915143b3c38541216d1503a2316 100644
--- a/ee/spec/frontend/audit_events/components/stream/stream_item_spec.js
+++ b/ee/spec/frontend/audit_events/components/stream/stream_item_spec.js
@@ -8,6 +8,7 @@ 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 StreamDestinationEditor from 'ee/audit_events/components/stream/stream_destination_editor.vue';
import { destinationDeleteMutationPopulator, mockExternalDestinations } from '../../mock_data';
jest.mock('~/flash');
@@ -37,7 +38,9 @@ describe('StreamItem', () => {
});
};
- const findButton = () => wrapper.findComponent(GlButton);
+ const findEditButton = () => wrapper.findByTestId('edit-btn');
+ const findDeleteButton = () => wrapper.findByTestId('delete-btn');
+ const findEditor = () => wrapper.findComponent(StreamDestinationEditor);
afterEach(() => {
wrapper.destroy();
@@ -45,17 +48,23 @@ describe('StreamItem', () => {
});
describe('render', () => {
- it('should render correctly', () => {
+ beforeEach(() => {
createComponent();
+ });
+ it('should render correctly', () => {
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('should not show the editor', () => {
+ expect(findEditor().exists()).toBe(false);
+ });
});
describe('events', () => {
it('should emit delete with item id', async () => {
createComponent();
- const button = findButton();
+ const button = findDeleteButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
@@ -73,7 +82,7 @@ describe('StreamItem', () => {
.fn()
.mockResolvedValue(destinationDeleteMutationPopulator([errorMsg]));
createComponent(deleteExternalDestinationErrorSpy);
- const button = findButton();
+ const button = findDeleteButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
@@ -90,7 +99,7 @@ describe('StreamItem', () => {
it('should not emit delete when network error occurs', async () => {
const error = new Error('Network error');
createComponent(jest.fn().mockRejectedValue(error));
- const button = findButton();
+ const button = findDeleteButton();
await button.trigger('click');
expect(button.props('loading')).toBe(true);
@@ -106,4 +115,35 @@ describe('StreamItem', () => {
});
});
});
+
+ describe('editing', () => {
+ beforeEach(() => {
+ createComponent();
+ findEditButton().trigger('click');
+ });
+
+ it('should render correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should pass the item to the editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ expect(findEditor().props('item')).toStrictEqual(mockExternalDestinations[0]);
+ });
+
+ it('should emit the updated event when the editor fires its added event', async () => {
+ findEditor().vm.$emit('added');
+ await waitForPromises();
+
+ expect(wrapper.emitted('updated')).toBeDefined();
+ expect(findEditor().exists()).toBe(false);
+ });
+
+ it('should close the editor when the editor fires its cancel event', async () => {
+ findEditor().vm.$emit('cancel');
+ await waitForPromises();
+
+ expect(findEditor().exists()).toBe(false);
+ });
+ });
});
diff --git a/ee/spec/frontend/audit_events/mock_data.js b/ee/spec/frontend/audit_events/mock_data.js
index 04d27827cec1970eb3b30b67ff6d7a2a2383caf5..809a1fea43f481ad6339894482caf757436ee21c 100644
--- a/ee/spec/frontend/audit_events/mock_data.js
+++ b/ee/spec/frontend/audit_events/mock_data.js
@@ -36,17 +36,27 @@ export default () => [
];
export const mockExternalDestinationUrl = 'https://api.gitlab.com';
-
+export const mockExternalDestinationHeader = () => ({
+ id: uniqueId('gid://gitlab/AuditEvents::Streaming::Header/'),
+ key: uniqueId('header-key-'),
+ value: uniqueId('header-value-'),
+});
export const mockExternalDestinations = [
{
id: 'test_id1',
destinationUrl: mockExternalDestinationUrl,
verificationToken: 'id5hzCbERzSkQ82tAs16tH5Y',
+ headers: {
+ nodes: [],
+ },
},
{
id: 'test_id2',
destinationUrl: 'https://apiv2.gitlab.com',
verificationToken: 'JsSQtg86au6buRtX9j98sYa8',
+ headers: {
+ nodes: [],
+ },
},
];
diff --git a/ee/spec/frontend/audit_events/utils_spec.js b/ee/spec/frontend/audit_events/utils_spec.js
index 710a2d038759f8d08aa2abd19b879135a4799c27..35186b5f03f4107222905072a3696e9ff5aa890f 100644
--- a/ee/spec/frontend/audit_events/utils_spec.js
+++ b/ee/spec/frontend/audit_events/utils_spec.js
@@ -3,7 +3,9 @@ import {
getEntityTypeFromType,
parseAuditEventSearchQuery,
createAuditEventSearchQuery,
+ mapItemHeadersToFormData,
} from 'ee/audit_events/utils';
+import { mockExternalDestinationHeader } from './mock_data';
describe('Audit Event Utils', () => {
describe('getTypeFromEntityType', () => {
@@ -74,4 +76,99 @@ describe('Audit Event Utils', () => {
},
);
});
+
+ describe('mapItemHeadersToFormData', () => {
+ const header1 = mockExternalDestinationHeader();
+ const header2 = mockExternalDestinationHeader();
+ const header3 = mockExternalDestinationHeader();
+
+ it.each([{}, { headers: {} }, { headers: { nodes: [] } }])(
+ 'returns an empty array when there are no headers',
+ (item) => {
+ expect(mapItemHeadersToFormData(item)).toEqual([]);
+ },
+ );
+
+ it('returns the formatted headers', () => {
+ expect(mapItemHeadersToFormData({ headers: { nodes: [header1, header2] } })).toStrictEqual([
+ {
+ id: header1.id,
+ name: header1.key,
+ value: header1.value,
+ active: true,
+ disabled: false,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ {
+ id: header2.id,
+ name: header2.key,
+ value: header2.value,
+ active: true,
+ disabled: false,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ ]);
+ });
+
+ it('applies the settings to each header when given', () => {
+ expect(
+ mapItemHeadersToFormData({ headers: { nodes: [header1, header2] } }, { disabled: true }),
+ ).toStrictEqual([
+ {
+ id: header1.id,
+ name: header1.key,
+ value: header1.value,
+ active: true,
+ disabled: true,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ {
+ id: header2.id,
+ name: header2.key,
+ value: header2.value,
+ active: true,
+ disabled: true,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ ]);
+ });
+
+ it('sorts the headers by their ID', () => {
+ expect(
+ mapItemHeadersToFormData({ headers: { nodes: [header3, header1, header2] } }),
+ ).toStrictEqual([
+ {
+ id: header1.id,
+ name: header1.key,
+ value: header1.value,
+ active: true,
+ disabled: false,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ {
+ id: header2.id,
+ name: header2.key,
+ value: header2.value,
+ active: true,
+ disabled: false,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ {
+ id: header3.id,
+ name: header3.key,
+ value: header3.value,
+ active: true,
+ disabled: false,
+ deletionDisabled: true,
+ validationErrors: { name: '' },
+ },
+ ]);
+ });
+ });
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 641aad5c9c38aa0d302718c8f9b1afecfbb2d464..58a2b53aa4e01f6eb4570115b54d54aaff433b11 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5334,12 +5334,18 @@ msgstr ""
msgid "AuditStreams|Destinations receive all audit event data"
msgstr ""
+msgid "AuditStreams|Edit %{link}"
+msgstr ""
+
msgid "AuditStreams|Header"
msgstr ""
msgid "AuditStreams|Maximum of %{number} HTTP headers has been reached."
msgstr ""
+msgid "AuditStreams|Save external stream destination"
+msgstr ""
+
msgid "AuditStreams|Setup streaming for audit events"
msgstr ""