diff --git a/app/assets/javascripts/token_access/constants.js b/app/assets/javascripts/token_access/constants.js
index 80c013b2ed4c08bc386057ab5c0dadb4cb29a3dc..7fb9187b78d0c5b562217b0341880b0be7632cef 100644
--- a/app/assets/javascripts/token_access/constants.js
+++ b/app/assets/javascripts/token_access/constants.js
@@ -148,3 +148,5 @@ export const JOB_TOKEN_POLICIES = keyBy(
export const JOB_TOKEN_FORM_ADD_GROUP_OR_PROJECT = 'JOB_TOKEN_FORM_ADD_GROUP_OR_PROJECT';
export const JOB_TOKEN_FORM_AUTOPOPULATE_AUTH_LOG = 'JOB_TOKEN_FORM_AUTOPOPULATE_AUTH_LOG';
+export const JOB_TOKEN_REMOVE_AUTOPOPULATED_ENTRIES_MODAL =
+ 'JOB_TOKEN_REMOVE_AUTOPOPULATED_ENTRIES_MODAL';
diff --git a/app/assets/javascripts/token_access/graphql/mutations/remove_autopopulated_entries.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/remove_autopopulated_entries.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..4e73e54ef4fc0cd82ce93af4cd2674ac6402ac38
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/remove_autopopulated_entries.mutation.graphql
@@ -0,0 +1,6 @@
+mutation CiJobTokenScopeClearAllowlistAutopopulations($projectPath: ID!) {
+ ciJobTokenScopeClearAllowlistAutopopulations(input: { projectPath: $projectPath }) {
+ status
+ errors
+ }
+}
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index fdd40d29add32d697e6ca9db8855496d9ae6446f..29c6062aa4f9f855115e34e07012f3a126507e52 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -11231,6 +11231,9 @@ msgstr ""
msgid "CICD|An error occurred while adding the authentication log entries. Please try again."
msgstr ""
+msgid "CICD|An error occurred while removing the auto-added log entries. Please try again."
+msgstr ""
+
msgid "CICD|Are you sure you want to remove %{namespace} from the job token allowlist?"
msgstr ""
@@ -11246,6 +11249,9 @@ msgstr ""
msgid "CICD|Authentication log entries were successfully added to the allowlist."
msgstr ""
+msgid "CICD|Authentication log entries were successfully removed from the allowlist."
+msgstr ""
+
msgid "CICD|Authorized groups and projects"
msgstr ""
@@ -11345,9 +11351,18 @@ msgstr ""
msgid "CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more%{linkEnd}."
msgstr ""
+msgid "CICD|Remove all auto-added allowlist entries"
+msgstr ""
+
msgid "CICD|Remove group or project"
msgstr ""
+msgid "CICD|Removing auto-added allowlist entries. Please wait while the action completes."
+msgstr ""
+
+msgid "CICD|Removing these entries could cause authentication failures or disrupt pipelines."
+msgstr ""
+
msgid "CICD|Select the groups and projects authorized to use a CI/CD job token to authenticate requests to this project. %{linkStart}Learn more%{linkEnd}."
msgstr ""
@@ -11378,6 +11393,9 @@ msgstr ""
msgid "CICD|There was a problem fetching authorization logs count."
msgstr ""
+msgid "CICD|This action removes all groups and projects that were auto-added from the authentication log."
+msgstr ""
+
msgid "CICD|Unprotected branches will not have access to the cache from protected branches."
msgstr ""
@@ -47952,6 +47970,9 @@ msgstr ""
msgid "Remove email participants"
msgstr ""
+msgid "Remove entries"
+msgstr ""
+
msgid "Remove favicon"
msgstr ""
diff --git a/spec/frontend/token_access/autopopulate_allowlist_modal_spec.js b/spec/frontend/token_access/autopopulate_allowlist_modal_spec.js
index 2a3a59f53d2b1ecfc851aea7806f69bf57fb5f1a..e0eac4f7236fd983b5553332b1d132a8b44a28a4 100644
--- a/spec/frontend/token_access/autopopulate_allowlist_modal_spec.js
+++ b/spec/frontend/token_access/autopopulate_allowlist_modal_spec.js
@@ -87,12 +87,29 @@ describe('AutopopulateAllowlistModal component', () => {
);
});
- // TODO: Test for help link
- // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/181294
it('renders help link', () => {
expect(findLink().text()).toBe('What is the compaction algorithm?');
+ expect(findLink().attributes('href')).toBe(
+ '/help/ci/jobs/ci_job_token#auto-populate-a-projects-allowlist',
+ );
});
});
+
+ it.each`
+ modalEvent | emittedEvent
+ ${'canceled'} | ${'hide'}
+ ${'hidden'} | ${'hide'}
+ ${'secondary'} | ${'hide'}
+ `(
+ 'emits the $emittedEvent event when $modalEvent event is triggered',
+ ({ modalEvent, emittedEvent }) => {
+ expect(wrapper.emitted(emittedEvent)).toBeUndefined();
+
+ findModal().vm.$emit(modalEvent);
+
+ expect(wrapper.emitted(emittedEvent)).toHaveLength(1);
+ },
+ );
});
describe('when mutation is running', () => {
diff --git a/spec/frontend/token_access/inbound_token_access_spec.js b/spec/frontend/token_access/inbound_token_access_spec.js
index 45f99c7348fc7318b365540ea0d0409edba78043..5ecab522a0d2f4b341de36613ad4081826ab981c 100644
--- a/spec/frontend/token_access/inbound_token_access_spec.js
+++ b/spec/frontend/token_access/inbound_token_access_spec.js
@@ -1,4 +1,9 @@
-import { GlAlert, GlLoadingIcon, GlFormRadioGroup } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlFormRadioGroup,
+} from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -12,6 +17,7 @@ import {
} from '~/token_access/constants';
import AutopopulateAllowlistModal from '~/token_access/components/autopopulate_allowlist_modal.vue';
import NamespaceForm from '~/token_access/components/namespace_form.vue';
+import RemoveAutopopulatedEntriesModal from '~/token_access/components/remove_autopopulated_entries_modal.vue';
import inboundRemoveGroupCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_group_ci_job_token_scope.mutation.graphql';
import inboundRemoveProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql';
import inboundUpdateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql';
@@ -19,6 +25,7 @@ import inboundGetCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbou
import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql';
import getAuthLogCountQuery from '~/token_access/graphql/queries/get_auth_log_count.query.graphql';
import getCiJobTokenScopeAllowlistQuery from '~/token_access/graphql/queries/get_ci_job_token_scope_allowlist.query.graphql';
+import removeAutopopulatedEntriesMutation from '~/token_access/graphql/mutations/remove_autopopulated_entries.mutation.graphql';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ConfirmActionModal from '~/vue_shared/components/confirm_action_modal.vue';
import TokenAccessTable from '~/token_access/components/token_access_table.vue';
@@ -31,6 +38,7 @@ import {
inboundRemoveNamespaceSuccess,
inboundUpdateScopeSuccessResponse,
mockAuthLogsCountResponse,
+ mockRemoveAutopopulatedEntriesResponse,
} from './mock_data';
const projectPath = 'root/my-repo';
@@ -63,13 +71,22 @@ describe('TokenAccess component', () => {
const inboundUpdateScopeSuccessResponseHandler = jest
.fn()
.mockResolvedValue(inboundUpdateScopeSuccessResponse);
+ const removeAutopopulatedEntriesMutationHandler = jest
+ .fn()
+ .mockResolvedValue(mockRemoveAutopopulatedEntriesResponse());
+ const removeAutopopulatedEntriesMutationErrorHandler = jest
+ .fn()
+ .mockResolvedValue(mockRemoveAutopopulatedEntriesResponse({ errorMessage: message }));
const failureHandler = jest.fn().mockRejectedValue(error);
const mockToastShow = jest.fn();
const findAutopopulateAllowlistModal = () => wrapper.findComponent(AutopopulateAllowlistModal);
+ const findAutopopulationAlert = () => wrapper.findByTestId('autopopulation-alert');
+ const findAllowlistOptions = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findAllowlistOption = (index) =>
+ wrapper.findAllComponents(GlDisclosureDropdownItem).at(index).find('button');
const findFormSelector = () => wrapper.findByTestId('form-selector');
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findToggleFormBtn = () => wrapper.findByTestId('crud-form-toggle');
const findTokenDisabledAlert = () => wrapper.findComponent(GlAlert);
const findNamespaceForm = () => wrapper.findComponent(NamespaceForm);
@@ -78,6 +95,8 @@ describe('TokenAccess component', () => {
const findGroupCount = () => wrapper.findByTestId('group-count');
const findProjectCount = () => wrapper.findByTestId('project-count');
const findConfirmActionModal = () => wrapper.findComponent(ConfirmActionModal);
+ const findRemoveAutopopulatedEntriesModal = () =>
+ wrapper.findComponent(RemoveAutopopulatedEntriesModal);
const findTokenAccessTable = () => wrapper.findComponent(TokenAccessTable);
const createComponent = (
@@ -88,6 +107,7 @@ describe('TokenAccess component', () => {
enforceAllowlist = false,
projectAllowlistLimit = 2,
stubs = {},
+ isLoading = false,
} = {},
) => {
wrapper = shallowMountExtended(InboundTokenAccess, {
@@ -110,24 +130,30 @@ describe('TokenAccess component', () => {
},
});
- return waitForPromises();
+ if (!isLoading) {
+ return waitForPromises();
+ }
+
+ return Promise.resolve();
};
describe('loading state', () => {
it('shows loading state while waiting on query to resolve', async () => {
- createComponent([
- [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
+ createComponent(
[
- inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
- inboundGroupsAndProjectsWithScopeResponseHandler,
+ [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
+ [
+ inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
+ inboundGroupsAndProjectsWithScopeResponseHandler,
+ ],
],
- ]);
-
- expect(findLoadingIcon().exists()).toBe(true);
+ { isLoading: true },
+ );
- await waitForPromises();
+ await nextTick();
- expect(findLoadingIcon().exists()).toBe(false);
+ expect(findTokenAccessTable().props('loading')).toBe(true);
+ expect(findTokenAccessTable().props('loadingMessage')).toBe('');
});
});
@@ -448,8 +474,12 @@ describe('TokenAccess component', () => {
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
inboundGroupsAndProjectsWithScopeResponseHandler,
],
+ [removeAutopopulatedEntriesMutation, removeAutopopulatedEntriesMutationHandler],
],
- { authenticationLogsMigrationForAllowlist: true, stubs: { CrudComponent } },
+ {
+ authenticationLogsMigrationForAllowlist: true,
+ stubs: { CrudComponent, GlDisclosureDropdown, GlDisclosureDropdownItem },
+ },
),
);
@@ -496,6 +526,132 @@ describe('TokenAccess component', () => {
expect(findFormSelector().props('selected')).toBe(null);
});
});
+
+ describe('remove autopopulated entries', () => {
+ const triggerRemoveEntries = () => {
+ findAllowlistOption(0).trigger('click');
+ findRemoveAutopopulatedEntriesModal().vm.$emit('remove-entries');
+ };
+
+ it('additional actions are available in the disclosure dropdown', () => {
+ expect(findAllowlistOptions().exists()).toBe(true);
+ });
+
+ it('"Remove only entries auto-added" renders the remove autopopulated entries modal', async () => {
+ expect(findRemoveAutopopulatedEntriesModal().props('showModal')).toBe(false);
+
+ findAllowlistOption(0).trigger('click');
+ await nextTick();
+
+ expect(findRemoveAutopopulatedEntriesModal().props('showModal')).toBe(true);
+ });
+
+ it('shows loading state while remove autopopulated entries mutation is processing', async () => {
+ expect(findCountLoadingIcon().exists()).toBe(false);
+ expect(findTokenAccessTable().props('loading')).toBe(false);
+
+ triggerRemoveEntries();
+
+ await nextTick();
+
+ expect(findCountLoadingIcon().exists()).toBe(true);
+ expect(findTokenAccessTable().props('loading')).toBe(true);
+ expect(findTokenAccessTable().props('loadingMessage')).toBe(
+ 'Removing auto-added allowlist entries. Please wait while the action completes.',
+ );
+ });
+
+ it('calls the remove autopopulated entries mutation and refetches allowlist', async () => {
+ expect(removeAutopopulatedEntriesMutationHandler).toHaveBeenCalledTimes(0);
+ expect(inboundGroupsAndProjectsWithScopeResponseHandler).toHaveBeenCalledTimes(1);
+
+ triggerRemoveEntries();
+ await waitForPromises();
+ await nextTick();
+
+ expect(removeAutopopulatedEntriesMutationHandler).toHaveBeenCalledTimes(1);
+ expect(inboundGroupsAndProjectsWithScopeResponseHandler).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows toast message when mutation is successful', async () => {
+ triggerRemoveEntries();
+ await waitForPromises();
+ await nextTick();
+
+ expect(mockToastShow).toHaveBeenCalledWith(
+ 'Authentication log entries were successfully removed from the allowlist.',
+ );
+ });
+
+ it('shows error alert when mutation returns an error', async () => {
+ createComponent(
+ [
+ [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
+ [
+ inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
+ inboundGroupsAndProjectsWithScopeResponseHandler,
+ ],
+ [removeAutopopulatedEntriesMutation, removeAutopopulatedEntriesMutationErrorHandler],
+ ],
+ {
+ authenticationLogsMigrationForAllowlist: true,
+ stubs: { CrudComponent, GlDisclosureDropdown, GlDisclosureDropdownItem },
+ },
+ );
+
+ expect(findAutopopulationAlert().exists()).toBe(false);
+
+ triggerRemoveEntries();
+ await waitForPromises();
+ await nextTick();
+
+ expect(findAutopopulationAlert().text()).toBe('An error occurred');
+ });
+
+ it('shows error alert when mutation fails', async () => {
+ createComponent(
+ [
+ [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
+ [
+ inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
+ inboundGroupsAndProjectsWithScopeResponseHandler,
+ ],
+ [removeAutopopulatedEntriesMutation, failureHandler],
+ ],
+ {
+ authenticationLogsMigrationForAllowlist: true,
+ stubs: { CrudComponent, GlDisclosureDropdown, GlDisclosureDropdownItem },
+ },
+ );
+
+ expect(findAutopopulationAlert().exists()).toBe(false);
+
+ triggerRemoveEntries();
+ await waitForPromises();
+ await nextTick();
+
+ expect(findAutopopulationAlert().text()).toBe(
+ 'An error occurred while removing the auto-added log entries. Please try again.',
+ );
+ });
+
+ it('modal can be re-opened again after it closes', async () => {
+ findAllowlistOption(0).trigger('click');
+ await nextTick();
+
+ expect(findRemoveAutopopulatedEntriesModal().props('showModal')).toBe(true);
+
+ findRemoveAutopopulatedEntriesModal().vm.$emit('hide');
+ await nextTick();
+
+ expect(findRemoveAutopopulatedEntriesModal().props('showModal')).toBe(false);
+
+ findAllowlistOption(0).trigger('click');
+ await nextTick();
+
+ expect(findRemoveAutopopulatedEntriesModal().props('showModal')).toBe(true);
+ });
+ });
});
describe.each`
diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js
index 5bb8ed560a6b79f7347dda042099d99141e5facf..b7fd38afb6a7c5be8ea823618d8d7e21e2dfcb85 100644
--- a/spec/frontend/token_access/mock_data.js
+++ b/spec/frontend/token_access/mock_data.js
@@ -323,3 +323,13 @@ export const mockAutopopulateAllowlistError = {
},
},
};
+
+export const mockRemoveAutopopulatedEntriesResponse = ({ errorMessage } = {}) => ({
+ data: {
+ ciJobTokenScopeClearAllowlistAutopopulations: {
+ status: 'complete',
+ errors: errorMessage ? [{ message: errorMessage }] : [],
+ __typename: 'CiJobTokenScopeClearAllowlistAutopopulationsPayload',
+ },
+ },
+});
diff --git a/spec/frontend/token_access/remove_autopopulated_entries_modal_spec.js b/spec/frontend/token_access/remove_autopopulated_entries_modal_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..486df61705a3796d68eef3916d1d16271504f2e8
--- /dev/null
+++ b/spec/frontend/token_access/remove_autopopulated_entries_modal_spec.js
@@ -0,0 +1,63 @@
+import { GlModal } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RemoveAutopopulatedEntriesModal from '~/token_access/components/remove_autopopulated_entries_modal.vue';
+
+const projectName = 'My project';
+const fullPath = 'root/my-repo';
+
+Vue.use(VueApollo);
+
+describe('RemoveAutopopulatedEntriesModal component', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const createComponent = ({ props } = {}) => {
+ wrapper = shallowMountExtended(RemoveAutopopulatedEntriesModal, {
+ provide: {
+ fullPath,
+ },
+ propsData: {
+ projectName,
+ showModal: true,
+ ...props,
+ },
+ });
+ };
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ modalEvent | emittedEvent
+ ${'canceled'} | ${'hide'}
+ ${'hidden'} | ${'hide'}
+ ${'secondary'} | ${'hide'}
+ `(
+ 'emits the $emittedEvent event when $modalEvent event is triggered',
+ ({ modalEvent, emittedEvent }) => {
+ expect(wrapper.emitted(emittedEvent)).toBeUndefined();
+
+ findModal().vm.$emit(modalEvent);
+
+ expect(wrapper.emitted(emittedEvent)).toHaveLength(1);
+ },
+ );
+ });
+
+ describe('when clicking on the primary button', () => {
+ it('emits the remove-entries event', () => {
+ createComponent();
+
+ expect(wrapper.emitted('remove-entries')).toBeUndefined();
+
+ findModal().vm.$emit('primary', { preventDefault: jest.fn() });
+
+ expect(wrapper.emitted('remove-entries')).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/token_access/token_access_table_spec.js b/spec/frontend/token_access/token_access_table_spec.js
index 9278d05c7d15e66a819f702d8e8cc3e178b809f7..e97ae3720d59114a1fd377f2dd07293db80b394f 100644
--- a/spec/frontend/token_access/token_access_table_spec.js
+++ b/spec/frontend/token_access/token_access_table_spec.js
@@ -22,6 +22,7 @@ describe('Token access table', () => {
const findName = () => wrapper.findByTestId('token-access-name');
const findPolicies = () => findAllTableRows().at(0).findAll('td').at(1);
const findAutopopulatedIcon = () => wrapper.findByTestId('autopopulated-icon');
+ const findLoadingMessage = () => wrapper.findByTestId('loading-message');
describe.each`
type | items
@@ -86,6 +87,17 @@ describe('Token access table', () => {
createComponent({ items: mockGroups, loading: true });
expect(findTable().findComponent(GlLoadingIcon).props('size')).toBe('md');
+ expect(findLoadingMessage().exists()).toBe(false);
+ });
+
+ it('shows loading message when available', () => {
+ createComponent({
+ items: mockGroups,
+ loading: true,
+ loadingMessage: 'Removing auto-populated entries...',
+ });
+
+ expect(findLoadingMessage().text()).toBe('Removing auto-populated entries...');
});
});