Commit 9c80c1a6 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch 'jivanvl-create-token-access-section-project-ci-cd-settings' into 'master'

Add CI/CD access token section in project settings

See merge request gitlab-org/gitlab!61935
parents 671c8214 d85c68fa
......@@ -8,6 +8,7 @@ import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deploy
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
......@@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => {
initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
initTokenAccess();
});
<script>
import { GlButton, GlCard, GlFormGroup, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.query.graphql';
import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql';
import TokenProjectsTable from './token_projects_table.vue';
export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
toggleHelpText: s__(
`CICD|Manage which projects can use this project's CI_JOB_TOKEN CI/CD variable for API access`,
),
cardHeaderTitle: s__('CICD|Add an existing project to the scope'),
formGroupLabel: __('Search for project'),
addProject: __('Add project'),
cancel: __('Cancel'),
addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
},
components: {
GlButton,
GlCard,
GlFormGroup,
GlFormInput,
GlLoadingIcon,
GlToggle,
TokenProjectsTable,
},
inject: {
fullPath: {
default: '',
},
},
apollo: {
jobTokenScopeEnabled: {
query: getCIJobTokenScopeQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
return data.project.ciCdSettings.jobTokenScopeEnabled;
},
error() {
createFlash({ message: this.$options.i18n.scopeFetchError });
},
},
projects: {
query: getProjectsWithCIJobTokenScopeQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
return data.project?.ciJobTokenScope?.projects?.nodes ?? [];
},
error() {
createFlash({ message: this.$options.i18n.projectsFetchError });
},
},
},
data() {
return {
jobTokenScopeEnabled: null,
targetProjectPath: '',
projects: [],
};
},
computed: {
isProjectPathEmpty() {
return this.targetProjectPath === '';
},
},
methods: {
async updateCIJobTokenScope() {
try {
const {
data: {
ciCdSettingsUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: updateCIJobTokenScopeMutation,
variables: {
input: {
fullPath: this.fullPath,
jobTokenScopeEnabled: this.jobTokenScopeEnabled,
},
},
});
if (errors.length) {
throw new Error(errors[0]);
}
} catch (error) {
createFlash({ message: error });
}
},
async addProject() {
try {
const {
data: {
ciJobTokenScopeAddProject: { errors },
},
} = await this.$apollo.mutate({
mutation: addProjectCIJobTokenScopeMutation,
variables: {
input: {
projectPath: this.fullPath,
targetProjectPath: this.targetProjectPath,
},
},
});
if (errors.length) {
throw new Error(errors[0]);
}
} catch (error) {
createFlash({ message: error });
} finally {
this.clearTargetProjectPath();
this.getProjects();
}
},
async removeProject(removeTargetPath) {
try {
const {
data: {
ciJobTokenScopeRemoveProject: { errors },
},
} = await this.$apollo.mutate({
mutation: removeProjectCIJobTokenScopeMutation,
variables: {
input: {
projectPath: this.fullPath,
targetProjectPath: removeTargetPath,
},
},
});
if (errors.length) {
throw new Error(errors[0]);
}
} catch (error) {
createFlash({ message: error });
} finally {
this.getProjects();
}
},
clearTargetProjectPath() {
this.targetProjectPath = '';
},
getProjects() {
this.$apollo.queries.projects.refetch();
},
},
};
</script>
<template>
<div>
<gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
<template v-else>
<gl-toggle
v-model="jobTokenScopeEnabled"
:label="$options.i18n.toggleLabelTitle"
:help="$options.i18n.toggleHelpText"
@change="updateCIJobTokenScope"
/>
<div v-if="jobTokenScopeEnabled" data-testid="token-section">
<gl-card class="gl-mt-5">
<template #header>
<h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
</template>
<template #default>
<gl-form-group :label="$options.i18n.formGroupLabel" label-for="token-project-search">
<gl-form-input
id="token-project-search"
v-model="targetProjectPath"
:placeholder="$options.i18n.addProjectPlaceholder"
/>
</gl-form-group>
</template>
<template #footer>
<gl-button
variant="confirm"
:disabled="isProjectPathEmpty"
data-testid="add-project-button"
@click="addProject"
>
{{ $options.i18n.addProject }}
</gl-button>
<gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
</template>
</gl-card>
<token-projects-table :projects="projects" @removeProject="removeProject" />
</div>
</template>
</div>
</template>
<script>
import { GlButton, GlTable } from '@gitlab/ui';
import { __, s__ } from '~/locale';
const defaultTableClasses = {
thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
};
export default {
i18n: {
emptyText: s__('CI/CD|No projects have been added to the scope'),
},
fields: [
{
key: 'project',
label: __('Projects with access'),
tdClass: 'gl-p-5!',
...defaultTableClasses,
columnClass: 'gl-w-85p',
},
{
key: 'actions',
label: '',
tdClass: 'gl-p-5! gl-text-right',
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
],
components: {
GlButton,
GlTable,
},
inject: {
fullPath: {
default: '',
},
},
props: {
projects: {
type: Array,
required: true,
},
},
methods: {
removeProject(project) {
this.$emit('removeProject', project);
},
},
};
</script>
<template>
<gl-table
:items="projects"
:fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }"
:empty-text="$options.i18n.emptyText"
show-empty
stacked="sm"
fixed
>
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
<template #cell(project)="{ item }">
{{ item.name }}
</template>
<template #cell(actions)="{ item }">
<gl-button
v-if="item.fullPath !== fullPath"
category="primary"
variant="danger"
icon="remove"
:aria-label="__('Remove access')"
data-testid="remove-project-button"
@click="removeProject(item.fullPath)"
/>
</template>
</gl-table>
</template>
mutation addProjectCIJobTokenScope($input: CiJobTokenScopeAddProjectInput!) {
ciJobTokenScopeAddProject(input: $input) {
errors
}
}
mutation removeProjectCIJobTokenScope($input: CiJobTokenScopeRemoveProjectInput!) {
ciJobTokenScopeRemoveProject(input: $input) {
errors
}
}
mutation updateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
ciCdSettingsUpdate(input: $input) {
ciCdSettings {
jobTokenScopeEnabled
}
errors
}
}
query getCIJobTokenScope($fullPath: ID!) {
project(fullPath: $fullPath) {
ciCdSettings {
jobTokenScopeEnabled
}
}
}
query getProjectsWithCIJobTokenScope($fullPath: ID!) {
project(fullPath: $fullPath) {
ciJobTokenScope {
projects {
nodes {
name
fullPath
}
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import TokenAccess from './components/token_access.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
return false;
}
const { fullPath } = containerEl.dataset;
return new Vue({
el: containerEl,
apolloProvider,
provide: {
fullPath,
},
render(createElement) {
return createElement(TokenAccess);
},
});
};
......@@ -12,6 +12,7 @@ module Projects
before_action :define_variables
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
push_frontend_feature_flag(:ci_scoped_job_token, @project, default_enabled: :yaml)
end
helper_method :highlight_badge
......
#js-ci-token-access-app{ data: { full_path: @project.full_path } }
......@@ -95,3 +95,16 @@
.settings-content
= render 'ci/deploy_freeze/index'
- if Feature.enabled?(:ci_scoped_job_token, @project, default_enabled: :yaml)
%section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Token Access")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _("Control which projects can use the CI_JOB_TOKEN CI/CD variable for API access to this project. It is a security risk to disable this feature, because unauthorized projects may attempt to retrieve an active token and access the API.")
= link_to _('Learn more'), help_page_path('api/index', anchor: 'gitlab-cicd-job-token-scope'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'ci/token_access/index'
......@@ -245,6 +245,68 @@ your [runners](../ci/runners/README.md) to be secure. Avoid:
If you have an insecure GitLab Runner configuration, you increase the risk that someone
tries to steal tokens from other jobs.
#### GitLab CI/CD job token scope
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/328553) in GitLab 14.1.
- [Deployed behind a feature flag](../user/feature_flags.md), disabled by default.
- Disabled on GitLab.com.
- Not recommended for production use.
- To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-ci-job-token-scope). **(FREE SELF)**
This in-development feature might not be available for your use. There can be
[risks when enabling features still in development](../user/feature_flags.md#risks-when-enabling-features-still-in-development).
Refer to this feature's version history for more details.
CI job token can access only projects that are defined in its scope.
You can configure the scope via project settings.
The CI job token scope consists in a allowlist of projects that are authorized by maintainers to be
accessible via a CI job token. By default a scope only contains the same project where the token
comes from. Other projects can be added and removed by maintainers.
You can configure the scope via project settings.
Since GitLab 14.1 this setting is enabled by default for new projects. Existing projects are
recommended to enable this feature and configure which projects are authorized to be accessed
by a job token.
The CI job token scope limits the risks that a leaked token is used to access private data that
the user associated to the job can access to.
When the job token scope feature is enabled in the project settings, only the projects in scope
will be allowed to be accessed by a job token. If the job token scope feature is disabled, any
projects can be accessed, as long as the user associated to the job has permissions.
For example. If a project `A` has a running job with a `CI_JOB_TOKEN`, its scope is defined by
project `A`. If the job wants to use the `CI_JOB_TOKEN` to access data from project `B` or
trigger some actions in that project, then project `B` must be in the job token scope for `A`.
A job token might give extra permissions that aren't necessary to access specific resources.
There is [a proposal](https://gitlab.com/groups/gitlab-org/-/epics/3559) to redesign the feature
for more strategic control of the access permissions.
<!-- Add this at the end of the file -->
#### Enable or disable CI Job Token Scope **(FREE SELF)**
This is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:ci_scoped_job_token)
```
To disable it:
```ruby
Feature.disable(:ci_scoped_job_token)
```
### Impersonation tokens
Impersonation tokens are a type of [personal access token](../user/profile/personal_access_tokens.md).
......
......@@ -2050,6 +2050,9 @@ msgstr ""
msgid "Add previously merged commits"
msgstr ""
msgid "Add project"
msgstr ""
msgid "Add projects"
msgstr ""
......@@ -5762,6 +5765,9 @@ msgstr ""
msgid "CI/CD configuration file"
msgstr ""
msgid "CI/CD|No projects have been added to the scope"
msgstr ""
msgid "CICDAnalytics|%{percent}%{percentSymbol}"
msgstr ""
......@@ -5797,6 +5803,9 @@ msgstr ""
msgid "CICD|Add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} with a domain, or create an AUTO_DEVOPS_PLATFORM_TARGET CI variable."
msgstr ""
msgid "CICD|Add an existing project to the scope"
msgstr ""
msgid "CICD|Auto DevOps"
msgstr ""
......@@ -5821,6 +5830,12 @@ msgstr ""
msgid "CICD|Jobs"
msgstr ""
msgid "CICD|Limit CI_JOB_TOKEN access"
msgstr ""
msgid "CICD|Manage which projects can use this project's CI_JOB_TOKEN CI/CD variable for API access"
msgstr ""
msgid "CICD|The Auto DevOps pipeline runs by default in all projects with no CI/CD configuration file."
msgstr ""
......@@ -8784,6 +8799,9 @@ msgstr ""
msgid "Control whether to display third-party offers in GitLab."
msgstr ""
msgid "Control which projects can use the CI_JOB_TOKEN CI/CD variable for API access to this project. It is a security risk to disable this feature, because unauthorized projects may attempt to retrieve an active token and access the API."
msgstr ""
msgid "Cookie domain"
msgstr ""
......@@ -23624,6 +23642,9 @@ msgstr ""
msgid "Paste issue link"
msgstr ""
msgid "Paste project path (i.e. gitlab-org/gitlab)"
msgstr ""
msgid "Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity."
msgstr ""
......@@ -25970,6 +25991,9 @@ msgstr ""
msgid "Projects will be permanently deleted immediately."
msgstr ""
msgid "Projects with access"
msgstr ""
msgid "Projects with critical vulnerabilities"
msgstr ""
......@@ -27048,6 +27072,9 @@ msgstr ""
msgid "Remove Zoom meeting"
msgstr ""
msgid "Remove access"
msgstr ""
msgid "Remove all approvals in a merge request when new commits are pushed to its source branch."
msgstr ""
......@@ -28504,6 +28531,9 @@ msgstr ""
msgid "Search for a user"
msgstr ""
msgid "Search for project"
msgstr ""
msgid "Search for projects, issues, etc."
msgstr ""
......@@ -32820,9 +32850,15 @@ msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
msgid "There was a problem fetching the job token scope value"
msgstr ""
msgid "There was a problem fetching the keep latest artifacts setting."
msgstr ""
msgid "There was a problem fetching the projects"
msgstr ""
msgid "There was a problem fetching users."
msgstr ""
......@@ -34186,6 +34222,9 @@ msgstr ""
msgid "Token"
msgstr ""
msgid "Token Access"
msgstr ""
msgid "Token name"
msgstr ""
......
export const enabledJobTokenScope = {
data: {
project: {
ciCdSettings: {
jobTokenScopeEnabled: true,
__typename: 'ProjectCiCdSetting',
},
__typename: 'Project',
},
},
};
export const disabledJobTokenScope = {
data: {
project: {
ciCdSettings: {
jobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
},
__typename: 'Project',
},
},
};
export const updateJobTokenScope = {
data: {
ciCdSettingsUpdate: {
ciCdSettings: {
jobTokenScopeEnabled: true,
__typename: 'ProjectCiCdSetting',
},
errors: [],
__typename: 'CiCdSettingsUpdatePayload',
},
},
};
export const projectsWithScope = {
data: {
project: {
__typename: 'Project',
ciJobTokenScope: {
__typename: 'CiJobTokenScopeType',
projects: {
__typename: 'ProjectConnection',
nodes: [
{
fullPath: 'root/332268-test',
name: 'root/332268-test',
},
],
},
},
},
},
};
export const addProjectSuccess = {
data: {
ciJobTokenScopeAddProject: {
errors: [],
__typename: 'CiJobTokenScopeAddProjectPayload',
},
},
};
export const removeProjectSuccess = {
data: {
ciJobTokenScopeRemoveProject: {
errors: [],
__typename: 'CiJobTokenScopeRemoveProjectPayload',
},
},
};
export const mockProjects = [
{
name: 'merge-train-stuff',
fullPath: 'root/merge-train-stuff',
isLocked: false,
__typename: 'Project',
},
{ name: 'ci-project', fullPath: 'root/ci-project', isLocked: true, __typename: 'Project' },
];
import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import TokenAccess from '~/token_access/components/token_access.vue';
import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql';
import getCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_ci_job_token_scope.query.graphql';
import getProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql';
import {
enabledJobTokenScope,
disabledJobTokenScope,
updateJobTokenScope,
projectsWithScope,
addProjectSuccess,
removeProjectSuccess,
} from './mock_data';
const projectPath = 'root/my-repo';
const error = new Error('Error');
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.mock('~/flash');
describe('TokenAccess component', () => {
let wrapper;
const enabledJobTokenScopeHandler = jest.fn().mockResolvedValue(enabledJobTokenScope);
const disabledJobTokenScopeHandler = jest.fn().mockResolvedValue(disabledJobTokenScope);
const updateJobTokenScopeHandler = jest.fn().mockResolvedValue(updateJobTokenScope);
const getProjectsWithScope = jest.fn().mockResolvedValue(projectsWithScope);
const addProjectSuccessHandler = jest.fn().mockResolvedValue(addProjectSuccess);
const addProjectFailureHandler = jest.fn().mockRejectedValue(error);
const removeProjectSuccessHandler = jest.fn().mockResolvedValue(removeProjectSuccess);
const removeProjectFailureHandler = jest.fn().mockRejectedValue(error);
const findToggle = () => wrapper.findComponent(GlToggle);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAddProjectBtn = () => wrapper.find('[data-testid="add-project-button"]');
const findRemoveProjectBtn = () => wrapper.find('[data-testid="remove-project-button"]');
const findTokenSection = () => wrapper.find('[data-testid="token-section"]');
const createMockApolloProvider = (requestHandlers) => {
return createMockApollo(requestHandlers);
};
const createComponent = (requestHandlers, mountFn = shallowMount) => {
wrapper = mountFn(TokenAccess, {
localVue,
provide: {
fullPath: projectPath,
},
apolloProvider: createMockApolloProvider(requestHandlers),
data() {
return {
targetProjectPath: 'root/test',
};
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('loading state', () => {
it('shows loading state while waiting on query to resolve', async () => {
createComponent([
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
]);
expect(findLoadingIcon().exists()).toBe(true);
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('toggle', () => {
it('the toggle should be enabled and the token section should show', async () => {
createComponent([
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
]);
await waitForPromises();
expect(findToggle().props('value')).toBe(true);
expect(findTokenSection().exists()).toBe(true);
});
it('the toggle should be disabled and the token section should not show', async () => {
createComponent([
[getCIJobTokenScopeQuery, disabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
]);
await waitForPromises();
expect(findToggle().props('value')).toBe(false);
expect(findTokenSection().exists()).toBe(false);
});
it('switching the toggle calls the mutation', async () => {
createComponent([
[getCIJobTokenScopeQuery, disabledJobTokenScopeHandler],
[updateCIJobTokenScopeMutation, updateJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
]);
await waitForPromises();
findToggle().vm.$emit('change', true);
expect(updateJobTokenScopeHandler).toHaveBeenCalledWith({
input: { fullPath: projectPath, jobTokenScopeEnabled: true },
});
});
});
describe('add project', () => {
it('calls add project mutation', async () => {
createComponent(
[
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[addProjectCIJobTokenScopeMutation, addProjectSuccessHandler],
],
mount,
);
await waitForPromises();
findAddProjectBtn().trigger('click');
expect(addProjectSuccessHandler).toHaveBeenCalledWith({
input: {
projectPath,
targetProjectPath: 'root/test',
},
});
});
it('add project handles error correctly', async () => {
createComponent(
[
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[addProjectCIJobTokenScopeMutation, addProjectFailureHandler],
],
mount,
);
await waitForPromises();
findAddProjectBtn().trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
});
describe('remove project', () => {
it('calls remove project mutation', async () => {
createComponent(
[
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[removeProjectCIJobTokenScopeMutation, removeProjectSuccessHandler],
],
mount,
);
await waitForPromises();
findRemoveProjectBtn().trigger('click');
expect(removeProjectSuccessHandler).toHaveBeenCalledWith({
input: {
projectPath,
targetProjectPath: 'root/332268-test',
},
});
});
it('remove project handles error correctly', async () => {
createComponent(
[
[getCIJobTokenScopeQuery, enabledJobTokenScopeHandler],
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[removeProjectCIJobTokenScopeMutation, removeProjectFailureHandler],
],
mount,
);
await waitForPromises();
findRemoveProjectBtn().trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
});
});
import { GlTable, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TokenProjectsTable from '~/token_access/components/token_projects_table.vue';
import { mockProjects } from './mock_data';
describe('Token projects table', () => {
let wrapper;
const createComponent = () => {
wrapper = mount(TokenProjectsTable, {
provide: {
fullPath: 'root/ci-project',
},
propsData: {
projects: mockProjects,
},
});
};
const findTable = () => wrapper.findComponent(GlTable);
const findAllTableRows = () => wrapper.findAll('[data-testid="projects-token-table-row"]');
const findDeleteProjectBtn = () => wrapper.findComponent(GlButton);
const findAllDeleteProjectBtn = () => wrapper.findAllComponents(GlButton);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('displays a table', () => {
expect(findTable().exists()).toBe(true);
});
it('displays the correct amount of table rows', () => {
expect(findAllTableRows()).toHaveLength(mockProjects.length);
});
it('delete project button emits event with correct project to delete', async () => {
await findDeleteProjectBtn().trigger('click');
expect(wrapper.emitted('removeProject')).toEqual([[mockProjects[0].fullPath]]);
});
it('does not show the remove icon if the project is locked', () => {
// currently two mock projects with one being a locked project
expect(findAllDeleteProjectBtn()).toHaveLength(1);
});
});
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