diff options
Diffstat (limited to 'spec')
-rw-r--r-- | spec/controllers/projects/pages_controller_spec.rb | 121 | ||||
-rw-r--r-- | spec/factories/users.rb | 4 | ||||
-rw-r--r-- | spec/features/projects/pipelines/pipelines_spec.rb | 4 | ||||
-rw-r--r-- | spec/frontend/issues/show/components/description_spec.js | 392 | ||||
-rw-r--r-- | spec/lib/gitlab/ci/pipeline/duration_spec.rb | 204 | ||||
-rw-r--r-- | spec/lib/gitlab/pages/random_domain_spec.rb | 44 | ||||
-rw-r--r-- | spec/lib/object_storage/config_spec.rb | 7 | ||||
-rw-r--r-- | spec/models/concerns/has_user_type_spec.rb | 4 | ||||
-rw-r--r-- | spec/models/project_setting_spec.rb | 38 | ||||
-rw-r--r-- | spec/models/project_spec.rb | 185 | ||||
-rw-r--r-- | spec/policies/global_policy_spec.rb | 31 | ||||
-rw-r--r-- | spec/services/markup/rendering_service_spec.rb | 17 | ||||
-rw-r--r-- | spec/services/projects/update_service_spec.rb | 106 |
13 files changed, 790 insertions, 367 deletions
diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb index 136f98ac907..ded5dd57e3e 100644 --- a/spec/controllers/projects/pages_controller_spec.rb +++ b/spec/controllers/projects/pages_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::PagesController do +RSpec.describe Projects::PagesController, feature_category: :pages do let(:user) { create(:user) } let(:project) { create(:project, :public) } @@ -14,7 +14,12 @@ RSpec.describe Projects::PagesController do end before do - allow(Gitlab.config.pages).to receive(:enabled).and_return(true) + stub_config(pages: { + enabled: true, + external_https: true, + access_control: false + }) + sign_in(user) project.add_maintainer(user) end @@ -123,49 +128,99 @@ RSpec.describe Projects::PagesController do end describe 'PATCH update' do - let(:request_params) do - { - namespace_id: project.namespace, - project_id: project, - project: { pages_https_only: 'false' } - } - end + context 'when updating pages_https_only' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + project: { pages_https_only: 'true' } + } + end - let(:update_service) { double(execute: { status: :success }) } + it 'updates project field and redirects back to the pages settings' do + project.update!(pages_https_only: false) - before do - allow(Projects::UpdateService).to receive(:new) { update_service } - end + expect { patch :update, params: request_params } + .to change { project.reload.pages_https_only } + .from(false).to(true) - it 'returns 302 status' do - patch :update, params: request_params + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(project_pages_path(project)) + end - expect(response).to have_gitlab_http_status(:found) - end + context 'when it fails to update' do + it 'adds an error message' do + expect_next_instance_of(Projects::UpdateService) do |service| + expect(service) + .to receive(:execute) + .and_return(status: :error, message: 'some error happened') + end - it 'redirects back to the pages settings' do - patch :update, params: request_params + expect { patch :update, params: request_params } + .not_to change { project.reload.pages_https_only } - expect(response).to redirect_to(project_pages_path(project)) + expect(response).to redirect_to(project_pages_path(project)) + expect(flash[:alert]).to eq('some error happened') + end + end end - it 'calls the update service' do - expect(Projects::UpdateService) - .to receive(:new) - .with(project, user, ActionController::Parameters.new(request_params[:project]).permit!) - .and_return(update_service) + context 'when updating pages_unique_domain' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + project: { + project_setting_attributes: { + pages_unique_domain_enabled: 'true' + } + } + } + end - patch :update, params: request_params - end + before do + create(:project_setting, project: project, pages_unique_domain_enabled: false) + end - context 'when update_service returns an error message' do - let(:update_service) { double(execute: { status: :error, message: 'some error happened' }) } + context 'with pages_unique_domain feature flag disabled' do + it 'does not update pages unique domain' do + stub_feature_flags(pages_unique_domain: false) - it 'adds an error message' do - patch :update, params: request_params + expect { patch :update, params: request_params } + .not_to change { project.project_setting.reload.pages_unique_domain_enabled } + end + end - expect(response).to redirect_to(project_pages_path(project)) - expect(flash[:alert]).to eq('some error happened') + context 'with pages_unique_domain feature flag enabled' do + before do + stub_feature_flags(pages_unique_domain: true) + end + + it 'updates pages_https_only and pages_unique_domain and redirects back to pages settings' do + expect { patch :update, params: request_params } + .to change { project.project_setting.reload.pages_unique_domain_enabled } + .from(false).to(true) + + expect(project.project_setting.pages_unique_domain).not_to be_nil + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(project_pages_path(project)) + end + + context 'when it fails to update' do + it 'adds an error message' do + expect_next_instance_of(Projects::UpdateService) do |service| + expect(service) + .to receive(:execute) + .and_return(status: :error, message: 'some error happened') + end + + expect { patch :update, params: request_params } + .not_to change { project.project_setting.reload.pages_unique_domain_enabled } + + expect(response).to redirect_to(project_pages_path(project)) + expect(flash[:alert]).to eq('some error happened') + end + end end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index e641f925758..a3a2af52807 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -59,6 +59,10 @@ FactoryBot.define do user_type { :project_bot } end + trait :service_account do + user_type { :service_account } + end + trait :migration_bot do user_type { :migration_bot } end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index b5f640f1cca..e3c2402b2c9 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -695,7 +695,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do end context 'when variables are specified' do - it 'creates a new pipeline with variables', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/375552' do + it 'creates a new pipeline with variables' do page.within(find("[data-testid='ci-variable-row']")) do find("[data-testid='pipeline-form-ci-variable-key']").set('key_name') find("[data-testid='pipeline-form-ci-variable-value']").set('value') @@ -721,7 +721,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do it { expect(page).to have_content('Missing CI config file') } - it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file is available when trying again', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/375552' do + it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file is available when trying again' do stub_ci_pipeline_to_return_yaml_file expect do diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 3f4513e6bfa..da51372dd3d 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -310,69 +310,58 @@ describe('Description component', () => { }); }); - describe('with work_items_mvc feature flag enabled', () => { - describe('empty description', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: '', - }, - provide: { - glFeatures: { - workItemsMvc: true, - }, - }, - }); - return nextTick(); + describe('empty description', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: '', + }, }); + return nextTick(); + }); - it('renders without error', () => { - expect(findTaskActionButtons()).toHaveLength(0); - }); + it('renders without error', () => { + expect(findTaskActionButtons()).toHaveLength(0); }); + }); - describe('description with checkboxes', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithCheckboxes, - }, - provide: { - glFeatures: { - workItemsMvc: true, - }, - }, - }); - return nextTick(); + describe('description with checkboxes', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithCheckboxes, + }, }); + return nextTick(); + }); - it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => { - expect(findTaskActionButtons()).toHaveLength(3); - }); + it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => { + expect(findTaskActionButtons()).toHaveLength(3); + }); - it('does not show a modal by default', () => { - expect(findModal().exists()).toBe(false); - }); + it('does not show a modal by default', () => { + expect(findModal().exists()).toBe(false); + }); - it('shows toast after delete success', async () => { - const newDesc = 'description'; - findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); + it('shows toast after delete success', async () => { + const newDesc = 'description'; + findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); - expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); - expect($toast.show).toHaveBeenCalledWith('Task deleted'); - }); + expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); + expect($toast.show).toHaveBeenCalledWith('Task deleted'); }); + }); - describe('task list item actions', () => { - describe('converting the task list item to a task', () => { - describe('when successful', () => { - let createWorkItemMutationHandler; + describe('task list item actions', () => { + describe('converting the task list item to a task', () => { + describe('when successful', () => { + let createWorkItemMutationHandler; - beforeEach(async () => { - createWorkItemMutationHandler = jest - .fn() - .mockResolvedValue(createWorkItemMutationResponse); - const descriptionText = `Tasks + beforeEach(async () => { + createWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(createWorkItemMutationResponse); + const descriptionText = `Tasks 1. [ ] item 1 1. [ ] item 2 @@ -381,218 +370,207 @@ describe('Description component', () => { 1. [ ] item 3 1. [ ] item 4;`; - createComponent({ - props: { descriptionText }, - provide: { glFeatures: { workItemsMvc: true } }, - createWorkItemMutationHandler, - }); - await waitForPromises(); - - eventHub.$emit('convert-task-list-item', '4:4-8:19'); - await waitForPromises(); + createComponent({ + props: { descriptionText }, + createWorkItemMutationHandler, }); + await waitForPromises(); - it('emits an event to update the description with the deleted task list item omitted', () => { - const newDescriptionText = `Tasks + eventHub.$emit('convert-task-list-item', '4:4-8:19'); + await waitForPromises(); + }); + + it('emits an event to update the description with the deleted task list item omitted', () => { + const newDescriptionText = `Tasks 1. [ ] item 1 1. [ ] item 3 1. [ ] item 4;`; - expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); - }); + expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); + }); - it('calls a mutation to create a task', () => { - const { + it('calls a mutation to create a task', () => { + const { + confidential, + iteration, + milestone, + } = issueDetailsResponse.data.workspace.issuable; + expect(createWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { confidential, - iteration, - milestone, - } = issueDetailsResponse.data.workspace.issuable; - expect(createWorkItemMutationHandler).toHaveBeenCalledWith({ - input: { - confidential, - description: '\nparagraph text\n', - hierarchyWidget: { - parentId: 'gid://gitlab/WorkItem/1', - }, - iterationWidget: { - iterationId: IS_EE ? iteration.id : null, - }, - milestoneWidget: { - milestoneId: milestone.id, - }, - projectPath: 'gitlab-org/gitlab-test', - title: 'item 2', - workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + description: '\nparagraph text\n', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + iterationWidget: { + iterationId: IS_EE ? iteration.id : null, + }, + milestoneWidget: { + milestoneId: milestone.id, }, - }); + projectPath: 'gitlab-org/gitlab-test', + title: 'item 2', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + }, }); + }); - it('shows a toast to confirm the creation of the task', () => { - expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object)); - }); + it('shows a toast to confirm the creation of the task', () => { + expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object)); }); + }); - describe('when unsuccessful', () => { - beforeEach(async () => { - createComponent({ - props: { descriptionText: 'description' }, - provide: { glFeatures: { workItemsMvc: true } }, - createWorkItemMutationHandler: jest - .fn() - .mockResolvedValue(createWorkItemMutationErrorResponse), - }); - await waitForPromises(); - - eventHub.$emit('convert-task-list-item', '1:1-1:11'); - await waitForPromises(); + describe('when unsuccessful', () => { + beforeEach(async () => { + createComponent({ + props: { descriptionText: 'description' }, + createWorkItemMutationHandler: jest + .fn() + .mockResolvedValue(createWorkItemMutationErrorResponse), }); + await waitForPromises(); - it('shows an alert with an error message', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Something went wrong when creating task. Please try again.', - error: new Error('an error'), - captureError: true, - }); + eventHub.$emit('convert-task-list-item', '1:1-1:11'); + await waitForPromises(); + }); + + it('shows an alert with an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong when creating task. Please try again.', + error: new Error('an error'), + captureError: true, }); }); }); + }); - describe('deleting the task list item', () => { - it('emits an event to update the description with the deleted task list item', () => { - const descriptionText = `Tasks + describe('deleting the task list item', () => { + it('emits an event to update the description with the deleted task list item', () => { + const descriptionText = `Tasks 1. [ ] item 1 1. [ ] item 2 1. [ ] item 3 1. [ ] item 4;`; - const newDescriptionText = `Tasks + const newDescriptionText = `Tasks 1. [ ] item 1 1. [ ] item 3 1. [ ] item 4;`; - createComponent({ - props: { descriptionText }, - provide: { glFeatures: { workItemsMvc: true } }, - }); + createComponent({ + props: { descriptionText }, + }); - eventHub.$emit('delete-task-list-item', '4:4-5:19'); + eventHub.$emit('delete-task-list-item', '4:4-5:19'); - expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); - }); + expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); }); }); + }); - describe('work items detail', () => { - describe('when opening and closing', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithTask, - }, - provide: { - glFeatures: { workItemsMvc: true }, - }, - }); - return nextTick(); + describe('work items detail', () => { + describe('when opening and closing', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, }); + return nextTick(); + }); - it('opens when task button is clicked', async () => { - await findTaskLink().trigger('click'); + it('opens when task button is clicked', async () => { + await findTaskLink().trigger('click'); - expect(showDetailsModal).toHaveBeenCalled(); - expect(updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/?work_item_id=2`, - replace: true, - }); + expect(showDetailsModal).toHaveBeenCalled(); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?work_item_id=2`, + replace: true, }); + }); - it('closes from an open state', async () => { - await findTaskLink().trigger('click'); + it('closes from an open state', async () => { + await findTaskLink().trigger('click'); - findWorkItemDetailModal().vm.$emit('close'); - await nextTick(); + findWorkItemDetailModal().vm.$emit('close'); + await nextTick(); - expect(updateHistory).toHaveBeenLastCalledWith({ - url: `${TEST_HOST}/`, - replace: true, - }); + expect(updateHistory).toHaveBeenLastCalledWith({ + url: `${TEST_HOST}/`, + replace: true, }); + }); - it('tracks when opened', async () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - await findTaskLink().trigger('click'); + it('tracks when opened', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - expect(trackingSpy).toHaveBeenCalledWith( - TRACKING_CATEGORY_SHOW, - 'viewed_work_item_from_modal', - { - category: TRACKING_CATEGORY_SHOW, - label: 'work_item_view', - property: 'type_task', - }, - ); - }); - }); + await findTaskLink().trigger('click'); - describe('when url query `work_item_id` exists', () => { - it.each` - behavior | workItemId | modalOpened - ${'opens'} | ${'2'} | ${1} - ${'does not open'} | ${'123'} | ${0} - ${'does not open'} | ${'123e'} | ${0} - ${'does not open'} | ${'12e3'} | ${0} - ${'does not open'} | ${'1e23'} | ${0} - ${'does not open'} | ${'x'} | ${0} - ${'does not open'} | ${'undefined'} | ${0} - `( - '$behavior when url contains `work_item_id=$workItemId`', - async ({ workItemId, modalOpened }) => { - setWindowLocation(`?work_item_id=${workItemId}`); - - createComponent({ - props: { descriptionHtml: descriptionHtmlWithTask }, - provide: { glFeatures: { workItemsMvc: true } }, - }); - - expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); + expect(trackingSpy).toHaveBeenCalledWith( + TRACKING_CATEGORY_SHOW, + 'viewed_work_item_from_modal', + { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_view', + property: 'type_task', }, ); }); }); - describe('when hovering task links', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithTask, - }, - provide: { - glFeatures: { workItemsMvc: true }, - }, - }); - return nextTick(); - }); + describe('when url query `work_item_id` exists', () => { + it.each` + behavior | workItemId | modalOpened + ${'opens'} | ${'2'} | ${1} + ${'does not open'} | ${'123'} | ${0} + ${'does not open'} | ${'123e'} | ${0} + ${'does not open'} | ${'12e3'} | ${0} + ${'does not open'} | ${'1e23'} | ${0} + ${'does not open'} | ${'x'} | ${0} + ${'does not open'} | ${'undefined'} | ${0} + `( + '$behavior when url contains `work_item_id=$workItemId`', + async ({ workItemId, modalOpened }) => { + setWindowLocation(`?work_item_id=${workItemId}`); - it('prefetches work item detail after work item link is hovered for 150ms', async () => { - await findTaskLink().trigger('mouseover'); - jest.advanceTimersByTime(150); - await waitForPromises(); + createComponent({ + props: { descriptionHtml: descriptionHtmlWithTask }, + }); - expect(queryHandler).toHaveBeenCalledWith({ - id: 'gid://gitlab/WorkItem/2', - }); + expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); + }, + ); + }); + }); + + describe('when hovering task links', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, }); + return nextTick(); + }); - it('does not work item detail after work item link is hovered for less than 150ms', async () => { - await findTaskLink().trigger('mouseover'); - await findTaskLink().trigger('mouseout'); - jest.advanceTimersByTime(150); - await waitForPromises(); + it('prefetches work item detail after work item link is hovered for 150ms', async () => { + await findTaskLink().trigger('mouseover'); + jest.advanceTimersByTime(150); + await waitForPromises(); - expect(queryHandler).not.toHaveBeenCalled(); + expect(queryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', }); }); + + it('does not work item detail after work item link is hovered for less than 150ms', async () => { + await findTaskLink().trigger('mouseover'); + await findTaskLink().trigger('mouseout'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/lib/gitlab/ci/pipeline/duration_spec.rb b/spec/lib/gitlab/ci/pipeline/duration_spec.rb index 36714413da6..088a901c80e 100644 --- a/spec/lib/gitlab/ci/pipeline/duration_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/duration_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Pipeline::Duration do +RSpec.describe Gitlab::Ci::Pipeline::Duration, feature_category: :continuous_integration do describe '.from_periods' do let(:calculated_duration) { calculate(data) } @@ -113,16 +113,17 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do described_class::Period.new(first, last) end - described_class.from_periods(periods.sort_by(&:first)) + described_class.send(:from_periods, periods.sort_by(&:first)) end end describe '.from_pipeline' do + let_it_be_with_reload(:pipeline) { create(:ci_pipeline) } + let_it_be(:start_time) { Time.current.change(usec: 0) } let_it_be(:current) { start_time + 1000 } - let_it_be(:pipeline) { create(:ci_pipeline) } - let_it_be(:success_build) { create_build(:success, started_at: start_time, finished_at: start_time + 60) } - let_it_be(:failed_build) { create_build(:failed, started_at: start_time + 60, finished_at: start_time + 120) } + let_it_be(:success_build) { create_build(:success, started_at: start_time, finished_at: start_time + 50) } + let_it_be(:failed_build) { create_build(:failed, started_at: start_time + 60, finished_at: start_time + 110) } let_it_be(:canceled_build) { create_build(:canceled, started_at: start_time + 120, finished_at: start_time + 180) } let_it_be(:skipped_build) { create_build(:skipped, started_at: start_time) } let_it_be(:pending_build) { create_build(:pending) } @@ -141,21 +142,55 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do end context 'when there is no running build' do - let(:running_build) { nil } + let!(:running_build) { nil } it 'returns the duration for all the builds' do travel_to(current) do - expect(described_class.from_pipeline(pipeline)).to eq 180.seconds + # 160 = success (50) + failed (50) + canceled (60) + expect(described_class.from_pipeline(pipeline)).to eq 160.seconds end end end - context 'when there are bridge jobs' do - let!(:success_bridge) { create_bridge(:success, started_at: start_time + 220, finished_at: start_time + 280) } - let!(:failed_bridge) { create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 240) } - let!(:skipped_bridge) { create_bridge(:skipped, started_at: start_time) } - let!(:created_bridge) { create_bridge(:created) } - let!(:manual_bridge) { create_bridge(:manual) } + context 'when there are direct bridge jobs' do + let_it_be(:success_bridge) do + create_bridge(:success, started_at: start_time + 220, finished_at: start_time + 280) + end + + let_it_be(:failed_bridge) { create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 240) } + # NOTE: bridge won't be `canceled` as it will be marked as failed when downstream pipeline is canceled + # @see Ci::Bridge#inherit_status_from_downstream + let_it_be(:canceled_bridge) do + create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 210) + end + + let_it_be(:skipped_bridge) { create_bridge(:skipped, started_at: start_time) } + let_it_be(:created_bridge) { create_bridge(:created) } + let_it_be(:manual_bridge) { create_bridge(:manual) } + + let_it_be(:success_bridge_pipeline) do + create(:ci_pipeline, :success, started_at: start_time + 230, finished_at: start_time + 280).tap do |p| + create(:ci_sources_pipeline, source_job: success_bridge, pipeline: p) + create_build(:success, pipeline: p, started_at: start_time + 235, finished_at: start_time + 280) + create_bridge(:success, pipeline: p, started_at: start_time + 240, finished_at: start_time + 280) + end + end + + let_it_be(:failed_bridge_pipeline) do + create(:ci_pipeline, :failed, started_at: start_time + 225, finished_at: start_time + 240).tap do |p| + create(:ci_sources_pipeline, source_job: failed_bridge, pipeline: p) + create_build(:failed, pipeline: p, started_at: start_time + 230, finished_at: start_time + 240) + create_bridge(:success, pipeline: p, started_at: start_time + 235, finished_at: start_time + 240) + end + end + + let_it_be(:canceled_bridge_pipeline) do + create(:ci_pipeline, :canceled, started_at: start_time + 190, finished_at: start_time + 210).tap do |p| + create(:ci_sources_pipeline, source_job: canceled_bridge, pipeline: p) + create_build(:canceled, pipeline: p, started_at: start_time + 200, finished_at: start_time + 210) + create_bridge(:success, pipeline: p, started_at: start_time + 205, finished_at: start_time + 210) + end + end it 'returns the duration of the running build' do travel_to(current) do @@ -166,12 +201,147 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do context 'when there is no running build' do let!(:running_build) { nil } - it 'returns the duration for all the builds and bridge jobs' do + it 'returns the duration for all the builds (including self and downstreams)' do + travel_to(current) do + # 220 = 160 (see above) + # + success build (45) + failed (10) + canceled (10) - overlapping (success & failed) (5) + expect(described_class.from_pipeline(pipeline)).to eq 220.seconds + end + end + end + + context 'when feature flag ci_use_downstream_pipeline_duration_for_calculation is disabled' do + before do + stub_feature_flags(ci_use_downstream_pipeline_duration_for_calculation: false) + end + + it 'returns the duration of the running build' do + travel_to(current) do + expect(described_class.from_pipeline(pipeline)).to eq 1000.seconds + end + end + + context 'when there is no running build' do + let!(:running_build) { nil } + + it 'returns the duration for builds and bridges' do + travel_to(current) do + # 260 = 160 (see above) + # + success bridge build (60) + failed (60) + canceled (30) + # - overlapping (success & failed) (20) - overlapping (failed & canceled) (30) + expect(described_class.from_pipeline(pipeline)).to eq 260.seconds + end + end + end + end + + # rubocop:disable RSpec/MultipleMemoizedHelpers + context 'when there are downstream bridge jobs' do + let_it_be(:success_direct_bridge) do + create_bridge(:success, started_at: start_time + 280, finished_at: start_time + 400) + end + + let_it_be(:success_downstream_pipeline) do + create(:ci_pipeline, :success, started_at: start_time + 285, finished_at: start_time + 300).tap do |p| + create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p) + create_build(:success, pipeline: p, started_at: start_time + 290, finished_at: start_time + 296) + create_bridge(:success, pipeline: p, started_at: start_time + 285, finished_at: start_time + 288) + end + end + + let_it_be(:failed_downstream_pipeline) do + create(:ci_pipeline, :failed, started_at: start_time + 305, finished_at: start_time + 350).tap do |p| + create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p) + create_build(:failed, pipeline: p, started_at: start_time + 320, finished_at: start_time + 327) + create_bridge(:success, pipeline: p, started_at: start_time + 305, finished_at: start_time + 350) + end + end + + let_it_be(:canceled_downstream_pipeline) do + create(:ci_pipeline, :canceled, started_at: start_time + 360, finished_at: start_time + 400).tap do |p| + create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p) + create_build(:canceled, pipeline: p, started_at: start_time + 390, finished_at: start_time + 398) + create_bridge(:success, pipeline: p, started_at: start_time + 360, finished_at: start_time + 378) + end + end + + it 'returns the duration of the running build' do travel_to(current) do - expect(described_class.from_pipeline(pipeline)).to eq 280.seconds + expect(described_class.from_pipeline(pipeline)).to eq 1000.seconds + end + end + + context 'when there is no running build' do + let!(:running_build) { nil } + + it 'returns the duration for all the builds (including self and downstreams)' do + travel_to(current) do + # 241 = 220 (see above) + # + success downstream build (6) + failed (7) + canceled (8) + expect(described_class.from_pipeline(pipeline)).to eq 241.seconds + end + end + end + + context 'when feature flag ci_use_downstream_pipeline_duration_for_calculation is disabled' do + before do + stub_feature_flags(ci_use_downstream_pipeline_duration_for_calculation: false) + end + + it 'returns the duration of the running build' do + travel_to(current) do + expect(described_class.from_pipeline(pipeline)).to eq 1000.seconds + end + end + + context 'when there is no running build' do + let!(:running_build) { nil } + + it 'returns the duration for builds and bridges' do + travel_to(current) do + # 380 = 260 (see above) + success direct bridge (120) + expect(described_class.from_pipeline(pipeline)).to eq 380.seconds + end + end end end end + # rubocop:enable RSpec/MultipleMemoizedHelpers + end + + it 'does not generate N+1 queries if more builds are added' do + travel_to(current) do + expect do + described_class.from_pipeline(pipeline) + end.not_to exceed_query_limit(1) + + create_list(:ci_build, 2, :success, pipeline: pipeline, started_at: start_time, finished_at: start_time + 50) + + expect do + described_class.from_pipeline(pipeline) + end.not_to exceed_query_limit(1) + end + end + + it 'does not generate N+1 queries if more bridges and their pipeline builds are added' do + travel_to(current) do + expect do + described_class.from_pipeline(pipeline) + end.not_to exceed_query_limit(1) + + create_list( + :ci_bridge, 2, :success, + pipeline: pipeline, started_at: start_time + 220, finished_at: start_time + 280).each do |bridge| + create(:ci_pipeline, :success, started_at: start_time + 235, finished_at: start_time + 280).tap do |p| + create(:ci_sources_pipeline, source_job: bridge, pipeline: p) + create_builds(3, :success) + end + end + + expect do + described_class.from_pipeline(pipeline) + end.not_to exceed_query_limit(1) + end end private @@ -180,6 +350,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do create(:ci_build, trait, pipeline: pipeline, **opts) end + def create_builds(counts, trait, **opts) + create_list(:ci_build, counts, trait, pipeline: pipeline, **opts) + end + def create_bridge(trait, **opts) create(:ci_bridge, trait, pipeline: pipeline, **opts) end diff --git a/spec/lib/gitlab/pages/random_domain_spec.rb b/spec/lib/gitlab/pages/random_domain_spec.rb new file mode 100644 index 00000000000..978412bb72c --- /dev/null +++ b/spec/lib/gitlab/pages/random_domain_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pages::RandomDomain, feature_category: :pages do + let(:namespace_path) { 'namespace' } + + subject(:generator) do + described_class.new(project_path: project_path, namespace_path: namespace_path) + end + + RSpec.shared_examples 'random domain' do |domain| + it do + expect(SecureRandom) + .to receive(:hex) + .and_wrap_original do |_, size, _| + ('h' * size) + end + + generated = generator.generate + + expect(generated).to eq(domain) + expect(generated.length).to eq(63) + end + end + + context 'when project path is less than 48 chars' do + let(:project_path) { 'p' } + + it_behaves_like 'random domain', 'p-namespace-hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh' + end + + context 'when project path is close to 48 chars' do + let(:project_path) { 'p' * 45 } + + it_behaves_like 'random domain', 'ppppppppppppppppppppppppppppppppppppppppppppp-na-hhhhhhhhhhhhhh' + end + + context 'when project path is larger than 48 chars' do + let(:project_path) { 'p' * 49 } + + it_behaves_like 'random domain', 'pppppppppppppppppppppppppppppppppppppppppppppppp-hhhhhhhhhhhhhh' + end +end diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb index 2a81142ea44..3099468c07d 100644 --- a/spec/lib/object_storage/config_spec.rb +++ b/spec/lib/object_storage/config_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' require 'rspec-parameterized' require 'fog/core' @@ -130,6 +130,11 @@ RSpec.describe ObjectStorage::Config do it { expect(subject.provider).to eq('AWS') } it { expect(subject.aws?).to be true } it { expect(subject.google?).to be false } + it { expect(subject.credentials).to eq(credentials) } + + context 'with FIPS enabled', :fips_mode do + it { expect(subject.credentials).to eq(credentials.merge(disable_content_md5_validation: true)) } + end end context 'with Google credentials' do diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb index bd128112113..0f90bbcda4e 100644 --- a/spec/models/concerns/has_user_type_spec.rb +++ b/spec/models/concerns/has_user_type_spec.rb @@ -2,11 +2,11 @@ require 'spec_helper' -RSpec.describe User do +RSpec.describe User, feature_category: :authentication_and_authorization do specify 'types consistency checks', :aggregate_failures do expect(described_class::USER_TYPES.keys) .to match_array(%w[human ghost alert_bot project_bot support_bot service_user security_bot visual_review_bot - migration_bot automation_bot admin_bot suggested_reviewers_bot]) + migration_bot automation_bot admin_bot suggested_reviewers_bot service_account]) expect(described_class::USER_TYPES).to include(*described_class::BOT_USER_TYPES) expect(described_class::USER_TYPES).to include(*described_class::NON_INTERNAL_USER_TYPES) expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES) diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb index feb5985818b..42433a2a84a 100644 --- a/spec/models/project_setting_spec.rb +++ b/spec/models/project_setting_spec.rb @@ -39,6 +39,44 @@ RSpec.describe ProjectSetting, type: :model do [nil, 'not_allowed', :invalid].each do |invalid_value| it { is_expected.not_to allow_value([invalid_value]).for(:target_platforms) } end + + context "when pages_unique_domain is required", feature_category: :pages do + it "is not required if pages_unique_domain_enabled is false" do + project_setting = build(:project_setting, pages_unique_domain_enabled: false) + + expect(project_setting).to be_valid + expect(project_setting.errors.full_messages).not_to include("Pages unique domain can't be blank") + end + + it "is required when pages_unique_domain_enabled is true" do + project_setting = build(:project_setting, pages_unique_domain_enabled: true) + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank") + end + + it "is required if it is already saved in the database" do + project_setting = create( + :project_setting, + pages_unique_domain: "random-unique-domain-here", + pages_unique_domain_enabled: true + ) + + project_setting.pages_unique_domain = nil + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank") + end + end + + it "validates uniqueness of pages_unique_domain", feature_category: :pages do + create(:project_setting, pages_unique_domain: "random-unique-domain-here") + + project_setting = build(:project_setting, pages_unique_domain: "random-unique-domain-here") + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain has already been taken") + end end describe 'target_platforms=' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index dfc8919e19d..5304fec506e 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2666,7 +2666,11 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end describe '#pages_url', feature_category: :pages do + let(:group_name) { 'group' } + let(:project_name) { 'project' } + let(:group) { create(:group, name: group_name) } + let(:nested_group) { create(:group, parent: group) } let(:project_path) { project_name.downcase } let(:project) do @@ -2689,101 +2693,114 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do .and_return(['http://example.com', port].compact.join(':')) end - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } + context 'when pages_unique_domain feature flag is disabled' do + before do + stub_feature_flags(pages_unique_domain: false) + end - it { is_expected.to eq("http://group.example.com") } + it { is_expected.to eq('http://group.example.com/project') } + end - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + context 'when pages_unique_domain feature flag is enabled' do + before do + stub_feature_flags(pages_unique_domain: true) - it { is_expected.to eq("http://group.example.com") } + project.project_setting.update!( + pages_unique_domain_enabled: pages_unique_domain_enabled, + pages_unique_domain: 'unique-domain' + ) end - end - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'when pages_unique_domain_enabled is false' do + let(:pages_unique_domain_enabled) { false } - it { is_expected.to eq("http://group.example.com/project") } + it { is_expected.to eq('http://group.example.com/project') } + end - context 'mixed case path' do - let(:project_path) { 'Project' } + context 'when pages_unique_domain_enabled is false' do + let(:pages_unique_domain_enabled) { true } - it { is_expected.to eq("http://group.example.com/Project") } + it { is_expected.to eq('http://unique-domain.example.com') } end end - context 'when there is an explicit port' do - let(:port) { 3000 } - - context 'when not in dev mode' do - before do - stub_rails_env('production') - end + context 'with nested group' do + let(:project) { create(:project, namespace: nested_group, name: project_name) } + let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" } - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } + context 'group page' do + let(:project_name) { 'group.example.com' } - it { is_expected.to eq('http://group.example.com:3000/group.example.com') } + it { is_expected.to eq(expected_url) } + end - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + context 'project page' do + let(:project_name) { 'Project' } - it { is_expected.to eq('http://group.example.com:3000/Group.example.com') } - end - end + it { is_expected.to eq(expected_url) } + end + end - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'when the project matches its namespace url' do + let(:project_name) { 'group.example.com' } - it { is_expected.to eq("http://group.example.com:3000/project") } + it { is_expected.to eq('http://group.example.com') } - context 'mixed case path' do - let(:project_path) { 'Project' } + context 'with different group name capitalization' do + let(:group_name) { 'Group' } - it { is_expected.to eq("http://group.example.com:3000/Project") } - end - end + it { is_expected.to eq("http://group.example.com") } end - context 'when in dev mode' do - before do - stub_rails_env('development') - end - - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } + context 'with different project path capitalization' do + let(:project_path) { 'Group.example.com' } - it { is_expected.to eq('http://group.example.com:3000') } + it { is_expected.to eq("http://group.example.com") } + end - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + context 'with different project name capitalization' do + let(:project_name) { 'Project' } - it { is_expected.to eq('http://group.example.com:3000') } - end - end + it { is_expected.to eq("http://group.example.com/project") } + end - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'when there is an explicit port' do + let(:port) { 3000 } - it { is_expected.to eq("http://group.example.com:3000/project") } + context 'when not in dev mode' do + before do + stub_rails_env('production') + end - context 'mixed case path' do - let(:project_path) { 'Project' } + it { is_expected.to eq('http://group.example.com:3000/group.example.com') } + end - it { is_expected.to eq("http://group.example.com:3000/Project") } + context 'when in dev mode' do + before do + stub_rails_env('development') end + + it { is_expected.to eq('http://group.example.com:3000') } end end end end + describe '#pages_unique_url', feature_category: :pages do + let(:project_settings) { create(:project_setting, pages_unique_domain: 'unique-domain') } + let(:project) { build(:project, project_setting: project_settings) } + let(:domain) { 'example.com' } + + before do + allow(Settings.pages).to receive(:host).and_return(domain) + allow(Gitlab.config.pages).to receive(:url).and_return("http://#{domain}") + end + + it 'returns the pages unique url' do + expect(project.pages_unique_url).to eq('http://unique-domain.example.com') + end + end + describe '#pages_namespace_url', feature_category: :pages do let(:group) { create(:group, name: group_name) } let(:project) { create(:project, namespace: group, name: project_name) } @@ -4643,52 +4660,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe '#pages_url' do - let(:group) { create(:group, name: 'Group') } - let(:nested_group) { create(:group, parent: group) } - let(:domain) { 'Example.com' } - - subject { project.pages_url } - - before do - allow(Settings.pages).to receive(:host).and_return(domain) - allow(Gitlab.config.pages).to receive(:url).and_return('http://example.com') - end - - context 'top-level group' do - let(:project) { create(:project, namespace: group, name: project_name) } - - context 'group page' do - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq("http://group.example.com") } - end - - context 'project page' do - let(:project_name) { 'Project' } - - it { is_expected.to eq("http://group.example.com/project") } - end - end - - context 'nested group' do - let(:project) { create(:project, namespace: nested_group, name: project_name) } - let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" } - - context 'group page' do - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq(expected_url) } - end - - context 'project page' do - let(:project_name) { 'Project' } - - it { is_expected.to eq(expected_url) } - end - end - end - describe '#lfs_http_url_to_repo' do let(:project) { create(:project) } diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 0575ba3237b..78bbff57572 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -7,6 +7,7 @@ RSpec.describe GlobalPolicy, feature_category: :shared do let_it_be(:admin_user) { create(:admin) } let_it_be(:project_bot) { create(:user, :project_bot) } + let_it_be(:service_account) { create(:user, :service_account) } let_it_be(:migration_bot) { create(:user, :migration_bot) } let_it_be(:security_bot) { create(:user, :security_bot) } let_it_be_with_reload(:current_user) { create(:user) } @@ -219,6 +220,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do it { is_expected.to be_allowed(:access_api) } end + context 'service account' do + let(:current_user) { service_account } + + it { is_expected.to be_allowed(:access_api) } + end + context 'migration bot' do let(:current_user) { migration_bot } @@ -345,6 +352,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do it { is_expected.to be_disallowed(:receive_notifications) } end + context 'service account' do + let(:current_user) { service_account } + + it { is_expected.to be_disallowed(:receive_notifications) } + end + context 'migration bot' do let(:current_user) { migration_bot } @@ -433,6 +446,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do it { is_expected.to be_allowed(:access_git) } end + context 'service account' do + let(:current_user) { service_account } + + it { is_expected.to be_allowed(:access_git) } + end + context 'user blocked pending approval' do before do current_user.block_pending_approval @@ -517,6 +536,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do it { is_expected.to be_allowed(:use_slash_commands) } end + context 'service account' do + let(:current_user) { service_account } + + it { is_expected.to be_allowed(:use_slash_commands) } + end + context 'migration bot' do let(:current_user) { migration_bot } @@ -571,6 +596,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do it { is_expected.to be_disallowed(:log_in) } end + context 'service account' do + let(:current_user) { service_account } + + it { is_expected.to be_disallowed(:log_in) } + end + context 'migration bot' do let(:current_user) { migration_bot } diff --git a/spec/services/markup/rendering_service_spec.rb b/spec/services/markup/rendering_service_spec.rb index 99ab87f2072..ca70e983714 100644 --- a/spec/services/markup/rendering_service_spec.rb +++ b/spec/services/markup/rendering_service_spec.rb @@ -111,5 +111,22 @@ RSpec.describe Markup::RenderingService do is_expected.to eq(expected_html) end end + + context 'with reStructuredText' do + let(:file_name) { 'foo.rst' } + let(:text) { "####\nPART\n####" } + + it 'returns rendered html' do + is_expected.to eq("<h1>PART</h1>\n\n") + end + + context 'when input has an invalid syntax' do + let(:text) { "####\nPART\n##" } + + it 'uses a simple formatter for html' do + is_expected.to eq("<p>####\n<br>PART\n<br>##</p>") + end + end + end end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 3cda6bc2627..57249bcd562 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -794,6 +794,112 @@ RSpec.describe Projects::UpdateService do expect(project.topic_list).to eq(%w[tag_list]) end end + + describe 'when updating pages unique domain', feature_category: :pages do + let(:group) { create(:group, path: 'group') } + let(:project) { create(:project, path: 'project', group: group) } + + context 'with pages_unique_domain feature flag disabled' do + before do + stub_feature_flags(pages_unique_domain: false) + end + + it 'does not change pages unique domain' do + expect(project) + .to receive(:update) + .with({ project_setting_attributes: { has_confluence: true } }) + .and_call_original + + expect do + update_project(project, user, project_setting_attributes: { + has_confluence: true, + pages_unique_domain_enabled: true + }) + end.not_to change { project.project_setting.pages_unique_domain_enabled } + end + + it 'does not remove other attributes' do + expect(project) + .to receive(:update) + .with({ name: 'True' }) + .and_call_original + + update_project(project, user, name: 'True') + end + end + + context 'with pages_unique_domain feature flag enabled' do + before do + stub_feature_flags(pages_unique_domain: true) + end + + it 'updates project pages unique domain' do + expect do + update_project(project, user, project_setting_attributes: { + pages_unique_domain_enabled: true + }) + end.to change { project.project_setting.pages_unique_domain_enabled } + + expect(project.project_setting.pages_unique_domain_enabled).to eq true + expect(project.project_setting.pages_unique_domain).to match %r{project-group-\w+} + end + + it 'does not changes unique domain when it already exists' do + project.project_setting.update!( + pages_unique_domain_enabled: false, + pages_unique_domain: 'unique-domain' + ) + + expect do + update_project(project, user, project_setting_attributes: { + pages_unique_domain_enabled: true + }) + end.to change { project.project_setting.pages_unique_domain_enabled } + + expect(project.project_setting.pages_unique_domain_enabled).to eq true + expect(project.project_setting.pages_unique_domain).to eq 'unique-domain' + end + + it 'does not changes unique domain when it disabling unique domain' do + project.project_setting.update!( + pages_unique_domain_enabled: true, + pages_unique_domain: 'unique-domain' + ) + + expect do + update_project(project, user, project_setting_attributes: { + pages_unique_domain_enabled: false + }) + end.not_to change { project.project_setting.pages_unique_domain } + + expect(project.project_setting.pages_unique_domain_enabled).to eq false + expect(project.project_setting.pages_unique_domain).to eq 'unique-domain' + end + + context 'when there is another project with the unique domain' do + it 'fails pages unique domain already exists' do + create( + :project_setting, + pages_unique_domain_enabled: true, + pages_unique_domain: 'unique-domain' + ) + + allow(Gitlab::Pages::RandomDomain) + .to receive(:generate) + .and_return('unique-domain') + + result = update_project(project, user, project_setting_attributes: { + pages_unique_domain_enabled: true + }) + + expect(result).to eq( + status: :error, + message: 'Project setting pages unique domain has already been taken' + ) + end + end + end + end end describe '#run_auto_devops_pipeline?' do |