diff --git a/app/assets/javascripts/ci/job_details/components/empty_state.vue b/app/assets/javascripts/ci/job_details/components/empty_state.vue index b536465288e5c02667669910cfe30dc686943be3..89c687c1be5ac3ac3e17e4a497359d91e5e2adc3 100644 --- a/app/assets/javascripts/ci/job_details/components/empty_state.vue +++ b/app/assets/javascripts/ci/job_details/components/empty_state.vue @@ -1,12 +1,15 @@ <script> import { GlButton } from '@gitlab/ui'; -import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; +import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue'; +import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin'; export default { components: { GlButton, - ManualVariablesForm, + ManualJobForm, }, + mixins: [PipelineVariablesPermissionsMixin], + inject: ['projectPath', 'userRole'], props: { illustrationPath: { type: String, @@ -70,14 +73,20 @@ export default { shouldRenderManualVariables() { return this.playable && !this.scheduled; }, + shouldRenderPipelineVariablesText() { + return this.canViewPipelineVariables && this.shouldRenderManualVariables && !this.isRetryable; + }, }, }; </script> <template> <div class="gl-empty-state gl-flex gl-flex-col gl-text-center"> <div :class="illustrationSizeClass" class="gl-max-w-full"> - <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> - <img alt="" class="gl-max-w-full" :src="illustrationPath" /> + <img + :alt="s__('CiVariables|Manual job empty state image')" + class="gl-max-w-full" + :src="illustrationPath" + /> </div> <div class="gl-empty-state-content gl-m-auto gl-mx-auto gl-my-0 gl-p-5"> <h2 @@ -88,13 +97,19 @@ export default { </h2> <p v-if="content" class="gl-mb-0 gl-mt-4" data-testid="job-empty-state-content"> {{ content }} + <template v-if="shouldRenderPipelineVariablesText">{{ + s__( + 'CiVariables|You can add CI/CD variables below for last-minute configuration changes before starting the job.', + ) + }}</template> </p> - <manual-variables-form + <manual-job-form v-if="shouldRenderManualVariables" :is-retryable="isRetryable" :job-id="jobId" :job-name="jobName" :confirmation-message="confirmationMessage" + :can-view-pipeline-variables="canViewPipelineVariables" @hideManualVariablesForm="$emit('hideManualVariablesForm')" /> <div diff --git a/app/assets/javascripts/ci/job_details/components/job_variables_form.vue b/app/assets/javascripts/ci/job_details/components/job_variables_form.vue index d728f4fa3b6f506d35e3c841f37d4e64b9ed052f..57ac253303c348bd04651d779baf5f2a786cdb9f 100644 --- a/app/assets/javascripts/ci/job_details/components/job_variables_form.vue +++ b/app/assets/javascripts/ci/job_details/components/job_variables_form.vue @@ -170,9 +170,9 @@ export default { data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> - <!-- Placeholder button to keep the layout fixed --> <gl-button v-else + aria-hidden="true" class="gl-pointer-events-none gl-opacity-0" :class="$options.clearBtnSharedClasses" data-testid="delete-variable-btn-placeholder" diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_job_form.vue similarity index 94% rename from app/assets/javascripts/ci/job_details/components/manual_variables_form.vue rename to app/assets/javascripts/ci/job_details/components/manual_job_form.vue index 5734882c44fe1b09a33cfbe58d8d03331063969a..2b42fd5e5e95e9750aef6986e34ec1866921424b 100644 --- a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue +++ b/app/assets/javascripts/ci/job_details/components/manual_job_form.vue @@ -16,7 +16,7 @@ import JobVariablesForm from './job_variables_form.vue'; // It is meant to fetch/update the job information via GraphQL instead of REST API. export default { - name: 'ManualVariablesForm', + name: 'ManualJobForm', components: { GlButton, JobVariablesForm, @@ -39,6 +39,11 @@ export default { required: false, default: null, }, + canViewPipelineVariables: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -130,7 +135,11 @@ export default { </script> <template> <div> - <job-variables-form :job-id="jobId" @update-variables="onVariablesUpdate" /> + <job-variables-form + v-if="canViewPipelineVariables" + :job-id="jobId" + @update-variables="onVariablesUpdate" + /> <div class="gl-mt-5 gl-flex gl-justify-center gl-gap-x-2"> <gl-button diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue index db264a499ab95b92c45b9fa10ee38080f828c6b4..03a23ab621e234ee2da9c3460c9203016e9583dc 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue @@ -7,6 +7,7 @@ import { s__, __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { confirmJobConfirmationMessage } from '~/ci/pipeline_details/graph/utils'; +import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin'; export default { name: 'JobSidebarRetryButton', @@ -20,6 +21,8 @@ export default { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, }, + mixins: [PipelineVariablesPermissionsMixin], + inject: ['projectPath', 'userRole'], props: { modalId: { type: String, @@ -93,6 +96,7 @@ export default { /> <div v-else-if="isManualJob" class="gl-flex gl-gap-3"> <gl-button + v-if="canViewPipelineVariables" v-gl-tooltip.bottom :title="$options.i18n.updateVariables" :aria-label="$options.i18n.updateVariables" diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js index 7e14572b71740a05cba1d896de2658973513826d..ba35365bee85c319665be5159af05dbc31932605 100644 --- a/app/assets/javascripts/ci/job_details/index.js +++ b/app/assets/javascripts/ci/job_details/index.js @@ -34,6 +34,7 @@ export const initJobDetails = () => { pipelineTestReportUrl, logViewerPath, duoFeaturesEnabled, + userRole, } = el.dataset; const fullScreenAPIAvailable = document.fullscreenEnabled; @@ -58,6 +59,7 @@ export const initJobDetails = () => { aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), duoFeaturesEnabled: parseBoolean(duoFeaturesEnabled), pipelineTestReportUrl, + userRole: userRole?.toLowerCase(), }, render(h) { return h(JobApp, { diff --git a/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js b/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js new file mode 100644 index 0000000000000000000000000000000000000000..965e4fe42c67bc2f1c73b09b2cb7777c2a44a7a9 --- /dev/null +++ b/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js @@ -0,0 +1,62 @@ +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql'; + +const ROLE_NO_ONE = 'no_one_allowed'; +const ROLE_DEVELOPER = 'developer'; +const ROLE_MAINTAINER = 'maintainer'; +const ROLE_OWNER = 'owner'; + +export default { + USER_ROLES: Object.freeze([ROLE_DEVELOPER, ROLE_MAINTAINER, ROLE_OWNER]), + + inject: ['projectPath', 'userRole'], + + data() { + return { + hasError: false, + pipelineVariablesSettings: {}, + }; + }, + + apollo: { + pipelineVariablesSettings: { + query: getPipelineVariablesMinimumOverrideRoleQuery, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update({ project }) { + return project?.ciCdSettings || {}; + }, + error() { + this.hasError = true; + createAlert({ + message: s__('CiVariables|There was a problem fetching the CI/CD settings.'), + }); + }, + }, + }, + + computed: { + pipelineVariablesPermissionsLoading() { + return this.$apollo.queries.pipelineVariablesSettings.loading; + }, + minimumRole() { + return this.pipelineVariablesSettings?.pipelineVariablesMinimumOverrideRole; + }, + canViewPipelineVariables() { + if (this.pipelineVariablesPermissionsLoading) return false; + + if (this.minimumRole === ROLE_NO_ONE || this.hasError) { + return false; + } + + const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole); + const minRoleIndex = this.$options.USER_ROLES.indexOf(this.minimumRole); + + return userRoleIndex >= minRoleIndex || false; + }, + }, +}; diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 6c755f8fe96a5ffc90aa760ac1fe5457d8afbadc..2080d5264f6a4deb3616336b49a821420e01f5f3 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -14,7 +14,8 @@ def jobs_data(project, build) "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'), "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings.md', anchor: 'prevent-outdated-deployment-jobs'), "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline), - "log_viewer_path" => viewer_project_job_path(project, build) + "log_viewer_path" => viewer_project_job_path(project, build), + "user_role" => project.team.human_max_access(current_user&.id) } end diff --git a/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb b/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb index ca41bc1deeb504971c57b4b0e38e023f1667d576..b91603053cfc003cf132d11af950036bc8870d65 100644 --- a/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb @@ -44,10 +44,7 @@ it 'instructs the user about possible actions' do expect(illustration[:content]).to eq( - _( - 'This job does not start automatically and must be started manually. ' \ - 'You can add CI/CD variables below for last-minute configuration changes before starting the job.' - ) + _('This job does not start automatically and must be started manually.') ) end end diff --git a/lib/gitlab/ci/status/build/manual.rb b/lib/gitlab/ci/status/build/manual.rb index 0e16084d49173ff0441c4e0d508d7535e3fd15b0..16782c94e3435766157587ffb4d55f533bd58d4b 100644 --- a/lib/gitlab/ci/status/build/manual.rb +++ b/lib/gitlab/ci/status/build/manual.rb @@ -32,7 +32,7 @@ def manual_job_action_message if subject.retryable? _("You can modify this job's CI/CD variables before running it again.") else - _('This job does not start automatically and must be started manually. You can add CI/CD variables below for last-minute configuration changes before starting the job.') + _('This job does not start automatically and must be started manually.') end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 73e85a0792bda57c0c80a818233879a12a9b612c..afde06fb801057795db240b75bde9e11cc4fa998 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -12486,6 +12486,9 @@ msgstr "" msgid "CiVariables|Learn how to %{linkStart}restrict CI/CD variables to specific environments%{linkEnd} for better security." msgstr "" +msgid "CiVariables|Manual job empty state image" +msgstr "" + msgid "CiVariables|Masked" msgstr "" @@ -12576,6 +12579,9 @@ msgstr "" msgid "CiVariables|The value must have %{charsAmount} characters." msgstr "" +msgid "CiVariables|There was a problem fetching the CI/CD settings." +msgstr "" + msgid "CiVariables|There was a problem fetching the pipeline variables default role." msgstr "" @@ -12627,6 +12633,9 @@ msgstr "" msgid "CiVariables|What are pipeline variables?" msgstr "" +msgid "CiVariables|You can add CI/CD variables below for last-minute configuration changes before starting the job." +msgstr "" + msgid "CiVariables|You can use CI/CD variables with the same name in different places, but the variables might overwrite each other. %{linkStart}What is the order of precedence for variables?%{linkEnd}" msgstr "" @@ -59458,7 +59467,7 @@ msgstr "" msgid "This job does not run automatically and must be started manually, but you do not have access to it." msgstr "" -msgid "This job does not start automatically and must be started manually. You can add CI/CD variables below for last-minute configuration changes before starting the job." +msgid "This job does not start automatically and must be started manually." msgstr "" msgid "This job has been canceled" diff --git a/spec/frontend/ci/job_details/components/empty_state_spec.js b/spec/frontend/ci/job_details/components/empty_state_spec.js index 0c2e07526f1e703422b8959435788d9c6b7d17ab..2cb724095c05e6fe156ef760456f9983d78ea3db 100644 --- a/spec/frontend/ci/job_details/components/empty_state_spec.js +++ b/spec/frontend/ci/job_details/components/empty_state_spec.js @@ -1,7 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EmptyState from '~/ci/job_details/components/empty_state.vue'; -import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; -import { mockFullPath, mockId } from '../mock_data'; +import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue'; +import { mockFullPath, mockId, mockPipelineVariablesPermissions } from '../mock_data'; describe('Empty State', () => { let wrapper; @@ -10,20 +10,30 @@ describe('Empty State', () => { illustrationPath: 'illustrations/empty-state/empty-job-pending-md.svg', illustrationSizeClass: '', jobId: mockId, + jobName: 'My job', title: 'This job has not started yet', playable: false, isRetryable: true, }; - const createWrapper = (props) => { + const defaultProvide = { + projectPath: mockFullPath, + userRole: 'maintainer', + }; + + const createWrapper = ({ + props, + pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true), + } = {}) => { wrapper = shallowMountExtended(EmptyState, { propsData: { ...defaultProps, ...props, }, provide: { - projectPath: mockFullPath, + ...defaultProvide, }, + mixins: [pipelineVariablesPermissionsMixin], }); }; @@ -33,7 +43,7 @@ describe('Empty State', () => { const findTitle = () => wrapper.findByTestId('job-empty-state-title'); const findContent = () => wrapper.findByTestId('job-empty-state-content'); const findAction = () => wrapper.findByTestId('job-empty-state-action'); - const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm); + const findManualVarsForm = () => wrapper.findComponent(ManualJobForm); describe('renders image and title', () => { beforeEach(() => { @@ -44,6 +54,10 @@ describe('Empty State', () => { expect(findEmptyStateImage().exists()).toBe(true); }); + it('renders alt text', () => { + expect(findEmptyStateImage().attributes('alt')).toBe('Manual job empty state image'); + }); + it('renders provided title', () => { expect(findTitle().text().trim()).toBe(defaultProps.title); }); @@ -51,7 +65,7 @@ describe('Empty State', () => { describe('with content', () => { beforeEach(() => { - createWrapper({ content }); + createWrapper({ props: { content } }); }); it('renders content', () => { @@ -72,10 +86,12 @@ describe('Empty State', () => { describe('with action', () => { beforeEach(() => { createWrapper({ - action: { - path: 'runner', - button_title: 'Check runner', - method: 'post', + props: { + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, }, }); }); @@ -88,7 +104,9 @@ describe('Empty State', () => { describe('without action', () => { beforeEach(() => { createWrapper({ - action: null, + props: { + action: null, + }, }); }); @@ -104,13 +122,15 @@ describe('Empty State', () => { describe('with playable action and not scheduled job', () => { beforeEach(() => { createWrapper({ - content, - playable: true, - scheduled: false, - action: { - path: 'runner', - button_title: 'Check runner', - method: 'post', + props: { + content, + playable: true, + scheduled: false, + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, }, }); }); @@ -127,14 +147,71 @@ describe('Empty State', () => { describe('with playable action and scheduled job', () => { beforeEach(() => { createWrapper({ - playable: true, - scheduled: true, - content, + props: { + playable: true, + scheduled: true, + content, + }, + }); + }); + + it('does not render manual variables form', () => { + expect(findManualVarsForm().exists()).toBe(false); + }); + }); + + describe('when user is allowed to see the pipeline variables', () => { + beforeEach(() => { + createWrapper({ + props: { content, isRetryable: false, playable: true, scheduled: false }, + }); + }); + + it('provides `canViewPipelineVariables` as `true` to manual variables form', () => { + expect(findManualVarsForm().props('canViewPipelineVariables')).toBe(true); + }); + + it('renders additional text for pipeline variables when it is not a retryable job', () => { + expect(findContent().text()).toContain( + 'You can add CI/CD variables below for last-minute configuration changes before starting the job.', + ); + }); + }); + + describe('when user is not allowed to see the pipeline variables', () => { + beforeEach(() => { + createWrapper({ + props: { content, isRetryable: false, playable: true, scheduled: false }, + pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false), + }); + }); + + it('provides `canViewPipelineVariables` as `false` to manual variables form', () => { + expect(findManualVarsForm().props('canViewPipelineVariables')).toBe(false); + }); + + it('does not render additional text for pipeline variables when it is not a retryable job', () => { + expect(findContent().text()).not.toContain( + 'You can add CI/CD variables below for last-minute configuration changes before starting the job.', + ); + }); + }); + + describe('when user is not allowed to retry the pipeline', () => { + beforeEach(() => { + createWrapper({ + props: { content, isRetryable: false }, }); }); it('does not render manual variables form', () => { expect(findManualVarsForm().exists()).toBe(false); }); + + it('does not render additional text for pipeline variables when it is not a retryable job', () => { + expect(findContent().text()).not.toContain( + 'You can add CI/CD variables below for last-minute configuration changes before starting the job.', + ); + }); }); }); diff --git a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js b/spec/frontend/ci/job_details/components/manual_job_form_spec.js similarity index 92% rename from spec/frontend/ci/job_details/components/manual_variables_form_spec.js rename to spec/frontend/ci/job_details/components/manual_job_form_spec.js index 046ebd194a25adfe857da3d22ca419075dcbaee4..84e280a5d14fa3c5ee076087176dcd55a709f139 100644 --- a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js +++ b/spec/frontend/ci/job_details/components/manual_job_form_spec.js @@ -8,7 +8,7 @@ import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import waitForPromises from 'helpers/wait_for_promises'; import { visitUrl } from '~/lib/utils/url_utility'; -import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; +import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue'; import playJobMutation from '~/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql'; import retryJobMutation from '~/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -30,6 +30,13 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), })); +const defaultProps = { + jobId: mockId, + jobName: 'job-name', + isRetryable: false, + canViewPipelineVariables: true, +}; + const defaultProvide = { projectPath: mockFullPath, }; @@ -59,11 +66,9 @@ describe('Manual Variables Form', () => { apolloProvider: mockApollo, }; - wrapper = shallowMountExtended(ManualVariablesForm, { + wrapper = shallowMountExtended(ManualJobForm, { propsData: { - jobId: mockId, - jobName: 'job-name', - isRetryable: false, + ...defaultProps, ...props, }, provide: { @@ -253,4 +258,16 @@ describe('Manual Variables Form', () => { }); }); }); + + describe('when the user is not allowed to see the pipeline variables', () => { + beforeEach(() => { + createComponent({ + props: { canViewPipelineVariables: false }, + }); + }); + + it('does not render job variables form', () => { + expect(findVariablesForm().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js index 0d51695b8fbd4cdf358d8df30df97782e583d992..7d4a282a8bd395184238aa09258054abe2682f65 100644 --- a/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js @@ -6,9 +6,15 @@ import job from 'jest/ci/jobs_mock_data'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockFullPath, mockPipelineVariablesPermissions } from '../../mock_data'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_action'); +const defaultProvide = { + projectPath: mockFullPath, + userRole: 'maintainer', +}; + describe('Job Sidebar Retry Button', () => { let store; let wrapper; @@ -19,9 +25,13 @@ describe('Job Sidebar Retry Button', () => { const findManualRetryButton = () => wrapper.findByTestId('manual-run-again-btn'); const findManualRunEditButton = () => wrapper.findByTestId('manual-run-edit-btn'); - const createWrapper = ({ props = {} } = {}) => { + const createWrapper = ({ + mountFn = shallowMountExtended, + props = {}, + pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true), + } = {}) => { store = createStore(); - wrapper = shallowMountExtended(JobsSidebarRetryButton, { + wrapper = mountFn(JobsSidebarRetryButton, { propsData: { href: job.retry_path, isManualJob: false, @@ -30,13 +40,23 @@ describe('Job Sidebar Retry Button', () => { confirmationMessage: null, ...props, }, + provide: { + ...defaultProvide, + }, + mixins: [pipelineVariablesPermissionsMixin], store, }); }; - beforeEach(() => { - createWrapper(); - }); + const createWrapperWithConfirmation = () => { + createWrapper({ + mountFn: mountExtended, + props: { + isManualJob: true, + confirmationMessage: 'Are you sure?', + }, + }); + }; it.each([ [null, false, true], @@ -45,6 +65,7 @@ describe('Job Sidebar Retry Button', () => { ])( 'when error is: %s, should render button: %s | should render link: %s', async (failureReason, buttonExists, linkExists) => { + createWrapper(); await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason }); expect(findRetryButton().exists()).toBe(buttonExists); @@ -54,6 +75,8 @@ describe('Job Sidebar Retry Button', () => { describe('Button', () => { it('should have the correct configuration', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure }); expect(findRetryButton().attributes()).toMatchObject({ category: 'primary', @@ -65,6 +88,8 @@ describe('Job Sidebar Retry Button', () => { describe('Link', () => { it('should have the correct configuration', () => { + createWrapper(); + expect(findRetryLink().attributes()).toMatchObject({ 'data-method': 'post', href: job.retry_path, @@ -74,20 +99,8 @@ describe('Job Sidebar Retry Button', () => { }); describe('confirmationMessage', () => { - const createWrapperWithConfirmation = () => { - wrapper = mountExtended(JobsSidebarRetryButton, { - propsData: { - href: job.retry_path, - modalId: 'modal-id', - jobName: job.name, - isManualJob: true, - confirmationMessage: 'Are you sure?', - }, - store, - }); - }; - it('should not render confirmation modal if confirmation message is null', () => { + createWrapper(); findRetryLink().trigger('click'); expect(confirmAction).not.toHaveBeenCalled(); }); @@ -125,4 +138,21 @@ describe('Job Sidebar Retry Button', () => { expect(mock.history.post[0].url).toBe(job.retry_path); }); }); + + describe('manual job retry with update variables button', () => { + it('is rendered if user is allowed to view pipeline variables', async () => { + createWrapper({ props: { isManualJob: true } }); + await waitForPromises(); + expect(findManualRunEditButton().exists()).toBe(true); + }); + + it('is not rendered if user is not allowed to view pipeline variables', async () => { + createWrapper({ + props: { isManualJob: true }, + pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false), + }); + await waitForPromises(); + expect(findManualRunEditButton().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js index cce39c51b010aa658611f3cffa8577731a26d09f..584b75792ecc5787b163acae0d44214950fa28bf 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js @@ -23,6 +23,7 @@ describe('Sidebar Header', () => { ...props, jobId: mockId, restJob: { + name: 'My job', status: { action: { confirmation_message: null, diff --git a/spec/frontend/ci/job_details/mock_data.js b/spec/frontend/ci/job_details/mock_data.js index c1527f6f3bcce550dd86266c5d219f8b3cd069ea..3e98940ad59c038102b38aee0f97dc29785d639c 100644 --- a/spec/frontend/ci/job_details/mock_data.js +++ b/spec/frontend/ci/job_details/mock_data.js @@ -122,3 +122,11 @@ export const mockPendingJobData = { }, }, }; + +export const mockPipelineVariablesPermissions = (value) => ({ + computed: { + canViewPipelineVariables() { + return value; + }, + }, +}); diff --git a/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js b/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..50b71c6a87a59ab1e3ebfd6fa1ad35ebe30e4134 --- /dev/null +++ b/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js @@ -0,0 +1,132 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import pipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin'; +import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +const TestComponent = { + mixins: [pipelineVariablesPermissionsMixin], + template: ` + <div> + <div v-if="pipelineVariablesPermissionsLoading" data-testid="loading-state">Loading...</div> + <div v-else-if="canViewPipelineVariables" data-testid="authorized-content">Authorized</div> + <div v-else data-testid="unauthorized-content">Unauthorized</div> + <div v-if="hasError" data-testid="error-state">Error occurred</div> + </div> + `, +}; + +describe('Pipeline Variables Permissions Mixin', () => { + let wrapper; + let minimumRoleHandler; + + const ROLE_NO_ONE = 'no_one_allowed'; + const ROLE_DEVELOPER = 'developer'; + const ROLE_MAINTAINER = 'maintainer'; + const ROLE_OWNER = 'owner'; + + const defaultProvide = { + userRole: ROLE_DEVELOPER, + projectPath: 'project/path', + }; + + const generateSettingsResponse = (minimumRole = ROLE_DEVELOPER) => ({ + data: { + project: { + id: 'gid://gitlab/Project/12', + ciCdSettings: { + pipelineVariablesMinimumOverrideRole: minimumRole, + }, + }, + }, + }); + + const createComponent = async ({ provide = {} } = {}) => { + const handlers = [[getPipelineVariablesMinimumOverrideRoleQuery, minimumRoleHandler]]; + + wrapper = shallowMountExtended(TestComponent, { + apolloProvider: createMockApollo(handlers), + provide: { + ...defaultProvide, + ...provide, + }, + }); + + await waitForPromises(); + }; + + const findLoadingState = () => wrapper.findByTestId('loading-state'); + const findAuthorizedContent = () => wrapper.findByTestId('authorized-content'); + const findUnauthorizedContent = () => wrapper.findByTestId('unauthorized-content'); + const findErrorState = () => wrapper.findByTestId('error-state'); + + describe('on load', () => { + describe('when settings query is successful', () => { + beforeEach(async () => { + minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse()); + await createComponent(); + }); + + it('fetches data from settings query', () => { + expect(minimumRoleHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('when settings query fails', () => { + beforeEach(async () => { + minimumRoleHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + await createComponent(); + }); + + it('calls createAlert with the correct message', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the CI/CD settings.', + }); + }); + + it('shows error state', () => { + expect(findErrorState().exists()).toBe(true); + }); + }); + }); + + describe('during loading state', () => { + it('shows loading state and not content', async () => { + minimumRoleHandler = jest.fn().mockImplementation(() => new Promise(() => {})); + await createComponent(); + + expect(findLoadingState().exists()).toBe(true); + expect(findAuthorizedContent().exists()).toBe(false); + expect(findUnauthorizedContent().exists()).toBe(false); + }); + }); + + describe('permissions calculations based on user roles', () => { + it.each` + scenario | userRole | minimumRole | isAuthorized + ${'user role is lower than minimum role'} | ${ROLE_DEVELOPER} | ${ROLE_MAINTAINER} | ${false} + ${'user role is equal to minimum role'} | ${ROLE_MAINTAINER} | ${ROLE_MAINTAINER} | ${true} + ${'user role is higher than minimum role'} | ${ROLE_OWNER} | ${ROLE_MAINTAINER} | ${true} + ${'minimum role is no_one_allowed'} | ${ROLE_OWNER} | ${ROLE_NO_ONE} | ${false} + `( + 'when $scenario, authorization is $isAuthorized', + async ({ userRole, minimumRole, isAuthorized }) => { + minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse(minimumRole)); + + await createComponent({ + provide: { userRole }, + }); + + expect(findAuthorizedContent().exists()).toBe(isAuthorized); + expect(findUnauthorizedContent().exists()).toBe(!isAuthorized); + }, + ); + }); +}); diff --git a/spec/helpers/ci/jobs_helper_spec.rb b/spec/helpers/ci/jobs_helper_spec.rb index 1373f8767b4d9c1e695afd021056e143d26366bd..d7619ea98c66a3c8500d2969004ce766a15dafdd 100644 --- a/spec/helpers/ci/jobs_helper_spec.rb +++ b/spec/helpers/ci/jobs_helper_spec.rb @@ -9,6 +9,10 @@ let_it_be(:user) { create(:user) } let_it_be(:report) { create(:ci_build_report_result, build: job, project: project) } + before_all do + project.add_maintainer(user) + end + before do helper.instance_variable_set(:@project, project) helper.instance_variable_set(:@build, job) @@ -30,7 +34,8 @@ "runner_settings_url" => "/#{project.full_path}/-/runners#js-runners-settings", "retry_outdated_job_docs_url" => "/help/ci/pipelines/settings.md#prevent-outdated-deployment-jobs", "pipeline_test_report_url" => "/#{project.full_path}/-/pipelines/#{job.pipeline.id}/test_report", - "log_viewer_path" => "/#{project.full_path}/-/jobs/#{job.id}/viewer" + "log_viewer_path" => "/#{project.full_path}/-/jobs/#{job.id}/viewer", + "user_role" => "Maintainer" }) end diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb index 20915f06a8fe738df92c419bef6905ab4cb8cd37..ac9da81f368711e336ca17be426ab6645a611b35 100644 --- a/spec/lib/gitlab/ci/status/build/manual_spec.rb +++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb @@ -21,10 +21,7 @@ context 'when the job has not been played' do it 'instructs the user about possible actions' do expect(subject.illustration[:content]).to eq( - _( - 'This job does not start automatically and must be started manually. ' \ - 'You can add CI/CD variables below for last-minute configuration changes before starting the job.' - ) + _('This job does not start automatically and must be started manually.') ) end end