diff --git a/app/assets/javascripts/token_access/components/autopopulate_allowlist_modal.vue b/app/assets/javascripts/token_access/components/autopopulate_allowlist_modal.vue index 31f86d1eea9d02a247be8d59f614b0354badedd1..3b6df8c2e5e4ab102a29ef1142da6b4954045dbb 100644 --- a/app/assets/javascripts/token_access/components/autopopulate_allowlist_modal.vue +++ b/app/assets/javascripts/token_access/components/autopopulate_allowlist_modal.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { __, s__, sprintf } from '~/locale'; import autopopulateAllowlistMutation from '../graphql/mutations/autopopulate_allowlist.mutation.graphql'; @@ -116,6 +117,9 @@ export default { this.$emit('hide'); }, }, + compactionAlgorithmHelpPage: helpPagePath('ci/jobs/ci_job_token', { + anchor: 'auto-populate-a-projects-allowlist', + }), }; </script> @@ -129,6 +133,7 @@ export default { @primary.prevent="autopopulateAllowlist" @secondary="hideModal" @canceled="hideModal" + @hidden="hideModal" > <gl-alert v-if="errorMessage" variant="danger" class="gl-mb-3" :dismissible="false"> {{ errorMessage }} @@ -138,8 +143,6 @@ export default { {{ authLogExceedsLimitMessage }} </gl-alert> <p data-testid="modal-description"> - <!-- TODO: Update documentation link --> - <!-- See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/181294 --> <gl-sprintf :message=" s__( @@ -148,7 +151,9 @@ export default { " > <template #link="{ content }"> - <gl-link href="/" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.compactionAlgorithmHelpPage" target="_blank">{{ + content + }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index efc465e56afa934befa50d29f5220834a28ea586..3f81fbc7a7bfb1dabcdbe2509281c8f24ce8c094 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -3,6 +3,7 @@ import { GlAlert, GlButton, GlCollapsibleListbox, + GlDisclosureDropdown, GlIcon, GlLink, GlLoadingIcon, @@ -24,13 +25,16 @@ import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_jo import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql'; import getCiJobTokenScopeAllowlistQuery from '../graphql/queries/get_ci_job_token_scope_allowlist.query.graphql'; import getAuthLogCountQuery from '../graphql/queries/get_auth_log_count.query.graphql'; +import removeAutopopulatedEntriesMutation from '../graphql/mutations/remove_autopopulated_entries.mutation.graphql'; import { JOB_TOKEN_FORM_ADD_GROUP_OR_PROJECT, JOB_TOKEN_FORM_AUTOPOPULATE_AUTH_LOG, + JOB_TOKEN_REMOVE_AUTOPOPULATED_ENTRIES_MODAL, } from '../constants'; import TokenAccessTable from './token_access_table.vue'; import NamespaceForm from './namespace_form.vue'; import AutopopulateAllowlistModal from './autopopulate_allowlist_modal.vue'; +import RemoveAutopopulatedEntriesModal from './remove_autopopulated_entries_modal.vue'; export default { i18n: { @@ -55,6 +59,7 @@ export default { 'CICD|Are you sure you want to remove %{namespace} from the job token allowlist?', ), removeNamespaceModalActionText: s__('CICD|Remove group or project'), + removeAutopopulatedEntries: s__('CICD|Remove all auto-added allowlist entries'), }, inboundJobTokenScopeOptions: [ { @@ -81,11 +86,13 @@ export default { GlAlert, GlButton, GlCollapsibleListbox, + GlDisclosureDropdown, GlIcon, GlLink, GlLoadingIcon, GlSprintf, CrudComponent, + RemoveAutopopulatedEntriesModal, TokenAccessTable, GlFormRadioGroup, NamespaceForm, @@ -170,9 +177,11 @@ export default { data() { return { authLogCount: 0, + allowlistLoadingMessage: '', inboundJobTokenScopeEnabled: null, - isUpdating: false, + isUpdatingJobTokenScope: false, groupsAndProjectsWithAccess: { groups: [], projects: [] }, + autopopulationErrorMessage: null, projectName: '', namespaceToEdit: null, namespaceToRemove: null, @@ -183,6 +192,12 @@ export default { authLogExceedsLimit() { return this.projectCount + this.groupCount + this.authLogCount > this.projectAllowlistLimit; }, + isAllowlistLoading() { + return ( + this.$apollo.queries.groupsAndProjectsWithAccess.loading || + this.allowlistLoadingMessage.length > 0 + ); + }, isJobTokenPoliciesEnabled() { return this.glFeatures.addPoliciesToCiJobToken; }, @@ -198,6 +213,17 @@ export default { canAutopopulateAuthLog() { return this.glFeatures.authenticationLogsMigrationForAllowlist; }, + disclosureDropdownOptions() { + return [ + { + text: this.$options.i18n.removeAutopopulatedEntries, + variant: 'danger', + action: () => { + this.selectedAction = JOB_TOKEN_REMOVE_AUTOPOPULATED_ENTRIES_MODAL; + }, + }, + ]; + }, groupCount() { return this.groupsAndProjectsWithAccess.groups.length; }, @@ -210,14 +236,14 @@ export default { projectCountTooltip() { return n__('%d project has access', '%d projects have access', this.projectCount); }, - isAllowlistLoading() { - return this.$apollo.queries.groupsAndProjectsWithAccess.loading; - }, removeNamespaceModalTitle() { return sprintf(this.$options.i18n.removeNamespaceModalTitle, { namespace: this.namespaceToRemove?.fullPath, }); }, + showRemoveAutopopulatedEntriesModal() { + return this.selectedAction === JOB_TOKEN_REMOVE_AUTOPOPULATED_ENTRIES_MODAL; + }, showAutopopulateModal() { return this.selectedAction === JOB_TOKEN_FORM_AUTOPOPULATE_AUTH_LOG; }, @@ -238,7 +264,7 @@ export default { })); }, async updateCIJobTokenScope() { - this.isUpdating = true; + this.isUpdatingJobTokenScope = true; try { const { @@ -268,7 +294,7 @@ export default { this.inboundJobTokenScopeEnabled = !this.inboundJobTokenScopeEnabled; createAlert({ message: error.message }); } finally { - this.isUpdating = false; + this.isUpdatingJobTokenScope = false; } }, async removeItem() { @@ -291,6 +317,42 @@ export default { this.refetchGroupsAndProjects(); return Promise.resolve(); }, + async removeAutopopulatedEntries() { + this.hideSelectedAction(); + this.autopopulationErrorMessage = null; + this.allowlistLoadingMessage = s__( + 'CICD|Removing auto-added allowlist entries. Please wait while the action completes.', + ); + + try { + const { + data: { + ciJobTokenScopeClearAllowlistAutopopulations: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: removeAutopopulatedEntriesMutation, + variables: { + projectPath: this.fullPath, + }, + }); + + if (errors.length) { + this.autopopulationErrorMessage = errors[0].message; + return; + } + + this.refetchAllowlist(); + this.$toast.show( + s__('CICD|Authentication log entries were successfully removed from the allowlist.'), + ); + } catch (error) { + this.autopopulationErrorMessage = s__( + 'CICD|An error occurred while removing the auto-added log entries. Please try again.', + ); + } finally { + this.allowlistLoadingMessage = ''; + } + }, refetchAllowlist() { this.$apollo.queries.groupsAndProjectsWithAccess.refetch(); this.hideSelectedAction(); @@ -328,117 +390,133 @@ export default { @hide="hideSelectedAction" @refetch-allowlist="refetchAllowlist" /> - <gl-loading-icon v-if="$apollo.queries.inboundJobTokenScopeEnabled.loading" size="md" /> - <template v-else> - <div class="gl-font-bold"> - {{ $options.i18n.radioGroupTitle }} - </div> - <div class="gl-mb-3"> - <gl-sprintf :message="$options.i18n.radioGroupDescription"> - <template #link="{ content }"> - <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </div> - <gl-form-radio-group - v-if="!enforceAllowlist" - v-model="inboundJobTokenScopeEnabled" - :options="$options.inboundJobTokenScopeOptions" - stacked - /> - <gl-alert - v-if="!inboundJobTokenScopeEnabled && !enforceAllowlist" - variant="warning" - class="gl-my-3" - :dismissible="false" - :show-icon="false" - > - {{ $options.i18n.settingDisabledMessage }} - </gl-alert> - - <gl-button - v-if="!enforceAllowlist" - variant="confirm" - class="gl-mt-3" - data-testid="save-ci-job-token-scope-changes-btn" - :loading="isUpdating" - @click="updateCIJobTokenScope" - > - {{ $options.i18n.saveButtonTitle }} - </gl-button> - - <crud-component - :title="$options.i18n.cardHeaderTitle" - :description="$options.i18n.cardHeaderDescription" - :toggle-text="!canAutopopulateAuthLog ? $options.i18n.addGroupOrProject : undefined" - class="gl-mt-5" - @hideForm="hideSelectedAction" - > - <template v-if="canAutopopulateAuthLog" #actions="{ showForm }"> - <gl-collapsible-listbox - v-model="selectedAction" - :items="$options.crudFormActions" - :toggle-text="$options.i18n.add" - data-testid="form-selector" - size="small" - @select="selectAction($event, showForm)" - /> + <remove-autopopulated-entries-modal + :show-modal="showRemoveAutopopulatedEntriesModal" + @hide="hideSelectedAction" + @remove-entries="removeAutopopulatedEntries" + /> + <div class="gl-font-bold"> + {{ $options.i18n.radioGroupTitle }} + </div> + <div class="gl-mb-3"> + <gl-sprintf :message="$options.i18n.radioGroupDescription"> + <template #link="{ content }"> + <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">{{ + content + }}</gl-link> </template> - <template #count> - <gl-loading-icon v-if="isAllowlistLoading" data-testid="count-loading-icon" /> - <template v-else> - <span - v-gl-tooltip.d0="groupCountTooltip" - class="gl-cursor-default" - data-testid="group-count" - > - <gl-icon name="group" /> {{ groupCount }} - </span> - <span - v-gl-tooltip.d0="projectCountTooltip" - class="gl-ml-2 gl-cursor-default" - data-testid="project-count" - > - <gl-icon name="project" /> {{ projectCount }} - </span> - </template> + </gl-sprintf> + </div> + <gl-form-radio-group + v-if="!enforceAllowlist" + v-model="inboundJobTokenScopeEnabled" + :options="$options.inboundJobTokenScopeOptions" + stacked + /> + <gl-alert + v-if="!inboundJobTokenScopeEnabled && !enforceAllowlist" + variant="warning" + class="gl-my-3" + :dismissible="false" + :show-icon="false" + > + {{ $options.i18n.settingDisabledMessage }} + </gl-alert> + <gl-button + v-if="!enforceAllowlist" + variant="confirm" + class="gl-mt-3" + data-testid="save-ci-job-token-scope-changes-btn" + :loading="isUpdatingJobTokenScope" + @click="updateCIJobTokenScope" + > + {{ $options.i18n.saveButtonTitle }} + </gl-button> + <gl-alert + v-if="autopopulationErrorMessage" + variant="danger" + class="gl-my-5" + :dismissible="false" + data-testid="autopopulation-alert" + > + {{ autopopulationErrorMessage }} + </gl-alert> + <crud-component + :title="$options.i18n.cardHeaderTitle" + :description="$options.i18n.cardHeaderDescription" + :toggle-text="!canAutopopulateAuthLog ? $options.i18n.addGroupOrProject : undefined" + class="gl-mt-5" + @hideForm="hideSelectedAction" + > + <template v-if="canAutopopulateAuthLog" #actions="{ showForm }"> + <gl-collapsible-listbox + v-model="selectedAction" + :items="$options.crudFormActions" + :toggle-text="$options.i18n.add" + data-testid="form-selector" + size="small" + @select="selectAction($event, showForm)" + /> + <gl-disclosure-dropdown + category="tertiary" + icon="ellipsis_v" + no-caret + :items="disclosureDropdownOptions" + /> + </template> + <template #count> + <gl-loading-icon v-if="isAllowlistLoading" data-testid="count-loading-icon" /> + <template v-else> + <span + v-gl-tooltip.d0="groupCountTooltip" + class="gl-cursor-default" + data-testid="group-count" + > + <gl-icon name="group" /> {{ groupCount }} + </span> + <span + v-gl-tooltip.d0="projectCountTooltip" + class="gl-ml-2 gl-cursor-default" + data-testid="project-count" + > + <gl-icon name="project" /> {{ projectCount }} + </span> </template> + </template> - <template #form="{ hideForm }"> - <namespace-form - :namespace="namespaceToEdit" - @saved="refetchGroupsAndProjects" - @close="hideForm" - /> - </template> + <template #form="{ hideForm }"> + <namespace-form + :namespace="namespaceToEdit" + @saved="refetchGroupsAndProjects" + @close="hideForm" + /> + </template> - <template #default="{ showForm }"> - <token-access-table - :items="allowlist" - :loading="isAllowlistLoading" - :show-policies="isJobTokenPoliciesEnabled" - @editItem="showNamespaceForm($event, showForm)" - @removeItem="namespaceToRemove = $event" - /> + <template #default="{ showForm }"> + <token-access-table + :items="allowlist" + :loading="isAllowlistLoading" + :loading-message="allowlistLoadingMessage" + :show-policies="isJobTokenPoliciesEnabled" + @editItem="showNamespaceForm($event, showForm)" + @removeItem="namespaceToRemove = $event" + /> - <confirm-action-modal - v-if="namespaceToRemove" - modal-id="inbound-token-access-remove-confirm-modal" - :title="removeNamespaceModalTitle" - :action-fn="removeItem" - :action-text="$options.i18n.removeNamespaceModalActionText" - @close="namespaceToRemove = null" - > - <gl-sprintf :message="$options.i18n.removeNamespaceModalText"> - <template #namespace> - <code>{{ namespaceToRemove.fullPath }}</code> - </template> - </gl-sprintf> - </confirm-action-modal> - </template> - </crud-component> - </template> + <confirm-action-modal + v-if="namespaceToRemove" + modal-id="inbound-token-access-remove-confirm-modal" + :title="removeNamespaceModalTitle" + :action-fn="removeItem" + :action-text="$options.i18n.removeNamespaceModalActionText" + @close="namespaceToRemove = null" + > + <gl-sprintf :message="$options.i18n.removeNamespaceModalText"> + <template #namespace> + <code>{{ namespaceToRemove.fullPath }}</code> + </template> + </gl-sprintf> + </confirm-action-modal> + </template> + </crud-component> </div> </template> diff --git a/app/assets/javascripts/token_access/components/remove_autopopulated_entries_modal.vue b/app/assets/javascripts/token_access/components/remove_autopopulated_entries_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..47dd1d96a116565d2b2b359eda05d6cf859f59b3 --- /dev/null +++ b/app/assets/javascripts/token_access/components/remove_autopopulated_entries_modal.vue @@ -0,0 +1,72 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'RemoveAutopopulatedEntriesModal', + components: { + GlModal, + }, + inject: ['fullPath'], + props: { + showModal: { + type: Boolean, + required: true, + }, + }, + apollo: {}, + computed: { + modalOptions() { + return { + actionPrimary: { + text: __('Remove entries'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }; + }, + }, + methods: { + hideModal() { + this.$emit('hide'); + }, + removeEntries() { + this.$emit('remove-entries'); + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="remove-autopopulated-allowlist-entries-modal" + :visible="showModal" + :title="s__('CICD|Remove all auto-added allowlist entries')" + :action-primary="modalOptions.actionPrimary" + :action-secondary="modalOptions.actionSecondary" + @primary.prevent="removeEntries" + @secondary="hideModal" + @canceled="hideModal" + @hidden="hideModal" + > + <p> + {{ + s__( + 'CICD|This action removes all groups and projects that were auto-added from the authentication log.', + ) + }} + </p> + <p> + {{ + s__('CICD|Removing these entries could cause authentication failures or disrupt pipelines.') + }} + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/token_access/components/token_access_table.vue b/app/assets/javascripts/token_access/components/token_access_table.vue index d145aaac1430ef696ef794324894d6e0d618bba4..5be7dd67640a7db148e76523d0824c1aa23b87c4 100644 --- a/app/assets/javascripts/token_access/components/token_access_table.vue +++ b/app/assets/javascripts/token_access/components/token_access_table.vue @@ -40,6 +40,11 @@ export default { required: false, default: false, }, + loadingMessage: { + type: String, + required: false, + default: '', + }, // This can be removed after outbound_token_access.vue is removed, which is a deprecated feature. We need to hide // policies for that component, but show them on inbound_token_access.vue. showPolicies: { @@ -95,6 +100,13 @@ export default { <gl-table :items="items" :fields="fields" :busy="loading" class="gl-mb-0" stacked="md"> <template #table-busy> <gl-loading-icon size="md" /> + <p + v-if="loadingMessage.length > 0" + class="gl-mt-5 gl-text-center" + data-testid="loading-message" + > + {{ loadingMessage }} + </p> </template> <template #cell(fullPath)="{ item }"> <div class="gl-inline-flex gl-items-center"> 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 e687feddbc57a62405e62f56d021b5753719f587..ea0fae5641712d4ddfad2084e1e99984f3f86b99 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11240,6 +11240,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 "" @@ -11255,6 +11258,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 "" @@ -11354,9 +11360,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 "" @@ -11387,6 +11402,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 "" @@ -47964,6 +47982,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 95ed442b1e1a53936702854f32c1d628d1b46806..b3c9aabcc8c052d1da23c18b7df5d45cc3ea9483 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...'); }); });